From 5a97d6a6cb664d88dc241bfbdda19ace025d9f37 Mon Sep 17 00:00:00 2001 From: alexmerlin Date: Mon, 21 Oct 2024 18:23:05 +0300 Subject: [PATCH] Issue #314: Implemented mezzio-problem-details and handler delegators. Signed-off-by: alexmerlin --- README.md | 60 ++++++--- composer.json | 8 +- config/autoload/local.php.dist | 21 ++-- config/autoload/problem-details.global.php | 43 +++++++ config/pipeline.php | 4 + src/Admin/src/Command/AdminCreateCommand.php | 16 ++- src/Admin/src/ConfigProvider.php | 10 +- src/Admin/src/Handler/AdminAccountHandler.php | 28 ++--- .../src/Handler/AdminCollectionHandler.php | 22 +--- src/Admin/src/Handler/AdminHandler.php | 33 +++-- .../Handler/AdminRoleCollectionHandler.php | 22 +--- src/Admin/src/Handler/AdminRoleHandler.php | 22 +--- src/Admin/src/Service/AdminRoleService.php | 13 +- src/Admin/src/Service/AdminService.php | 31 +++-- src/App/src/Command/TokenGenerateCommand.php | 6 +- src/App/src/ConfigProvider.php | 8 +- src/App/src/Entity/AbstractEntity.php | 2 +- src/App/src/Entity/EntityInterface.php | 9 ++ src/App/src/Entity/EntityListenerResolver.php | 3 +- src/App/src/Entity/OAuthAccessToken.php | 16 ++- src/App/src/Exception/BadRequestException.php | 23 ++-- src/App/src/Exception/ConflictException.php | 20 ++- src/App/src/Exception/ExpiredException.php | 20 ++- src/App/src/Exception/ForbiddenException.php | 20 ++- .../Exception/MethodNotAllowedException.php | 20 ++- src/App/src/Exception/NotFoundException.php | 20 ++- src/App/src/Exception/RuntimeException.php | 29 +++++ .../src/Exception/UnauthorizedException.php | 20 ++- .../src/Factory/HandlerDelegatorFactory.php | 60 +++++++++ src/App/src/Handler/AbstractHandler.php | 119 ++++++++++++++++++ src/App/src/Handler/ErrorReportHandler.php | 22 +--- src/App/src/Handler/HandlerTrait.php | 62 --------- src/App/src/Handler/HomeHandler.php | 20 +-- src/App/src/Handler/NotFoundHandler.php | 5 +- src/App/src/Handler/ResponseTrait.php | 90 ------------- src/App/src/Message.php | 69 ++++++---- .../Middleware/AuthorizationMiddleware.php | 68 ++++------ .../ContentNegotiationMiddleware.php | 33 +++-- .../src/Middleware/DeprecationMiddleware.php | 13 +- .../Middleware/ErrorResponseMiddleware.php | 9 +- src/App/src/OpenAPI.php | 33 ++--- src/App/src/Service/ErrorReportService.php | 82 ++++++------ .../Service/ErrorReportServiceInterface.php | 2 +- src/User/src/ConfigProvider.php | 20 ++- .../src/Handler/AccountActivateHandler.php | 31 ++--- src/User/src/Handler/AccountAvatarHandler.php | 31 ++--- src/User/src/Handler/AccountHandler.php | 37 +++--- .../src/Handler/AccountRecoveryHandler.php | 27 ++-- .../Handler/AccountResetPasswordHandler.php | 46 +++---- src/User/src/Handler/UserActivateHandler.php | 24 +--- src/User/src/Handler/UserAvatarHandler.php | 22 ++-- .../src/Handler/UserCollectionHandler.php | 22 +--- src/User/src/Handler/UserHandler.php | 35 +++--- .../src/Handler/UserRoleCollectionHandler.php | 22 +--- src/User/src/Handler/UserRoleHandler.php | 22 +--- .../src/InputFilter/Input/AvatarInput.php | 4 +- src/User/src/Repository/UserRepository.php | 15 ++- src/User/src/Service/UserRoleService.php | 4 +- src/User/src/Service/UserService.php | 47 +++---- src/User/src/Service/UserServiceInterface.php | 7 -- test/Functional/AdminTest.php | 15 +-- test/Functional/UserTest.php | 47 +++---- test/Unit/Admin/Service/AdminServiceTest.php | 9 +- .../AuthorizationMiddlewareTest.php | 58 ++------- .../Middleware/DeprecationMiddlewareTest.php | 6 +- 65 files changed, 931 insertions(+), 856 deletions(-) create mode 100644 config/autoload/problem-details.global.php create mode 100644 src/App/src/Entity/EntityInterface.php create mode 100644 src/App/src/Exception/RuntimeException.php create mode 100644 src/App/src/Factory/HandlerDelegatorFactory.php create mode 100644 src/App/src/Handler/AbstractHandler.php delete mode 100644 src/App/src/Handler/HandlerTrait.php delete mode 100644 src/App/src/Handler/ResponseTrait.php diff --git a/README.md b/README.md index f83d7680..537e4327 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# DotKernel API +# Dotkernel API -Based on Enrico Zimuel's [Zend Expressive API - Skeleton example](https://github.com/ezimuel/zend-expressive-api), DotKernel API runs on [Laminas](https://github.com/laminas) and [Mezzio](https://github.com/mezzio) components and implements standards like PSR-3, PSR-4, PSR-7, PSR-11 and PSR-15. +Based on Enrico Zimuel's [Zend Expressive API - Skeleton example](https://github.com/ezimuel/zend-expressive-api), Dotkernel API runs on [Laminas](https://github.com/laminas) and [Mezzio](https://github.com/mezzio) components and implements standards like PSR-3, PSR-4, PSR-7, PSR-11 and PSR-15. ![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/api) -![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/api/5.0.x-dev) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/api/5.0.0) [![GitHub issues](https://img.shields.io/github/issues/dotkernel/api)](https://github.com/dotkernel/api/issues) [![GitHub forks](https://img.shields.io/github/forks/dotkernel/api)](https://github.com/dotkernel/api/network) @@ -14,33 +14,41 @@ Based on Enrico Zimuel's [Zend Expressive API - Skeleton example](https://github [![codecov](https://codecov.io/gh/dotkernel/api/graph/badge.svg?token=53FN78G5CK)](https://codecov.io/gh/dotkernel/api) [![Qodana](https://github.com/dotkernel/api/actions/workflows/qodana_code_quality.yml/badge.svg?branch=5.0)](https://github.com/dotkernel/api/actions/workflows/qodana_code_quality.yml) -[![SymfonyInsight](https://insight.symfony.com/projects/7f9143cc-5e3c-4cfc-992c-377a001fde3e/big.svg)](https://insight.symfony.com/projects/7f9143cc-5e3c-4cfc-992c-377a001fde3e) - ## Getting Started ## Step 1: Clone the project Using your terminal, navigate inside the directory you want to download the project files into. Make sure that the directory is empty before proceeding to the download process. Once there, run the following command: - git clone https://github.com/dotkernel/api.git . +```shell +git clone https://github.com/dotkernel/api.git . +``` ## Step 2: Install project's dependencies - composer install +```shell +composer install +``` ## Step 3: Development mode If you're installing the project for development, make sure you have development mode enabled, by running: - composer development-enable +```shell +composer development-enable +``` You can disable development mode by running: - composer development-disable +```shell +composer development-disable +``` You can check if you have development mode enabled by running: - composer development-status +```shell +composer development-status +``` ## Step 4: Prepare config files @@ -57,11 +65,13 @@ You can check if you have development mode enabled by running: * fill out the database connection params in `config/autoload/local.php` under `$databases['default']` * run the database migrations by using the following command: - php vendor/bin/doctrine-migrations migrate +```shell +php vendor/bin/doctrine-migrations migrate +``` This command will prompt you to confirm that you want to run it: - WARNING! You are about to execute a migration in database "..." that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no) [yes]: +> WARNING! You are about to execute a migration in database "..." that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no) [yes]: Hit `Enter` to confirm the operation. @@ -71,33 +81,43 @@ Hit `Enter` to confirm the operation. To list all the fixtures, run: - php bin/doctrine fixtures:list +```shell +php bin/doctrine fixtures:list +``` This will output all the fixtures in the order of execution. To execute all fixtures, run: - php bin/doctrine fixtures:execute +```shell +php bin/doctrine fixtures:execute +``` To execute a specific fixture, run: - php bin/doctrine fixtures:execute --class=FixtureClassName +```shell +php bin/doctrine fixtures:execute --class=FixtureClassName +``` More details on how fixtures work can be found here: https://github.com/dotkernel/dot-data-fixtures#creating-fixtures ## Step 6: Test the installation - php -S 0.0.0.0:8080 -t public +```shell +php -S 0.0.0.0:8080 -t public +``` Sending a GET request to the [home page](http://0.0.0.0:8080/) should output the following message: - { - "message": "DotKernel API version 5" - } +```json +{ + "message": "Dotkernel API version 5" +} +``` ## Documentation -In order to access DotKernel API documentation, check the provided [readme file](documentation/README.md). +In order to access Dotkernel API documentation, check the provided [readme file](documentation/README.md). Additionally, each CLI command available has it's own documentation: diff --git a/composer.json b/composer.json index e9a78c4e..6ddcb208 100644 --- a/composer.json +++ b/composer.json @@ -27,9 +27,9 @@ "sort-packages": true, "allow-plugins": { "dotkernel/*": true, - "laminas/laminas-component-installer": true, "composer/package-versions-deprecated": true, - "dealerdirect/phpcodesniffer-composer-installer": true + "dealerdirect/phpcodesniffer-composer-installer": true, + "laminas/laminas-component-installer": true } }, "extra": { @@ -58,7 +58,7 @@ "dotkernel/dot-errorhandler": "^4.0.0", "dotkernel/dot-mail": "^4.1.1", "dotkernel/dot-response-header": "^3.2.3", - "laminas/laminas-component-installer": "^3.4.0", + "laminas/laminas-component-installer": "^3.4", "laminas/laminas-config": "^3.9.0", "laminas/laminas-config-aggregator": "^1.14.0", "laminas/laminas-http": "^2.19.0", @@ -74,7 +74,7 @@ "mezzio/mezzio-cors": "^1.11.1", "mezzio/mezzio-fastroute": "^3.11.0", "mezzio/mezzio-hal": "^2.9", - "mezzio/mezzio-problem-details": "^1.13.1", + "mezzio/mezzio-problem-details": "^1.14", "mezzio/mezzio-twigrenderer": "^2.15.0", "ramsey/uuid-doctrine": "^2.1.0", "roave/psr-container-doctrine": "^5.2.1", diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist index c0a9aae5..f670ce62 100644 --- a/config/autoload/local.php.dist +++ b/config/autoload/local.php.dist @@ -2,7 +2,7 @@ declare(strict_types=1); -$baseUrl = 'http://localhost:8080'; +$baseUrl = 'http://api.dotkernel.localhost'; $databases = [ 'default' => [ @@ -19,14 +19,11 @@ $databases = [ ]; return [ - 'application' => [ - 'name' => 'DotKernel API', - 'url' => $baseUrl, - 'versioning' => [ - 'documentation_url' => 'https://docs.dotkernel.org/api-documentation/v5/core-features/versioning', - ], + 'application' => [ + 'name' => 'DotKernel API', + 'url' => $baseUrl, ], - 'authentication' => [ + 'authentication' => [ 'private_key' => [ 'key_or_path' => getcwd() . '/data/oauth/private.key', 'key_permissions_check' => false, @@ -45,15 +42,15 @@ return [ 'message' => 'Invalid credentials.', ], ], - 'databases' => $databases, - 'doctrine' => [ - 'connection' => [ + 'databases' => $databases, + 'doctrine' => [ + 'connection' => [ 'orm_default' => [ 'params' => $databases['default'], ], ], ], - 'uploads' => [ + 'uploads' => [ 'user' => [ 'url' => $baseUrl . '/uploads/user', 'path' => realpath(__DIR__ . '/../../public/uploads/user'), diff --git a/config/autoload/problem-details.global.php b/config/autoload/problem-details.global.php new file mode 100644 index 00000000..77814dc1 --- /dev/null +++ b/config/autoload/problem-details.global.php @@ -0,0 +1,43 @@ + [ + 'default_types_map' => [ + StatusCodeInterface::STATUS_BAD_REQUEST => 'https://example.com/error/bad-request', + StatusCodeInterface::STATUS_UNAUTHORIZED => 'https://example.com/error/unauthorized', + StatusCodeInterface::STATUS_FORBIDDEN => 'https://example.com/error/forbidden', + StatusCodeInterface::STATUS_NOT_FOUND => 'https://example.com/error/not-found', + StatusCodeInterface::STATUS_METHOD_NOT_ALLOWED => 'https://example.com/error/method-not-allowed', + StatusCodeInterface::STATUS_NOT_ACCEPTABLE => 'https://example.com/error/method-not-acceptable', + StatusCodeInterface::STATUS_CONFLICT => 'https://example.com/error/conflict', + StatusCodeInterface::STATUS_GONE => 'https://example.com/error/gone', + StatusCodeInterface::STATUS_UNSUPPORTED_MEDIA_TYPE => 'https://example.com/error/unsupported-media-type', + StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR => 'https://example.com/error/internal-server-error', + ], + ], + + /** + * Misc documentation URLs used application-wide + */ + 'application' => [ + 'versioning' => [ + 'documentation_url' => 'https://docs.dotkernel.org/api-documentation/v5/core-features/versioning', + ], + ], + + /** + * Error-reporting specific documentation URLs + */ + ErrorReportServiceInterface::class => [ + 'documentation_url' => 'https://example.com/error/not-authorized/error-reporting', + ], +]; diff --git a/config/pipeline.php b/config/pipeline.php index 3c5cbff2..38da2a53 100644 --- a/config/pipeline.php +++ b/config/pipeline.php @@ -14,6 +14,8 @@ use Mezzio\Helper\BodyParams\BodyParamsMiddleware; use Mezzio\Helper\ServerUrlMiddleware; use Mezzio\Helper\UrlHelperMiddleware; +use Mezzio\ProblemDetails\ProblemDetailsMiddleware; +use Mezzio\ProblemDetails\ProblemDetailsNotFoundHandler; use Mezzio\Router\Middleware\DispatchMiddleware; use Mezzio\Router\Middleware\ImplicitHeadMiddleware; use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; @@ -24,6 +26,7 @@ // The error handler should be the first (most outer) middleware to catch // all Exceptions. $app->pipe(ErrorHandlerInterface::class); + $app->pipe(ProblemDetailsMiddleware::class); $app->pipe(BodyParamsMiddleware::class); $app->pipe(ServerUrlMiddleware::class); @@ -84,5 +87,6 @@ // At this point, if no Response is returned by any middleware, the // NotFoundHandler kicks in; alternately, you can provide other fallback // middleware to execute. + $app->pipe(ProblemDetailsNotFoundHandler::class); $app->pipe(NotFoundHandler::class); }; diff --git a/src/Admin/src/Command/AdminCreateCommand.php b/src/Admin/src/Command/AdminCreateCommand.php index 777d8df5..c255142f 100644 --- a/src/Admin/src/Command/AdminCreateCommand.php +++ b/src/Admin/src/Command/AdminCreateCommand.php @@ -8,7 +8,6 @@ use Api\Admin\InputFilter\CreateAdminInputFilter; use Api\Admin\Service\AdminRoleService; use Api\Admin\Service\AdminService; -use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; use Api\App\Message; @@ -17,6 +16,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use function implode; use function sprintf; @@ -54,12 +54,12 @@ protected function configure(): void } /** - * @throws BadRequestException - * @throws ConflictException * @throws NotFoundException */ protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + $inputFilter = (new CreateAdminInputFilter())->setData($this->getData($input)); if (! $inputFilter->isValid()) { $messages = []; @@ -69,10 +69,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - throw new BadRequestException(implode(PHP_EOL, $messages)); + $io->error(implode(PHP_EOL, $messages)); + return Command::FAILURE; } - $this->adminService->createAdmin($inputFilter->getValues()); + try { + $this->adminService->createAdmin($inputFilter->getValues()); + } catch (ConflictException | NotFoundException $e) { + $io->error($e->getDetail()); + return Command::FAILURE; + } $output->writeln(Message::ADMIN_CREATED); diff --git a/src/Admin/src/ConfigProvider.php b/src/Admin/src/ConfigProvider.php index 12d9a01d..30734dcb 100644 --- a/src/Admin/src/ConfigProvider.php +++ b/src/Admin/src/ConfigProvider.php @@ -22,6 +22,7 @@ use Api\Admin\Service\AdminService; use Api\Admin\Service\AdminServiceInterface; use Api\App\ConfigProvider as AppConfigProvider; +use Api\App\Factory\HandlerDelegatorFactory; use Doctrine\ORM\Mapping\Driver\AttributeDriver; use Dot\DependencyInjection\Factory\AttributedRepositoryFactory; use Dot\DependencyInjection\Factory\AttributedServiceFactory; @@ -43,9 +44,12 @@ public function getDependencies(): array { return [ 'delegators' => [ - Application::class => [ - RoutesDelegator::class, - ], + Application::class => [RoutesDelegator::class], + AdminHandler::class => [HandlerDelegatorFactory::class], + AdminCollectionHandler::class => [HandlerDelegatorFactory::class], + AdminAccountHandler::class => [HandlerDelegatorFactory::class], + AdminRoleHandler::class => [HandlerDelegatorFactory::class], + AdminRoleCollectionHandler::class => [HandlerDelegatorFactory::class], ], 'factories' => [ AdminHandler::class => AttributedServiceFactory::class, diff --git a/src/Admin/src/Handler/AdminAccountHandler.php b/src/Admin/src/Handler/AdminAccountHandler.php index fd8f76fe..a5e1d805 100644 --- a/src/Admin/src/Handler/AdminAccountHandler.php +++ b/src/Admin/src/Handler/AdminAccountHandler.php @@ -10,28 +10,17 @@ use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; +use Api\App\Message; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class AdminAccountHandler implements RequestHandlerInterface +class AdminAccountHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - AdminServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected AdminServiceInterface $adminService, - ) { + #[Inject(AdminServiceInterface::class)] + public function __construct(protected AdminServiceInterface $adminService) + { } public function get(ServerRequestInterface $request): ResponseInterface @@ -48,7 +37,10 @@ public function patch(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new UpdateAdminInputFilter())->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $admin = $this->adminService->updateAdmin($request->getAttribute(Admin::class), $inputFilter->getValues()); diff --git a/src/Admin/src/Handler/AdminCollectionHandler.php b/src/Admin/src/Handler/AdminCollectionHandler.php index 8d8b4873..ea64bae0 100644 --- a/src/Admin/src/Handler/AdminCollectionHandler.php +++ b/src/Admin/src/Handler/AdminCollectionHandler.php @@ -6,28 +6,16 @@ use Api\Admin\Service\AdminServiceInterface; use Api\App\Exception\BadRequestException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class AdminCollectionHandler implements RequestHandlerInterface +class AdminCollectionHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - AdminServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected AdminServiceInterface $adminService, - ) { + #[Inject(AdminServiceInterface::class)] + public function __construct(protected AdminServiceInterface $adminService) + { } /** diff --git a/src/Admin/src/Handler/AdminHandler.php b/src/Admin/src/Handler/AdminHandler.php index 2a571331..f7fb8e01 100644 --- a/src/Admin/src/Handler/AdminHandler.php +++ b/src/Admin/src/Handler/AdminHandler.php @@ -10,28 +10,17 @@ use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; +use Api\App\Message; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class AdminHandler implements RequestHandlerInterface +class AdminHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - AdminServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected AdminServiceInterface $adminService, - ) { + #[Inject(AdminServiceInterface::class)] + public function __construct(protected AdminServiceInterface $adminService) + { } /** @@ -65,7 +54,10 @@ public function patch(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new UpdateAdminInputFilter())->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $admin = $this->adminService->findOneBy(['uuid' => $request->getAttribute('uuid')]); @@ -83,7 +75,10 @@ public function post(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new CreateAdminInputFilter())->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $admin = $this->adminService->createAdmin($inputFilter->getValues()); diff --git a/src/Admin/src/Handler/AdminRoleCollectionHandler.php b/src/Admin/src/Handler/AdminRoleCollectionHandler.php index d4e55362..abebb575 100644 --- a/src/Admin/src/Handler/AdminRoleCollectionHandler.php +++ b/src/Admin/src/Handler/AdminRoleCollectionHandler.php @@ -6,28 +6,16 @@ use Api\Admin\Service\AdminRoleServiceInterface; use Api\App\Exception\BadRequestException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class AdminRoleCollectionHandler implements RequestHandlerInterface +class AdminRoleCollectionHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - AdminRoleServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected AdminRoleServiceInterface $roleService, - ) { + #[Inject(AdminRoleServiceInterface::class)] + public function __construct(protected AdminRoleServiceInterface $roleService) + { } /** diff --git a/src/Admin/src/Handler/AdminRoleHandler.php b/src/Admin/src/Handler/AdminRoleHandler.php index 18b377a5..8281d10b 100644 --- a/src/Admin/src/Handler/AdminRoleHandler.php +++ b/src/Admin/src/Handler/AdminRoleHandler.php @@ -6,28 +6,16 @@ use Api\Admin\Service\AdminRoleServiceInterface; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class AdminRoleHandler implements RequestHandlerInterface +class AdminRoleHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - AdminRoleServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected AdminRoleServiceInterface $roleService, - ) { + #[Inject(AdminRoleServiceInterface::class)] + public function __construct(protected AdminRoleServiceInterface $roleService) + { } /** diff --git a/src/Admin/src/Service/AdminRoleService.php b/src/Admin/src/Service/AdminRoleService.php index bace975a..db214918 100644 --- a/src/Admin/src/Service/AdminRoleService.php +++ b/src/Admin/src/Service/AdminRoleService.php @@ -17,12 +17,9 @@ class AdminRoleService implements AdminRoleServiceInterface { - #[Inject( - AdminRoleRepository::class, - )] - public function __construct( - protected AdminRoleRepository $adminRoleRepository, - ) { + #[Inject(AdminRoleRepository::class)] + public function __construct(protected AdminRoleRepository $adminRoleRepository) + { } /** @@ -32,7 +29,7 @@ public function findOneBy(array $params = []): AdminRole { $role = $this->adminRoleRepository->findOneBy($params); if (! $role instanceof AdminRole) { - throw new NotFoundException(Message::ROLE_NOT_FOUND); + throw NotFoundException::create(Message::ADMIN_ROLE_NOT_FOUND); } return $role; @@ -51,7 +48,7 @@ public function getAdminRoles(array $params = []): AdminRoleCollection $params['order'] = $params['order'] ?? 'role.created'; if (! in_array($params['order'], $values)) { - throw (new BadRequestException())->setMessages([sprintf(Message::INVALID_VALUE, 'order')]); + throw BadRequestException::create(sprintf(Message::INVALID_VALUE, 'order')); } return $this->adminRoleRepository->getAdminRoles($params); diff --git a/src/Admin/src/Service/AdminService.php b/src/Admin/src/Service/AdminService.php index 5d07def9..8b1e4a8a 100644 --- a/src/Admin/src/Service/AdminService.php +++ b/src/Admin/src/Service/AdminService.php @@ -35,7 +35,7 @@ public function __construct( public function createAdmin(array $data = []): Admin { if ($this->exists($data['identity'])) { - throw new ConflictException(Message::DUPLICATE_IDENTITY); + throw ConflictException::create(Message::DUPLICATE_IDENTITY); } $admin = (new Admin()) @@ -88,7 +88,7 @@ public function findOneBy(array $params = []): Admin { $admin = $this->adminRepository->findOneBy($params); if (! $admin instanceof Admin) { - throw new NotFoundException(Message::ADMIN_NOT_FOUND); + throw NotFoundException::create(Message::ADMIN_NOT_FOUND); } return $admin; @@ -99,7 +99,8 @@ public function findOneBy(array $params = []): Admin */ public function getAdmins(array $params = []): AdminCollection { - $values = [ + $orders = [ + 'admin.uuid', 'admin.identity', 'admin.firstName', 'admin.lastName', @@ -109,8 +110,24 @@ public function getAdmins(array $params = []): AdminCollection ]; $params['order'] = $params['order'] ?? 'admin.created'; - if (! in_array($params['order'], $values)) { - throw (new BadRequestException())->setMessages([sprintf(Message::INVALID_VALUE, 'order')]); + if (! in_array($params['order'], $orders)) { + throw BadRequestException::create( + detail: sprintf(Message::INVALID_VALUE_USE_ONE_OF, 'order'), + additional: ['order' => $orders], + ); + } + + $dirs = [ + 'asc', + 'desc', + ]; + + $params['dir'] = $params['dir'] ?? 'desc'; + if (! in_array($params['dir'], $dirs)) { + throw BadRequestException::create( + detail: sprintf(Message::INVALID_VALUE_USE_ONE_OF, 'dir'), + additional: ['dir' => $dirs], + ); } return $this->adminRepository->getAdmins($params); @@ -124,7 +141,7 @@ public function getAdmins(array $params = []): AdminCollection public function updateAdmin(Admin $admin, array $data = []): Admin { if (isset($data['identity']) && $this->existsOther($data['identity'], $admin->getUuid()->toString())) { - throw new ConflictException(Message::DUPLICATE_IDENTITY); + throw ConflictException::create(Message::DUPLICATE_IDENTITY); } if (! empty($data['password'])) { @@ -153,7 +170,7 @@ public function updateAdmin(Admin $admin, array $data = []): Admin } if (! $admin->hasRoles()) { - throw (new BadRequestException())->setMessages([Message::RESTRICTION_ROLES]); + throw BadRequestException::create(Message::RESTRICTION_ROLES); } return $this->adminRepository->saveAdmin($admin); diff --git a/src/App/src/Command/TokenGenerateCommand.php b/src/App/src/Command/TokenGenerateCommand.php index 00f359de..bb9e4bdb 100644 --- a/src/App/src/Command/TokenGenerateCommand.php +++ b/src/App/src/Command/TokenGenerateCommand.php @@ -4,8 +4,8 @@ namespace Api\App\Command; -use Api\App\Exception\NotFoundException; use Api\App\Service\ErrorReportServiceInterface; +use Exception; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -54,14 +54,14 @@ protected function configure(): void } /** - * @throws NotFoundException + * @throws Exception */ protected function execute(InputInterface $input, OutputInterface $output): int { $type = $input->getArgument('type'); match ($type) { $this->typeErrorReporting => $this->generateErrorReportingToken($output), - default => throw new NotFoundException( + default => throw new Exception( sprintf('Unknown token type: %s', $type) ) }; diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index 07ca4c60..ed8c7fa1 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -9,10 +9,10 @@ use Api\App\Entity\EntityListenerResolver; use Api\App\Factory\AuthenticationMiddlewareFactory; use Api\App\Factory\EntityListenerResolverFactory; +use Api\App\Factory\HandlerDelegatorFactory; use Api\App\Factory\RouteListCommandFactory; use Api\App\Factory\TokenGenerateCommandFactory; use Api\App\Handler\ErrorReportHandler; -use Api\App\Handler\HomeHandler; use Api\App\Middleware\AuthenticationMiddleware; use Api\App\Middleware\AuthorizationMiddleware; use Api\App\Middleware\ContentNegotiationMiddleware; @@ -57,9 +57,8 @@ public function getDependencies(): array { return [ 'delegators' => [ - Application::class => [ - RoutesDelegator::class, - ], + Application::class => [RoutesDelegator::class], + ErrorReportHandler::class => [HandlerDelegatorFactory::class], ], 'factories' => [ 'doctrine.entity_manager.orm_default' => EntityManagerFactory::class, @@ -72,7 +71,6 @@ public function getDependencies(): array Environment::class => TwigEnvironmentFactory::class, TwigExtension::class => TwigExtensionFactory::class, TwigRenderer::class => TwigRendererFactory::class, - HomeHandler::class => AttributedServiceFactory::class, ErrorReportHandler::class => AttributedServiceFactory::class, ErrorResponseMiddleware::class => AttributedServiceFactory::class, RouteListCommand::class => RouteListCommandFactory::class, diff --git a/src/App/src/Entity/AbstractEntity.php b/src/App/src/Entity/AbstractEntity.php index d38d0533..d70a01de 100644 --- a/src/App/src/Entity/AbstractEntity.php +++ b/src/App/src/Entity/AbstractEntity.php @@ -14,7 +14,7 @@ use function ucfirst; #[ORM\MappedSuperclass] -abstract class AbstractEntity implements ArraySerializableInterface +abstract class AbstractEntity implements ArraySerializableInterface, EntityInterface { #[ORM\Id] #[ORM\Column(name: 'uuid', type: "uuid_binary", unique: true)] diff --git a/src/App/src/Entity/EntityInterface.php b/src/App/src/Entity/EntityInterface.php new file mode 100644 index 00000000..c519b99d --- /dev/null +++ b/src/App/src/Entity/EntityInterface.php @@ -0,0 +1,9 @@ +container->get($className); } diff --git a/src/App/src/Entity/OAuthAccessToken.php b/src/App/src/Entity/OAuthAccessToken.php index 1d8e3bd4..cf94f2c4 100644 --- a/src/App/src/Entity/OAuthAccessToken.php +++ b/src/App/src/Entity/OAuthAccessToken.php @@ -4,6 +4,8 @@ namespace Api\App\Entity; +use Api\App\Exception\RuntimeException; +use Api\App\Message; use Api\App\Repository\OAuthAccessTokenRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -18,7 +20,6 @@ use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; -use RuntimeException; #[ORM\Entity(repositoryClass: OAuthAccessTokenRepository::class)] #[ORM\Table(name: "oauth_access_tokens")] @@ -189,10 +190,13 @@ public function setPrivateKey(CryptKey $privateKey): self return $this; } + /** + * @throws RuntimeException + */ public function initJwtConfiguration(): self { if (null === $this->privateKey) { - throw new RuntimeException('Unable to init JWT without private key'); + throw RuntimeException::create(Message::OAUTH_MISSING_PRIVATE_KEY); } $this->jwtConfiguration = Configuration::forAsymmetricSigner( @@ -207,12 +211,15 @@ public function initJwtConfiguration(): self return $this; } + /** + * @throws RuntimeException + */ private function convertToJWT(): Token { $this->initJwtConfiguration(); if ($this->jwtConfiguration === null) { - throw new RuntimeException('Unable to convert to JWT without config'); + throw RuntimeException::create(Message::OAUTH_MISSING_CONFIG); } return $this->jwtConfiguration->builder() @@ -226,6 +233,9 @@ private function convertToJWT(): Token ->getToken($this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey()); } + /** + * @throws RuntimeException + */ public function __toString(): string { return $this->convertToJWT()->toString(); diff --git a/src/App/src/Exception/BadRequestException.php b/src/App/src/Exception/BadRequestException.php index 69a06cc7..b05d896c 100644 --- a/src/App/src/Exception/BadRequestException.php +++ b/src/App/src/Exception/BadRequestException.php @@ -4,21 +4,26 @@ namespace Api\App\Exception; +use Api\App\Message; use Exception; +use Fig\Http\Message\StatusCodeInterface; +use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -class BadRequestException extends Exception +class BadRequestException extends Exception implements ProblemDetailsExceptionInterface { - private array $messages = []; + use CommonProblemDetailsExceptionTrait; - public function getMessages(): array + public static function create(string $detail, string $type = '', array $additional = []): self { - return $this->messages; - } + $exception = new self(); - public function setMessages(array $messages): static - { - $this->messages = $messages; + $exception->type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_BAD_REQUEST; + $exception->title = Message::BAD_REQUEST; + $exception->additional = $additional; - return $this; + return $exception; } } diff --git a/src/App/src/Exception/ConflictException.php b/src/App/src/Exception/ConflictException.php index 15658958..c27e922a 100644 --- a/src/App/src/Exception/ConflictException.php +++ b/src/App/src/Exception/ConflictException.php @@ -4,8 +4,26 @@ namespace Api\App\Exception; +use Api\App\Message; use Exception; +use Fig\Http\Message\StatusCodeInterface; +use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -class ConflictException extends Exception +class ConflictException extends Exception implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + public static function create(string $detail, string $type = '', array $additional = []): self + { + $exception = new self(); + + $exception->type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_CONFLICT; + $exception->title = Message::CONFLICT; + $exception->additional = $additional; + + return $exception; + } } diff --git a/src/App/src/Exception/ExpiredException.php b/src/App/src/Exception/ExpiredException.php index ac36b776..60a60828 100644 --- a/src/App/src/Exception/ExpiredException.php +++ b/src/App/src/Exception/ExpiredException.php @@ -4,8 +4,26 @@ namespace Api\App\Exception; +use Api\App\Message; use Exception; +use Fig\Http\Message\StatusCodeInterface; +use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -class ExpiredException extends Exception +class ExpiredException extends Exception implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + public static function create(string $detail, string $type = '', array $additional = []): self + { + $exception = new self(); + + $exception->type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_GONE; + $exception->title = Message::EXPIRED; + $exception->additional = $additional; + + return $exception; + } } diff --git a/src/App/src/Exception/ForbiddenException.php b/src/App/src/Exception/ForbiddenException.php index 6aa88fc1..5c2f87a5 100644 --- a/src/App/src/Exception/ForbiddenException.php +++ b/src/App/src/Exception/ForbiddenException.php @@ -4,8 +4,26 @@ namespace Api\App\Exception; +use Api\App\Message; use Exception; +use Fig\Http\Message\StatusCodeInterface; +use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -class ForbiddenException extends Exception +class ForbiddenException extends Exception implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + public static function create(string $detail, string $type = '', array $additional = []): self + { + $exception = new self(); + + $exception->type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_FORBIDDEN; + $exception->title = Message::FORBIDDEN; + $exception->additional = $additional; + + return $exception; + } } diff --git a/src/App/src/Exception/MethodNotAllowedException.php b/src/App/src/Exception/MethodNotAllowedException.php index e604c375..fe0b8a92 100644 --- a/src/App/src/Exception/MethodNotAllowedException.php +++ b/src/App/src/Exception/MethodNotAllowedException.php @@ -4,8 +4,26 @@ namespace Api\App\Exception; +use Api\App\Message; use Exception; +use Fig\Http\Message\StatusCodeInterface; +use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -class MethodNotAllowedException extends Exception +class MethodNotAllowedException extends Exception implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + public static function create(string $detail, string $type = '', array $additional = []): self + { + $exception = new self(); + + $exception->type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_METHOD_NOT_ALLOWED; + $exception->title = Message::METHOD_NOT_ALLOWED; + $exception->additional = $additional; + + return $exception; + } } diff --git a/src/App/src/Exception/NotFoundException.php b/src/App/src/Exception/NotFoundException.php index d4254200..bc70d58d 100644 --- a/src/App/src/Exception/NotFoundException.php +++ b/src/App/src/Exception/NotFoundException.php @@ -4,8 +4,26 @@ namespace Api\App\Exception; +use Api\App\Message; use Exception; +use Fig\Http\Message\StatusCodeInterface; +use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -class NotFoundException extends Exception +class NotFoundException extends Exception implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + public static function create(string $detail, string $type = '', array $additional = []): self + { + $exception = new self(); + + $exception->type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_NOT_FOUND; + $exception->title = Message::NOT_FOUND; + $exception->additional = $additional; + + return $exception; + } } diff --git a/src/App/src/Exception/RuntimeException.php b/src/App/src/Exception/RuntimeException.php new file mode 100644 index 00000000..358dc4c1 --- /dev/null +++ b/src/App/src/Exception/RuntimeException.php @@ -0,0 +1,29 @@ +type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR; + $exception->title = Message::INTERNAL_SERVER_ERROR; + $exception->additional = $additional; + + return $exception; + } +} diff --git a/src/App/src/Exception/UnauthorizedException.php b/src/App/src/Exception/UnauthorizedException.php index 68fc283e..d08e7ff3 100644 --- a/src/App/src/Exception/UnauthorizedException.php +++ b/src/App/src/Exception/UnauthorizedException.php @@ -4,8 +4,26 @@ namespace Api\App\Exception; +use Api\App\Message; use Exception; +use Fig\Http\Message\StatusCodeInterface; +use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -class UnauthorizedException extends Exception +class UnauthorizedException extends Exception implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + public static function create(string $detail, string $type = '', array $additional = []): self + { + $exception = new self(); + + $exception->type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_UNAUTHORIZED; + $exception->title = Message::UNAUTHORIZED; + $exception->additional = $additional; + + return $exception; + } } diff --git a/src/App/src/Factory/HandlerDelegatorFactory.php b/src/App/src/Factory/HandlerDelegatorFactory.php new file mode 100644 index 00000000..ae0374d6 --- /dev/null +++ b/src/App/src/Factory/HandlerDelegatorFactory.php @@ -0,0 +1,60 @@ +has(HalResponseFactory::class)) { + throw RuntimeException::create( + sprintf(Message::SERVICE_NOT_FOUND, HalResponseFactory::class) + ); + } + + if (! $container->has(ResourceGenerator::class)) { + throw RuntimeException::create( + sprintf(Message::SERVICE_NOT_FOUND, ResourceGenerator::class) + ); + } + + if (! $container->has(ProblemDetailsResponseFactory::class)) { + throw RuntimeException::create( + sprintf(Message::SERVICE_NOT_FOUND, ProblemDetailsResponseFactory::class) + ); + } + + $handler = $callback(); + assert($handler instanceof AbstractHandler); + + return $handler + ->setResponseFactory($container->get(HalResponseFactory::class)) + ->setResourceGenerator($container->get(ResourceGenerator::class)) + ->setProblemDetailsFactory($container->get(ProblemDetailsResponseFactory::class)); + } +} diff --git a/src/App/src/Handler/AbstractHandler.php b/src/App/src/Handler/AbstractHandler.php new file mode 100644 index 00000000..1eff6f54 --- /dev/null +++ b/src/App/src/Handler/AbstractHandler.php @@ -0,0 +1,119 @@ +getMethod()); + if (! method_exists($this, $method)) { + throw new MethodNotAllowedException(Message::METHOD_NOT_ALLOWED); + } + + return $this->$method($request); + } catch (Throwable $exception) { + assert($this->problemDetailsFactory instanceof ProblemDetailsResponseFactory); + return $this->problemDetailsFactory->createResponseFromThrowable($request, $exception); + } + } + + public function setResponseFactory(HalResponseFactory $responseFactory): self + { + $this->responseFactory = $responseFactory; + + return $this; + } + + public function setResourceGenerator(ResourceGenerator $resourceGenerator): self + { + $this->resourceGenerator = $resourceGenerator; + + return $this; + } + + public function setProblemDetailsFactory(ProblemDetailsResponseFactory $problemDetailsFactory): self + { + $this->problemDetailsFactory = $problemDetailsFactory; + + return $this; + } + + public function emptyResponse(int $status = StatusCodeInterface::STATUS_NO_CONTENT): ResponseInterface + { + return new EmptyResponse($status, ['Content-Type' => 'text/plain']); + } + + public function jsonResponse( + array|string $messages = [], + int $status = StatusCodeInterface::STATUS_OK + ): ResponseInterface { + return new JsonResponse($messages, $status); + } + + public function createResponse( + ServerRequestInterface $request, + CollectionInterface|EntityInterface $instance + ): ResponseInterface { + assert($this->responseFactory instanceof HalResponseFactory); + assert($this->resourceGenerator instanceof ResourceGenerator); + + return $this->responseFactory->createResponse( + $request, + $this->resourceGenerator->fromObject($instance, $request) + ); + } + + public function createdResponse(ServerRequestInterface $request, EntityInterface $instance): ResponseInterface + { + $response = $this->createResponse($request, $instance); + + return $response->withStatus(StatusCodeInterface::STATUS_CREATED); + } + + public function noContentResponse(): ResponseInterface + { + return $this->emptyResponse(); + } + + public function notFoundResponse(): ResponseInterface + { + return $this->emptyResponse(StatusCodeInterface::STATUS_NOT_FOUND); + } + + public function infoResponse( + array|string $messages = [], + int $status = StatusCodeInterface::STATUS_OK + ): ResponseInterface { + return $this->jsonResponse([ + 'messages' => is_array($messages) ? $messages : [$messages], + ], $status); + } +} diff --git a/src/App/src/Handler/ErrorReportHandler.php b/src/App/src/Handler/ErrorReportHandler.php index a6871394..d61f777a 100644 --- a/src/App/src/Handler/ErrorReportHandler.php +++ b/src/App/src/Handler/ErrorReportHandler.php @@ -6,32 +6,20 @@ use Api\App\Attribute\MethodDeprecation; use Api\App\Exception\ForbiddenException; +use Api\App\Exception\RuntimeException; use Api\App\Exception\UnauthorizedException; use Api\App\Message; use Api\App\Service\ErrorReportServiceInterface; use Dot\DependencyInjection\Attribute\Inject; use Fig\Http\Message\StatusCodeInterface; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -use RuntimeException; -class ErrorReportHandler implements RequestHandlerInterface +class ErrorReportHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - ErrorReportServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected ErrorReportServiceInterface $errorReportService, - ) { + #[Inject(ErrorReportServiceInterface::class)] + public function __construct(protected ErrorReportServiceInterface $errorReportService) + { } /** diff --git a/src/App/src/Handler/HandlerTrait.php b/src/App/src/Handler/HandlerTrait.php deleted file mode 100644 index 7e1b5f42..00000000 --- a/src/App/src/Handler/HandlerTrait.php +++ /dev/null @@ -1,62 +0,0 @@ -getMethod()); - if (! method_exists($this, $method)) { - throw new MethodNotAllowedException( - sprintf('Method %s is not implemented for the requested resource.', $method) - ); - } - - return $this->$method($request); - } catch (ConflictException $exception) { - return $this->errorResponse($exception->getMessage(), StatusCodeInterface::STATUS_CONFLICT); - } catch (ForbiddenException $exception) { - return $this->errorResponse($exception->getMessage(), StatusCodeInterface::STATUS_FORBIDDEN); - } catch (ExpiredException $exception) { - return $this->errorResponse($exception->getMessage(), StatusCodeInterface::STATUS_GONE); - } catch (OutOfBoundsException | NotFoundException $exception) { - return $this->errorResponse($exception->getMessage(), StatusCodeInterface::STATUS_NOT_FOUND); - } catch (UnauthorizedException $exception) { - return $this->errorResponse($exception->getMessage(), StatusCodeInterface::STATUS_UNAUTHORIZED); - } catch (MethodNotAllowedException $exception) { - return $this->errorResponse($exception->getMessage(), StatusCodeInterface::STATUS_METHOD_NOT_ALLOWED); - } catch (BadRequestException $exception) { - return $this->errorResponse($exception->getMessages(), StatusCodeInterface::STATUS_BAD_REQUEST); - } catch (MailException | RuntimeException | Exception $exception) { - return $this->errorResponse($exception->getMessage()); - } - } -} diff --git a/src/App/src/Handler/HomeHandler.php b/src/App/src/Handler/HomeHandler.php index ace153af..13d9a927 100644 --- a/src/App/src/Handler/HomeHandler.php +++ b/src/App/src/Handler/HomeHandler.php @@ -5,33 +5,17 @@ namespace Api\App\Handler; use Api\App\Attribute\ResourceDeprecation; -use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Server\RequestHandlerInterface; #[ResourceDeprecation( sunset: '2038-01-01', link: 'https://docs.dotkernel.org/api-documentation/v5/core-features/versioning', deprecationReason: 'Resource deprecation example.', )] -class HomeHandler implements RequestHandlerInterface +class HomeHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - ) { - } - public function get(): ResponseInterface { - return $this->jsonResponse(['message' => 'DotKernel API version 5']); + return $this->jsonResponse(['message' => 'Dotkernel API version 5']); } } diff --git a/src/App/src/Handler/NotFoundHandler.php b/src/App/src/Handler/NotFoundHandler.php index cd4ff15d..c108ad7f 100644 --- a/src/App/src/Handler/NotFoundHandler.php +++ b/src/App/src/Handler/NotFoundHandler.php @@ -6,12 +6,9 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class NotFoundHandler implements RequestHandlerInterface +class NotFoundHandler extends AbstractHandler { - use HandlerTrait; - public function handle(ServerRequestInterface $request): ResponseInterface { return $this->notFoundResponse(); diff --git a/src/App/src/Handler/ResponseTrait.php b/src/App/src/Handler/ResponseTrait.php deleted file mode 100644 index 09c7d727..00000000 --- a/src/App/src/Handler/ResponseTrait.php +++ /dev/null @@ -1,90 +0,0 @@ - 'text/plain']); - } - - public function jsonResponse( - array|string $messages = [], - int $status = StatusCodeInterface::STATUS_OK - ): ResponseInterface { - return new JsonResponse($messages, $status); - } - - public function createResponse(ServerRequestInterface $request, mixed $instance): ResponseInterface - { - return $this->responseFactory->createResponse( - $request, - $this->resourceGenerator->fromObject($instance, $request) - ); - } - - public function createdResponse(ServerRequestInterface $request, mixed $instance): ResponseInterface - { - $response = $this->createResponse($request, $instance); - - return $response->withStatus(StatusCodeInterface::STATUS_CREATED); - } - - public function noContentResponse(): ResponseInterface - { - return $this->emptyResponse(); - } - - public function notFoundResponse(): ResponseInterface - { - return $this->emptyResponse(StatusCodeInterface::STATUS_NOT_FOUND); - } - - public function errorResponse( - array|string $messages = [], - int $status = StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR - ): ResponseInterface { - return $this->jsonResponse([ - 'error' => [ - 'messages' => is_array($messages) ? $messages : [$messages], - ], - ], $status); - } - - public function infoResponse( - array|string $messages = [], - int $status = StatusCodeInterface::STATUS_OK - ): ResponseInterface { - return $this->jsonResponse([ - 'info' => [ - 'messages' => is_array($messages) ? $messages : [$messages], - ], - ], $status); - } - - public function notAcceptableResponse(string $message): ResponseInterface - { - return $this->errorResponse($message, StatusCodeInterface::STATUS_NOT_ACCEPTABLE); - } - - public function unsupportedMediaTypeResponse(string $message): ResponseInterface - { - return $this->errorResponse($message, StatusCodeInterface::STATUS_UNSUPPORTED_MEDIA_TYPE); - } -} diff --git a/src/App/src/Message.php b/src/App/src/Message.php index 68a67346..890f5b81 100644 --- a/src/App/src/Message.php +++ b/src/App/src/Message.php @@ -6,43 +6,64 @@ class Message { + public const ACCEPT_NOT_ACCEPTABLE = 'Not Acceptable'; + public const ACCEPT_NOT_RESOLVABLE = 'Unable to resolve Accept header to a representation'; public const ADMIN_CREATED = 'Admin account has been created.'; - public const ADMIN_NOT_ACTIVATED = 'This account is deactivated.'; - public const ADMIN_NOT_FOUND = 'Admin not found.'; - public const AVATAR_MISSING = 'This user account has no avatar associated with it.'; + public const ADMIN_INACTIVE = 'Admin account must be activated first.'; + public const ADMIN_NOT_FOUND = 'Admin account was not found.'; + public const ADMIN_ROLE_NOT_FOUND = 'Admin role not found.'; + public const BAD_REQUEST = 'The submitted data contains invalid values.'; + public const CONFLICT = 'The submitted request conflicts with the current state of an' + . ' existing resource.'; public const DUPLICATE_EMAIL = 'An account with this email address already exists.'; public const DUPLICATE_IDENTITY = 'An account with this identity already exists.'; public const ERROR_REPORT_OK = 'Error report successfully saved.'; - public const ERROR_REPORT_NOT_ALLOWED = 'You are not allowed to report errors.'; - public const ERROR_REPORT_NOT_ENABLED = 'Remote error reporting is not enabled.'; - public const INVALID_CLIENT_ID = 'Invalid client_id.'; - public const INVALID_CONFIG = 'Invalid configuration value: \'%s\''; + public const ERROR_REPORT_NOT_ALLOWED = 'The client is not allowed to report errors.'; + public const ERROR_REPORT_NOT_CONFIGURED = 'Error report feature is not configured correctly.'; + public const ERROR_REPORT_NOT_ENABLED = 'Error report feature is not enabled.'; + public const ERROR_REPORT_UNAUTHORIZED = 'The client must provide a valid error reporting token via the %s' + . ' header.'; + public const EXPIRED = 'The requested resource has expired.'; + public const FORBIDDEN = 'The client is not allowed to access this resource.'; + public const INVALID_CLIENT_ID = 'The submitted client_id is invalid.'; + public const INVALID_CONFIG = 'Invalid configuration value for: \'%s\''; public const INVALID_VALUE = 'The value specified for \'%s\' is invalid.'; - public const MAIL_NOT_SENT_TO = 'Could not send mail to \'%s\'.'; + public const INVALID_VALUE_USE_ONE_OF = 'The value specified for \'%s\' is invalid. The client should use' + . ' one of the predefined values.'; + public const INTERNAL_SERVER_ERROR = 'An unexpected error occurred while processing the client request.'; + public const MAIL_NOT_SENT_TO = 'Unable to send mail to \'%s\'.'; public const MAIL_SENT_RECOVER_IDENTITY = 'If the provided email identifies an account in our system, ' - . 'you will receive an email with your account\'s identity.'; + . 'the account owner will receive an email with the account identity.'; public const MAIL_SENT_RESET_PASSWORD = 'If the provided email identifies an account in our system, ' - . 'you will receive an email with further instructions on resetting your account\'s password.'; + . 'the account owner will receive an email with further instructions on resetting the account password.'; public const MAIL_SENT_USER_ACTIVATION = 'User activation mail has been successfully sent to \'%s\''; + public const METHOD_NOT_ALLOWED = 'The request method is not supported for the requested resource.'; public const MISSING_CONFIG = 'Missing configuration value: \'%s\'.'; - public const RESET_PASSWORD_EXPIRED = 'Password reset request for hash: \'%s\' is invalid (expired).'; - public const RESET_PASSWORD_NOT_FOUND = 'Could not find password reset request identified by hash: \'%s\''; + public const NOT_ENOUGH_PERMISSIONS = 'To access this resource, the client needs to have the right' + . ' permissions.'; + public const OAUTH_MISSING_CONFIG = 'Unable to convert to JWT without config.'; + public const OAUTH_MISSING_PRIVATE_KEY = 'Unable to init JWT without private key'; + public const RESET_PASSWORD_EXPIRED = 'Password reset request is invalid (expired).'; + public const RESET_PASSWORD_NOT_FOUND = 'Password reset request not found.'; public const RESET_PASSWORD_OK = 'Password successfully modified.'; - public const RESET_PASSWORD_USED = 'Password reset request for hash: \'%s\' is invalid (used).'; - public const RESET_PASSWORD_VALID = 'Password reset request for hash: \'%s\' is valid.'; - public const RESOURCE_NOT_ALLOWED = 'You are not allowed to access this resource.'; + public const RESET_PASSWORD_USED = 'Password reset request is invalid (used).'; + public const RESET_PASSWORD_VALID = 'Password reset request is valid.'; + public const NOT_FOUND = 'The requested resource could not be found.'; public const RESTRICTION_DEPRECATION = 'Cannot use both `%s` and `%s` attributes on the same object.'; - public const RESTRICTION_IMAGE = 'File must be an image (jpg, png).'; - public const RESTRICTION_ROLES = 'User accounts must have at least one role.'; - public const ROLE_NOT_FOUND = 'Role not found.'; - public const USER_ACTIVATED = 'This account has been activated.'; - public const USER_ALREADY_ACTIVATED = 'This account is already active.'; - public const USER_NOT_ACTIVATED = 'User account must be activated first.'; - public const USER_NOT_FOUND = 'User not found.'; - public const USER_NOT_FOUND_BY_IDENTITY = 'Could not find account by identity \'%s\''; + public const RESTRICTION_IMAGE = 'Input file must have one of the following formats: jpg or png.'; + public const RESTRICTION_ROLES = 'Accounts must have at least one role.'; + public const SERVICE_NOT_FOUND = 'Service %s not found in container.'; + public const UNAUTHORIZED = 'The client must be authorized before accessing this resource.'; + public const UNSUPPORTED_MEDIA_TYPE = 'Unsupported Media Type'; + public const USER_ACTIVATED = 'User account has been activated.'; + public const USER_ALREADY_ACTIVE = 'User account is already active.'; + public const USER_AVATAR_NOT_FOUND = 'User avatar was not found.'; + public const USER_INACTIVE = 'User account must be activated first.'; + public const USER_NOT_FOUND = 'User account was not found.'; + public const USER_ROLE_NOT_FOUND = 'User role not found.'; + public const VALIDATOR_FIX_ERRORS = 'Fix the errors and try again.'; public const VALIDATOR_MIN_LENGTH = '%s must be at least %d characters long.'; public const VALIDATOR_PASSWORD_MISMATCH = 'Password confirmation does not match the provided password.'; - public const VALIDATOR_REQUIRED_FIELD = 'This field is required and cannot be empty.'; public const VALIDATOR_REQUIRED_FIELD_BY_NAME = '%s is required and cannot be empty.'; public const VALIDATOR_REQUIRED_UPLOAD = 'A file must be uploaded first.'; } diff --git a/src/App/src/Middleware/AuthorizationMiddleware.php b/src/App/src/Middleware/AuthorizationMiddleware.php index 600d446f..39de4327 100644 --- a/src/App/src/Middleware/AuthorizationMiddleware.php +++ b/src/App/src/Middleware/AuthorizationMiddleware.php @@ -8,13 +8,13 @@ use Api\Admin\Repository\AdminRepository; use Api\App\Entity\Guest; use Api\App\Entity\RoleInterface; +use Api\App\Exception\ForbiddenException; +use Api\App\Exception\UnauthorizedException; use Api\App\Message; use Api\App\UserIdentity; use Api\User\Entity\User; use Api\User\Repository\UserRepository; use Dot\DependencyInjection\Attribute\Inject; -use Fig\Http\Message\StatusCodeInterface; -use Laminas\Diactoros\Response\JsonResponse; use Mezzio\Authentication\UserInterface; use Mezzio\Authorization\AuthorizationInterface; use Psr\Http\Message\ResponseInterface; @@ -23,7 +23,6 @@ use Psr\Http\Server\RequestHandlerInterface; use function assert; -use function sprintf; class AuthorizationMiddleware implements MiddlewareInterface { @@ -39,6 +38,10 @@ public function __construct( ) { } + /** + * @throws ForbiddenException + * @throws UnauthorizedException + */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $defaultUser = $request->getAttribute(UserInterface::class); @@ -46,68 +49,45 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface switch ($defaultUser->getDetail('oauth_client_id')) { case 'admin': - $user = $this->adminRepository->findOneBy(['identity' => $defaultUser->getIdentity()]); - if (! $user instanceof Admin) { - return $this->unauthorizedResponse(sprintf( - Message::USER_NOT_FOUND_BY_IDENTITY, - $defaultUser->getIdentity() - )); + $account = $this->adminRepository->findOneBy(['identity' => $defaultUser->getIdentity()]); + if (! $account instanceof Admin) { + throw UnauthorizedException::create(Message::ADMIN_NOT_FOUND); } - if (! $user->isActive()) { - return $this->unauthorizedResponse(Message::ADMIN_NOT_ACTIVATED); + if (! $account->isActive()) { + throw UnauthorizedException::create(Message::ADMIN_INACTIVE); } - $request = $request->withAttribute(Admin::class, $user); + $request = $request->withAttribute(Admin::class, $account); break; case 'frontend': - $user = $this->userRepository->findOneBy(['identity' => $defaultUser->getIdentity()]); - if (! $user instanceof User || $user->isDeleted()) { - return $this->unauthorizedResponse(sprintf( - Message::USER_NOT_FOUND_BY_IDENTITY, - $defaultUser->getIdentity() - )); + $account = $this->userRepository->findOneBy(['identity' => $defaultUser->getIdentity()]); + if (! $account instanceof User || $account->isDeleted()) { + throw UnauthorizedException::create(Message::USER_NOT_FOUND); } - if ($user->getStatus() !== User::STATUS_ACTIVE) { - return $this->unauthorizedResponse(Message::USER_NOT_ACTIVATED); + if ($account->getStatus() !== User::STATUS_ACTIVE) { + throw UnauthorizedException::create(Message::USER_INACTIVE); } - $request = $request->withAttribute(User::class, $user); + $request = $request->withAttribute(User::class, $account); break; case 'guest': - $user = new Guest(); - $request = $request->withAttribute(Guest::class, $user); + $account = new Guest(); + $request = $request->withAttribute(Guest::class, $account); break; default: - return $this->unauthorizedResponse(Message::INVALID_CLIENT_ID); + throw UnauthorizedException::create(Message::INVALID_CLIENT_ID); } - $defaultUser->setRoles($user->getRoles()->map(function (RoleInterface $role) { + $defaultUser->setRoles($account->getRoles()->map(function (RoleInterface $role) { return $role->getName(); })->toArray()); $request = $request->withAttribute(UserInterface::class, $defaultUser); - $isGranted = false; foreach ($defaultUser->getRoles() as $role) { if ($this->authorization->isGranted($role, $request)) { - $isGranted = true; - break; + return $handler->handle($request); } } - if (! $isGranted) { - return $this->unauthorizedResponse(Message::RESOURCE_NOT_ALLOWED); - } - - return $handler->handle($request); - } - - protected function unauthorizedResponse(string $message): ResponseInterface - { - return new JsonResponse([ - 'error' => [ - 'messages' => [ - $message, - ], - ], - ], StatusCodeInterface::STATUS_FORBIDDEN); + throw ForbiddenException::create(Message::NOT_ENOUGH_PERMISSIONS); } } diff --git a/src/App/src/Middleware/ContentNegotiationMiddleware.php b/src/App/src/Middleware/ContentNegotiationMiddleware.php index 7d51390b..a3f04682 100644 --- a/src/App/src/Middleware/ContentNegotiationMiddleware.php +++ b/src/App/src/Middleware/ContentNegotiationMiddleware.php @@ -4,8 +4,10 @@ namespace Api\App\Middleware; -use Api\App\Handler\ResponseTrait; +use Api\App\Message; use Dot\DependencyInjection\Attribute\Inject; +use Fig\Http\Message\StatusCodeInterface; +use Laminas\Diactoros\Response\JsonResponse; use Mezzio\Router\RouteResult; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -22,16 +24,11 @@ use function strtok; use function trim; -class ContentNegotiationMiddleware implements MiddlewareInterface +readonly class ContentNegotiationMiddleware implements MiddlewareInterface { - use ResponseTrait; - - #[Inject( - "config.content-negotiation", - )] - public function __construct( - private readonly array $config, - ) { + #[Inject("config.content-negotiation")] + public function __construct(private array $config) + { } public function process( @@ -47,12 +44,12 @@ public function process( $accept = $this->formatAcceptRequest($request->getHeaderLine('Accept')); if (! $this->checkAccept($routeName, $accept)) { - return $this->notAcceptableResponse('Not Acceptable'); + return $this->notAcceptableResponse(Message::ACCEPT_NOT_ACCEPTABLE); } $contentType = $request->getHeaderLine('Content-Type'); if (! $this->checkContentType($routeName, $contentType)) { - return $this->unsupportedMediaTypeResponse('Unsupported Media Type'); + return $this->unsupportedMediaTypeResponse(Message::UNSUPPORTED_MEDIA_TYPE); } $response = $handler->handle($request); @@ -60,7 +57,7 @@ public function process( $responseContentType = $response->getHeaderLine('Content-Type'); if (! $this->validateResponseContentType($responseContentType, $accept)) { - return $this->notAcceptableResponse('Unable to resolve Accept header to a representation'); + return $this->notAcceptableResponse(Message::ACCEPT_NOT_RESOLVABLE); } return $response; @@ -133,4 +130,14 @@ public function validateResponseContentType(?string $contentType, array $accept) return in_array($contentType, $accept, true); } + + public function notAcceptableResponse(string $message): ResponseInterface + { + return new JsonResponse(['messages' => [$message]], StatusCodeInterface::STATUS_NOT_ACCEPTABLE); + } + + public function unsupportedMediaTypeResponse(string $message): ResponseInterface + { + return new JsonResponse(['messages' => [$message]], StatusCodeInterface::STATUS_UNSUPPORTED_MEDIA_TYPE); + } } diff --git a/src/App/src/Middleware/DeprecationMiddleware.php b/src/App/src/Middleware/DeprecationMiddleware.php index 914df509..831a8006 100644 --- a/src/App/src/Middleware/DeprecationMiddleware.php +++ b/src/App/src/Middleware/DeprecationMiddleware.php @@ -7,7 +7,7 @@ use Api\App\Attribute\MethodDeprecation; use Api\App\Attribute\ResourceDeprecation; use Api\App\Exception\DeprecationConflictException; -use Api\App\Handler\ResponseTrait; +use Api\App\Exception\RuntimeException; use Api\App\Message; use Dot\DependencyInjection\Attribute\Inject; use Laminas\Stratigility\MiddlewarePipe; @@ -20,7 +20,6 @@ use ReflectionClass; use ReflectionException; use ReflectionMethod; -use RuntimeException; use function array_column; use function array_filter; @@ -35,8 +34,6 @@ class DeprecationMiddleware implements MiddlewareInterface { - use ResponseTrait; - public const RESOURCE_DEPRECATION_ATTRIBUTE = ResourceDeprecation::class; public const METHOD_DEPRECATION_ATTRIBUTE = MethodDeprecation::class; @@ -51,7 +48,9 @@ public function __construct(protected readonly array $config) } /** + * @throws DeprecationConflictException * @throws ReflectionException + * @throws RuntimeException */ public function process( ServerRequestInterface $request, @@ -133,6 +132,7 @@ private function getReflectionAttributes(ReflectionClass $reflectionObject): arr /** * @throws ReflectionException + * @throws RuntimeException */ private function getHandler(MiddlewareInterface $routeMiddleware): ?ReflectionClass { @@ -154,12 +154,15 @@ private function getHandler(MiddlewareInterface $routeMiddleware): ?ReflectionCl } } } else { - throw new RuntimeException('Invalid route middleware provided.'); + throw RuntimeException::create('Invalid route middleware provided.'); } return $reflectionHandler; } + /** + * @throws DeprecationConflictException + */ private function validateAttributes(array $attributes): void { $intersect = array_intersect(self::DEPRECATION_ATTRIBUTES, array_column($attributes, 'deprecationType')); diff --git a/src/App/src/Middleware/ErrorResponseMiddleware.php b/src/App/src/Middleware/ErrorResponseMiddleware.php index 74db3714..f0d05c49 100644 --- a/src/App/src/Middleware/ErrorResponseMiddleware.php +++ b/src/App/src/Middleware/ErrorResponseMiddleware.php @@ -17,12 +17,9 @@ class ErrorResponseMiddleware implements MiddlewareInterface { - #[Inject( - "config.authentication", - )] - public function __construct( - protected array $config, - ) { + #[Inject("config.authentication")] + public function __construct(protected array $config) + { } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/src/App/src/OpenAPI.php b/src/App/src/OpenAPI.php index 07634f94..783c19d2 100644 --- a/src/App/src/OpenAPI.php +++ b/src/App/src/OpenAPI.php @@ -10,10 +10,10 @@ use Mezzio\Authentication\OAuth2\TokenEndpointHandler; use OpenApi\Attributes as OA; -#[OA\Info(version: '1.0', title: 'DotKernel API')] +#[OA\Info(version: '1.0', title: 'Dotkernel API')] #[OA\Server(url: 'http://api.dotkernel.localhost', description: 'Local development server')] #[OA\SecurityScheme(securityScheme: 'AuthToken', type: 'http', in: 'header', bearerFormat: 'JWT', scheme: 'bearer')] -#[OA\SecurityScheme(securityScheme: 'ErrorReportingToken', type: 'apiKey', in: 'header', name: 'Error-Reporting-Token')] +#[OA\SecurityScheme(securityScheme: 'ErrorReportingToken', type: 'apiKey', name: 'Error-Reporting-Token', in: 'header')] #[OA\ExternalDocumentation( description: 'Dotkernel API documentation', @@ -193,7 +193,7 @@ #[OA\Schema( schema: 'HomeMessage', properties: [ - new OA\Property(property: 'message', type: 'string', default: 'DotKernel API version 5'), + new OA\Property(property: 'message', type: 'string', default: 'Dotkernel API version 5'), ], type: 'object' )] @@ -201,13 +201,18 @@ #[OA\Schema( schema: 'ErrorMessage', properties: [ - new OA\Property( - property: 'error', - properties: [ - new OA\Property(property: 'messages', type: 'array', items: new OA\Items(type: 'string')), - ], - type: 'object', - ), + new OA\Property(property: 'title', type: 'string'), + new OA\Property(property: 'type', type: 'string', example: 'https://example.com/error/some-error'), + new OA\Property(property: 'status', type: 'integer', example: 500), + new OA\Property(property: 'detail', type: 'string', example: 'An error occurred'), + new OA\Property(property: 'additional', properties: [ + new OA\Property( + property: 'errors', + required: null, + type: 'array', + items: new OA\Items(type: 'string'), + ), + ], type: 'object'), ], type: 'object', )] @@ -216,11 +221,9 @@ schema: 'InfoMessage', properties: [ new OA\Property( - property: 'info', - properties: [ - new OA\Property(property: 'messages', type: 'array', items: new OA\Items(type: 'string')), - ], - type: 'object', + property: 'messages', + type: 'array', + items: new OA\Items(type: 'string'), ), ], type: 'object', diff --git a/src/App/src/Service/ErrorReportService.php b/src/App/src/Service/ErrorReportService.php index af50afed..12ea5f7b 100644 --- a/src/App/src/Service/ErrorReportService.php +++ b/src/App/src/Service/ErrorReportService.php @@ -5,11 +5,13 @@ namespace Api\App\Service; use Api\App\Exception\ForbiddenException; +use Api\App\Exception\RuntimeException; use Api\App\Exception\UnauthorizedException; use Api\App\Message; use Dot\DependencyInjection\Attribute\Inject; +use Dot\Log\LoggerInterface; use Psr\Http\Message\ServerRequestInterface; -use RuntimeException; +use Ramsey\Uuid\Uuid; use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; @@ -18,9 +20,7 @@ use function date; use function in_array; use function parse_url; -use function sha1; use function sprintf; -use function uniqid; use const PHP_EOL; use const PHP_URL_HOST; @@ -28,17 +28,22 @@ class ErrorReportService implements ErrorReportServiceInterface { private const HEADER_NAME = 'Error-Reporting-Token'; + private Filesystem $fileSystem; - private ?string $token = null; + private string $documentationUrl; + private string $token = ''; #[Inject( + "dot-log.default_logger", "config", )] public function __construct( - protected array $config + protected LoggerInterface $logger, + protected array $config, ) { - $this->fileSystem = new Filesystem(); - $this->config = $config[ErrorReportServiceInterface::class] ?? []; + $this->fileSystem = new Filesystem(); + $this->config = $config[ErrorReportServiceInterface::class] ?? []; + $this->documentationUrl = $this->config['documentation_url'] ?? ''; } /** @@ -46,10 +51,7 @@ public function __construct( */ public function appendMessage(string $message): void { - $this->fileSystem->appendToFile( - $this->config['path'], - sprintf('[%s] [%s] %s' . PHP_EOL, date('Y-m-d H:i:s'), (string) $this->token, $message) - ); + $this->fileSystem->appendToFile($this->config['path'], $this->prepareMessage($message)); } /** @@ -63,7 +65,7 @@ public function checkRequest(ServerRequestInterface $request): self $this->validateToken($request); if (! $this->isMatchingDomain($request) && ! $this->isMatchingIpAddress($request)) { - throw new ForbiddenException(Message::ERROR_REPORT_NOT_ALLOWED); + throw ForbiddenException::create(Message::ERROR_REPORT_NOT_ALLOWED); } return $this; @@ -71,23 +73,23 @@ public function checkRequest(ServerRequestInterface $request): self public function generateToken(): string { - return sha1(uniqid()); + return Uuid::uuid4()->toString(); } /** * @throws UnauthorizedException - * @throws ForbiddenException */ private function validateToken(ServerRequestInterface $request): void { - $this->token = $request->getHeaderLine(self::HEADER_NAME); - if (empty($this->token)) { - throw new UnauthorizedException(Message::ERROR_REPORT_NOT_ALLOWED); + $token = $request->getHeaderLine(self::HEADER_NAME); + if (empty($token) || ! in_array($token, $this->config['tokens'])) { + throw UnauthorizedException::create( + sprintf(Message::ERROR_REPORT_UNAUTHORIZED, self::HEADER_NAME), + $this->documentationUrl + ); } - if (! in_array($this->token, $this->config['tokens'])) { - throw new ForbiddenException(Message::ERROR_REPORT_NOT_ALLOWED); - } + $this->token = $token; } private function isMatchingDomain(ServerRequestInterface $request): bool @@ -109,60 +111,68 @@ private function isMatchingIpAddress(ServerRequestInterface $request): bool } /** + * @throws ForbiddenException * @throws RuntimeException */ private function validateConfigs(): void { if (! array_key_exists('enabled', $this->config)) { - throw new RuntimeException( + $this->logger->err( sprintf(Message::MISSING_CONFIG, 'config.ErrorReportServiceInterface::class.enabled') ); + throw RuntimeException::create(Message::ERROR_REPORT_NOT_CONFIGURED, $this->documentationUrl); } if ($this->config['enabled'] !== true) { - throw new RuntimeException(Message::ERROR_REPORT_NOT_ENABLED); + $this->logger->err(Message::ERROR_REPORT_NOT_ENABLED); + throw ForbiddenException::create(Message::ERROR_REPORT_NOT_ENABLED, $this->documentationUrl); } if (! array_key_exists('path', $this->config)) { - throw new RuntimeException( + $this->logger->err( sprintf(Message::MISSING_CONFIG, 'config.ErrorReportServiceInterface::class.path') ); + throw RuntimeException::create(Message::ERROR_REPORT_NOT_CONFIGURED, $this->documentationUrl); } if (empty($this->config['path'])) { - throw new RuntimeException( + $this->logger->err( sprintf(Message::INVALID_CONFIG, 'config.ErrorReportServiceInterface::class.path') ); + throw RuntimeException::create(Message::ERROR_REPORT_NOT_CONFIGURED, $this->documentationUrl); } if (! array_key_exists('tokens', $this->config)) { - throw new RuntimeException( + $this->logger->err( sprintf(Message::MISSING_CONFIG, 'config.ErrorReportServiceInterface::class.tokens') ); + throw RuntimeException::create(Message::ERROR_REPORT_NOT_CONFIGURED, $this->documentationUrl); } if (empty($this->config['tokens'])) { - throw new RuntimeException( + $this->logger->err( sprintf(Message::INVALID_CONFIG, 'config.ErrorReportServiceInterface::class.tokens') ); + throw RuntimeException::create(Message::ERROR_REPORT_NOT_CONFIGURED, $this->documentationUrl); } if (! array_key_exists('domain_whitelist', $this->config)) { - throw new RuntimeException( - sprintf( - Message::MISSING_CONFIG, - sprintf('config.%s.domain_whitelist', ErrorReportServiceInterface::class) - ) + $this->logger->err( + sprintf(Message::MISSING_CONFIG, 'config.ErrorReportServiceInterface::class.domain_whitelist') ); + throw RuntimeException::create(Message::ERROR_REPORT_NOT_CONFIGURED, $this->documentationUrl); } if (! array_key_exists('ip_whitelist', $this->config)) { - throw new RuntimeException( - sprintf( - Message::MISSING_CONFIG, - sprintf('config.%s.ip_whitelist', ErrorReportServiceInterface::class) - ) + $this->logger->err( + sprintf(Message::MISSING_CONFIG, 'config.ErrorReportServiceInterface::class.ip_whitelist') ); + throw RuntimeException::create(Message::ERROR_REPORT_NOT_CONFIGURED, $this->documentationUrl); } } + + private function prepareMessage(string $message): string + { + return sprintf('[%s] [%s] %s' . PHP_EOL, date('Y-m-d H:i:s'), $this->token, $message); + } } diff --git a/src/App/src/Service/ErrorReportServiceInterface.php b/src/App/src/Service/ErrorReportServiceInterface.php index 5d9a20f0..f6eb7865 100644 --- a/src/App/src/Service/ErrorReportServiceInterface.php +++ b/src/App/src/Service/ErrorReportServiceInterface.php @@ -5,9 +5,9 @@ namespace Api\App\Service; use Api\App\Exception\ForbiddenException; +use Api\App\Exception\RuntimeException; use Api\App\Exception\UnauthorizedException; use Psr\Http\Message\ServerRequestInterface; -use RuntimeException; use Symfony\Component\Filesystem\Exception\IOException; interface ErrorReportServiceInterface diff --git a/src/User/src/ConfigProvider.php b/src/User/src/ConfigProvider.php index 976326b6..26e1d6ee 100644 --- a/src/User/src/ConfigProvider.php +++ b/src/User/src/ConfigProvider.php @@ -5,6 +5,7 @@ namespace Api\User; use Api\App\ConfigProvider as AppConfigProvider; +use Api\App\Factory\HandlerDelegatorFactory; use Api\User\Collection\UserCollection; use Api\User\Collection\UserRoleCollection; use Api\User\Entity\User; @@ -46,8 +47,8 @@ public function __invoke(): array return [ 'dependencies' => $this->getDependencies(), 'doctrine' => $this->getDoctrineConfig(), - MetadataMap::class => $this->getHalConfig(), 'templates' => $this->getTemplates(), + MetadataMap::class => $this->getHalConfig(), ]; } @@ -55,9 +56,18 @@ public function getDependencies(): array { return [ 'delegators' => [ - Application::class => [ - RoutesDelegator::class, - ], + Application::class => [RoutesDelegator::class], + AccountActivateHandler::class => [HandlerDelegatorFactory::class], + AccountAvatarHandler::class => [HandlerDelegatorFactory::class], + AccountHandler::class => [HandlerDelegatorFactory::class], + AccountResetPasswordHandler::class => [HandlerDelegatorFactory::class], + AccountRecoveryHandler::class => [HandlerDelegatorFactory::class], + UserActivateHandler::class => [HandlerDelegatorFactory::class], + UserAvatarHandler::class => [HandlerDelegatorFactory::class], + UserHandler::class => [HandlerDelegatorFactory::class], + UserCollectionHandler::class => [HandlerDelegatorFactory::class], + UserRoleHandler::class => [HandlerDelegatorFactory::class], + UserRoleCollectionHandler::class => [HandlerDelegatorFactory::class], ], 'factories' => [ AccountActivateHandler::class => AttributedServiceFactory::class, @@ -67,11 +77,11 @@ public function getDependencies(): array AccountRecoveryHandler::class => AttributedServiceFactory::class, UserActivateHandler::class => AttributedServiceFactory::class, UserAvatarHandler::class => AttributedServiceFactory::class, - UserAvatarEventListener::class => AttributedServiceFactory::class, UserHandler::class => AttributedServiceFactory::class, UserCollectionHandler::class => AttributedServiceFactory::class, UserRoleHandler::class => AttributedServiceFactory::class, UserRoleCollectionHandler::class => AttributedServiceFactory::class, + UserAvatarEventListener::class => AttributedServiceFactory::class, UserService::class => AttributedServiceFactory::class, UserRoleService::class => AttributedServiceFactory::class, UserAvatarService::class => AttributedServiceFactory::class, diff --git a/src/User/src/Handler/AccountActivateHandler.php b/src/User/src/Handler/AccountActivateHandler.php index cdd12750..b1071e17 100644 --- a/src/User/src/Handler/AccountActivateHandler.php +++ b/src/User/src/Handler/AccountActivateHandler.php @@ -7,35 +7,23 @@ use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\App\Message; use Api\User\InputFilter\ActivateAccountInputFilter; use Api\User\Service\UserServiceInterface; use Dot\DependencyInjection\Attribute\Inject; use Dot\Mail\Exception\MailException; use Fig\Http\Message\StatusCodeInterface; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; use function sprintf; -class AccountActivateHandler implements RequestHandlerInterface +class AccountActivateHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserServiceInterface $userService, - ) { + #[Inject(UserServiceInterface::class)] + public function __construct(protected UserServiceInterface $userService) + { } /** @@ -46,7 +34,7 @@ public function patch(ServerRequestInterface $request): ResponseInterface { $user = $this->userService->findOneBy(['hash' => $request->getAttribute('hash')]); if ($user->isActive()) { - throw new ConflictException(Message::USER_ALREADY_ACTIVATED); + throw ConflictException::create(Message::USER_ALREADY_ACTIVE); } $this->userService->activateUser($user); @@ -64,12 +52,15 @@ public function post(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new ActivateAccountInputFilter())->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $user = $this->userService->findByEmail($inputFilter->getValue('email')); if ($user->isActive()) { - throw new ConflictException(Message::USER_ALREADY_ACTIVATED); + throw ConflictException::create(Message::USER_ALREADY_ACTIVE); } $this->userService->activateUser($user); diff --git a/src/User/src/Handler/AccountAvatarHandler.php b/src/User/src/Handler/AccountAvatarHandler.php index 1bfa7006..65ff295d 100644 --- a/src/User/src/Handler/AccountAvatarHandler.php +++ b/src/User/src/Handler/AccountAvatarHandler.php @@ -6,32 +6,20 @@ use Api\App\Exception\BadRequestException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\App\Message; use Api\User\Entity\User; use Api\User\InputFilter\UpdateAvatarInputFilter; use Api\User\Service\UserAvatarServiceInterface; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class AccountAvatarHandler implements RequestHandlerInterface +class AccountAvatarHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserAvatarServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserAvatarServiceInterface $userAvatarService, - ) { + #[Inject(UserAvatarServiceInterface::class)] + public function __construct(protected UserAvatarServiceInterface $userAvatarService) + { } /** @@ -41,7 +29,7 @@ public function delete(ServerRequestInterface $request): ResponseInterface { $user = $request->getAttribute(User::class); if (! $user->hasAvatar()) { - throw new NotFoundException(Message::AVATAR_MISSING); + throw NotFoundException::create(Message::USER_AVATAR_NOT_FOUND); } $this->userAvatarService->removeAvatar($user); @@ -56,7 +44,7 @@ public function get(ServerRequestInterface $request): ResponseInterface { $user = $request->getAttribute(User::class); if (! $user->hasAvatar()) { - throw new NotFoundException(Message::AVATAR_MISSING); + throw NotFoundException::create(Message::USER_AVATAR_NOT_FOUND); } return $this->createResponse($request, $user->getAvatar()); @@ -69,7 +57,10 @@ public function post(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new UpdateAvatarInputFilter())->setData($request->getUploadedFiles()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $userAvatar = $this->userAvatarService->createAvatar( diff --git a/src/User/src/Handler/AccountHandler.php b/src/User/src/Handler/AccountHandler.php index 89567b9b..0ee65da9 100644 --- a/src/User/src/Handler/AccountHandler.php +++ b/src/User/src/Handler/AccountHandler.php @@ -7,39 +7,24 @@ use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; +use Api\App\Message; use Api\User\Entity\User; use Api\User\InputFilter\CreateUserInputFilter; use Api\User\InputFilter\UpdateUserInputFilter; use Api\User\Service\UserServiceInterface; use Dot\DependencyInjection\Attribute\Inject; use Dot\Mail\Exception\MailException; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -use RuntimeException; -class AccountHandler implements RequestHandlerInterface +class AccountHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserServiceInterface $userService, - ) { + #[Inject(UserServiceInterface::class)] + public function __construct(protected UserServiceInterface $userService) + { } - /** - * @throws RuntimeException - */ public function delete(ServerRequestInterface $request): ResponseInterface { $this->userService->deleteUser($request->getAttribute(User::class)); @@ -63,7 +48,10 @@ public function patch(ServerRequestInterface $request): ResponseInterface ->setValidationGroup(['password', 'passwordConfirm', 'detail']) ->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $user = $this->userService->updateUser($request->getAttribute(User::class), $inputFilter->getValues()); @@ -83,7 +71,10 @@ public function post(ServerRequestInterface $request): ResponseInterface ->setValidationGroup(['identity', 'password', 'passwordConfirm', 'detail']) ->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $user = $this->userService->createUser($inputFilter->getValues()); diff --git a/src/User/src/Handler/AccountRecoveryHandler.php b/src/User/src/Handler/AccountRecoveryHandler.php index fef7d3ca..bc7bcea5 100644 --- a/src/User/src/Handler/AccountRecoveryHandler.php +++ b/src/User/src/Handler/AccountRecoveryHandler.php @@ -6,32 +6,20 @@ use Api\App\Exception\BadRequestException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\App\Message; use Api\User\InputFilter\RecoverIdentityInputFilter; use Api\User\Service\UserServiceInterface; use Dot\DependencyInjection\Attribute\Inject; use Dot\Mail\Exception\MailException; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class AccountRecoveryHandler implements RequestHandlerInterface +class AccountRecoveryHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserServiceInterface $userService, - ) { + #[Inject(UserServiceInterface::class)] + public function __construct(protected UserServiceInterface $userService) + { } /** @@ -43,7 +31,10 @@ public function post(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new RecoverIdentityInputFilter())->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $user = $this->userService->findByEmail($inputFilter->getValue('email')); diff --git a/src/User/src/Handler/AccountResetPasswordHandler.php b/src/User/src/Handler/AccountResetPasswordHandler.php index f3af021a..9e673383 100644 --- a/src/User/src/Handler/AccountResetPasswordHandler.php +++ b/src/User/src/Handler/AccountResetPasswordHandler.php @@ -8,7 +8,7 @@ use Api\App\Exception\ConflictException; use Api\App\Exception\ExpiredException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\App\Message; use Api\User\Entity\User; use Api\User\InputFilter\ResetPasswordInputFilter; @@ -17,28 +17,14 @@ use Dot\DependencyInjection\Attribute\Inject; use Dot\Mail\Exception\MailException; use Fig\Http\Message\StatusCodeInterface; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -use function sprintf; - -class AccountResetPasswordHandler implements RequestHandlerInterface +class AccountResetPasswordHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserServiceInterface $userService, - ) { + #[Inject(UserServiceInterface::class)] + public function __construct(protected UserServiceInterface $userService) + { } /** @@ -51,13 +37,13 @@ public function get(ServerRequestInterface $request): ResponseInterface $userResetPassword = $this->userService->findResetPasswordByHash($hash); if (! $userResetPassword->isValid()) { - throw new ExpiredException(sprintf(Message::RESET_PASSWORD_EXPIRED, $hash)); + throw ExpiredException::create(Message::RESET_PASSWORD_EXPIRED); } if ($userResetPassword->isCompleted()) { - throw new ExpiredException(sprintf(Message::RESET_PASSWORD_USED, $hash)); + throw ExpiredException::create(Message::RESET_PASSWORD_USED); } - return $this->infoResponse(sprintf(Message::RESET_PASSWORD_VALID, $hash)); + return $this->infoResponse(Message::RESET_PASSWORD_VALID); } /** @@ -73,15 +59,18 @@ public function patch(ServerRequestInterface $request): ResponseInterface $userResetPassword = $this->userService->findResetPasswordByHash($hash); if (! $userResetPassword->isValid()) { - throw new ExpiredException(sprintf(Message::RESET_PASSWORD_EXPIRED, $hash)); + throw ExpiredException::create(Message::RESET_PASSWORD_EXPIRED); } if ($userResetPassword->isCompleted()) { - throw new ConflictException(sprintf(Message::RESET_PASSWORD_USED, $hash)); + throw ConflictException::create(Message::RESET_PASSWORD_USED); } $inputFilter = (new UpdatePasswordInputFilter())->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $this->userService->updateUser( @@ -104,7 +93,10 @@ public function post(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new ResetPasswordInputFilter())->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } if (! empty($inputFilter->getValue('email'))) { @@ -116,7 +108,7 @@ public function post(ServerRequestInterface $request): ResponseInterface } if (! $user instanceof User) { - throw new NotFoundException(Message::USER_NOT_FOUND); + throw NotFoundException::create(Message::USER_NOT_FOUND); } $this->userService->updateUser($user->createResetPassword()); diff --git a/src/User/src/Handler/UserActivateHandler.php b/src/User/src/Handler/UserActivateHandler.php index 27e67f16..2d958e49 100644 --- a/src/User/src/Handler/UserActivateHandler.php +++ b/src/User/src/Handler/UserActivateHandler.php @@ -6,31 +6,19 @@ use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\App\Message; use Api\User\Service\UserServiceInterface; use Dot\DependencyInjection\Attribute\Inject; use Dot\Mail\Exception\MailException; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class UserActivateHandler implements RequestHandlerInterface +class UserActivateHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserServiceInterface $userService, - ) { + #[Inject(UserServiceInterface::class)] + public function __construct(protected UserServiceInterface $userService) + { } /** @@ -42,7 +30,7 @@ public function patch(ServerRequestInterface $request): ResponseInterface { $user = $this->userService->findOneBy(['uuid' => $request->getAttribute('uuid')]); if ($user->isActive()) { - throw new ConflictException(Message::USER_ALREADY_ACTIVATED); + throw ConflictException::create(Message::USER_ALREADY_ACTIVE); } $this->userService->activateUser($user); diff --git a/src/User/src/Handler/UserAvatarHandler.php b/src/User/src/Handler/UserAvatarHandler.php index 0c2cb1e9..a6f6cade 100644 --- a/src/User/src/Handler/UserAvatarHandler.php +++ b/src/User/src/Handler/UserAvatarHandler.php @@ -6,31 +6,22 @@ use Api\App\Exception\BadRequestException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\App\Message; use Api\User\InputFilter\UpdateAvatarInputFilter; use Api\User\Service\UserAvatarServiceInterface; use Api\User\Service\UserServiceInterface; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class UserAvatarHandler implements RequestHandlerInterface +class UserAvatarHandler extends AbstractHandler { - use HandlerTrait; - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, UserServiceInterface::class, UserAvatarServiceInterface::class, )] public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, protected UserServiceInterface $userService, protected UserAvatarServiceInterface $userAvatarService, ) { @@ -43,7 +34,7 @@ public function delete(ServerRequestInterface $request): ResponseInterface { $user = $this->userService->findOneBy(['uuid' => $request->getAttribute('uuid')]); if (! $user->hasAvatar()) { - throw new NotFoundException(Message::AVATAR_MISSING); + throw NotFoundException::create(Message::USER_AVATAR_NOT_FOUND); } $this->userAvatarService->removeAvatar($user); @@ -58,7 +49,7 @@ public function get(ServerRequestInterface $request): ResponseInterface { $user = $this->userService->findOneBy(['uuid' => $request->getAttribute('uuid')]); if (! $user->hasAvatar()) { - throw new NotFoundException(Message::AVATAR_MISSING); + throw NotFoundException::create(Message::USER_AVATAR_NOT_FOUND); } return $this->createResponse($request, $user->getAvatar()); @@ -72,7 +63,10 @@ public function post(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new UpdateAvatarInputFilter())->setData($request->getUploadedFiles()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $user = $this->userService->findOneBy(['uuid' => $request->getAttribute('uuid')]); diff --git a/src/User/src/Handler/UserCollectionHandler.php b/src/User/src/Handler/UserCollectionHandler.php index dfa94f4f..ced6280c 100644 --- a/src/User/src/Handler/UserCollectionHandler.php +++ b/src/User/src/Handler/UserCollectionHandler.php @@ -5,29 +5,17 @@ namespace Api\User\Handler; use Api\App\Exception\BadRequestException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\User\Service\UserServiceInterface; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class UserCollectionHandler implements RequestHandlerInterface +class UserCollectionHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserServiceInterface $userService, - ) { + #[Inject(UserServiceInterface::class)] + public function __construct(protected UserServiceInterface $userService) + { } /** diff --git a/src/User/src/Handler/UserHandler.php b/src/User/src/Handler/UserHandler.php index 490dcb4a..f5f024f7 100644 --- a/src/User/src/Handler/UserHandler.php +++ b/src/User/src/Handler/UserHandler.php @@ -7,38 +7,25 @@ use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; +use Api\App\Message; use Api\User\InputFilter\CreateUserInputFilter; use Api\User\InputFilter\UpdateUserInputFilter; use Api\User\Service\UserServiceInterface; use Dot\DependencyInjection\Attribute\Inject; use Dot\Mail\Exception\MailException; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -use RuntimeException; -class UserHandler implements RequestHandlerInterface +class UserHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserServiceInterface $userService, - ) { + #[Inject(UserServiceInterface::class)] + public function __construct(protected UserServiceInterface $userService) + { } /** * @throws NotFoundException - * @throws RuntimeException */ public function delete(ServerRequestInterface $request): ResponseInterface { @@ -68,7 +55,10 @@ public function patch(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new UpdateUserInputFilter())->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $user = $this->userService->findOneBy(['uuid' => $request->getAttribute('uuid')]); @@ -87,7 +77,10 @@ public function post(ServerRequestInterface $request): ResponseInterface { $inputFilter = (new CreateUserInputFilter())->setData((array) $request->getParsedBody()); if (! $inputFilter->isValid()) { - throw (new BadRequestException())->setMessages($inputFilter->getMessages()); + throw BadRequestException::create( + detail: Message::VALIDATOR_FIX_ERRORS, + additional: ['errors' => $inputFilter->getMessages()], + ); } $user = $this->userService->createUser($inputFilter->getValues()); diff --git a/src/User/src/Handler/UserRoleCollectionHandler.php b/src/User/src/Handler/UserRoleCollectionHandler.php index 10df222a..3fe9b964 100644 --- a/src/User/src/Handler/UserRoleCollectionHandler.php +++ b/src/User/src/Handler/UserRoleCollectionHandler.php @@ -5,29 +5,17 @@ namespace Api\User\Handler; use Api\App\Exception\BadRequestException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\User\Service\UserRoleServiceInterface; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class UserRoleCollectionHandler implements RequestHandlerInterface +class UserRoleCollectionHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserRoleServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserRoleServiceInterface $roleService, - ) { + #[Inject(UserRoleServiceInterface::class)] + public function __construct(protected UserRoleServiceInterface $roleService) + { } /** diff --git a/src/User/src/Handler/UserRoleHandler.php b/src/User/src/Handler/UserRoleHandler.php index d7599b91..7c924de3 100644 --- a/src/User/src/Handler/UserRoleHandler.php +++ b/src/User/src/Handler/UserRoleHandler.php @@ -5,29 +5,17 @@ namespace Api\User\Handler; use Api\App\Exception\NotFoundException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\User\Service\UserRoleServiceInterface; use Dot\DependencyInjection\Attribute\Inject; -use Mezzio\Hal\HalResponseFactory; -use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -class UserRoleHandler implements RequestHandlerInterface +class UserRoleHandler extends AbstractHandler { - use HandlerTrait; - - #[Inject( - HalResponseFactory::class, - ResourceGenerator::class, - UserRoleServiceInterface::class, - )] - public function __construct( - protected HalResponseFactory $responseFactory, - protected ResourceGenerator $resourceGenerator, - protected UserRoleServiceInterface $roleService, - ) { + #[Inject(UserRoleServiceInterface::class)] + public function __construct(protected UserRoleServiceInterface $roleService) + { } /** diff --git a/src/User/src/InputFilter/Input/AvatarInput.php b/src/User/src/InputFilter/Input/AvatarInput.php index fabe0888..14341f05 100644 --- a/src/User/src/InputFilter/Input/AvatarInput.php +++ b/src/User/src/InputFilter/Input/AvatarInput.php @@ -12,6 +12,8 @@ use Laminas\Validator\File\UploadFile; use Laminas\Validator\NotEmpty; +use function sprintf; + class AvatarInput extends FileInput { public function __construct(?string $name = null, bool $isRequired = true) @@ -26,7 +28,7 @@ public function __construct(?string $name = null, bool $isRequired = true) $this->getValidatorChain() ->attachByName(NotEmpty::class, [ - 'message' => Message::VALIDATOR_REQUIRED_FIELD, + 'message' => sprintf(Message::VALIDATOR_REQUIRED_FIELD_BY_NAME, 'Avatar'), ], true) ->attachByName(UploadFile::class, [ 'message' => Message::VALIDATOR_REQUIRED_UPLOAD, diff --git a/src/User/src/Repository/UserRepository.php b/src/User/src/Repository/UserRepository.php index b3577611..42a7e693 100644 --- a/src/User/src/Repository/UserRepository.php +++ b/src/User/src/Repository/UserRepository.php @@ -12,6 +12,7 @@ use Api\User\Entity\User; use Doctrine\ORM\EntityRepository; use Dot\DependencyInjection\Attribute\Entity; +use Fig\Http\Message\StatusCodeInterface; use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\UserRepositoryInterface; @@ -118,7 +119,12 @@ public function getUserEntityByUserCredentials( ->setParameter('identity', $username); break; default: - throw new OAuthServerException(Message::INVALID_CLIENT_ID, 6, 'invalid_client', 401); + throw new OAuthServerException( + Message::INVALID_CLIENT_ID, + 6, + 'invalid_client', + StatusCodeInterface::STATUS_UNAUTHORIZED + ); } $result = $qb->getQuery()->getArrayResult(); @@ -133,7 +139,12 @@ public function getUserEntityByUserCredentials( } if ($clientEntity->getName() === 'frontend' && $result['status'] !== User::STATUS_ACTIVE) { - throw new OAuthServerException(Message::USER_NOT_ACTIVATED, 6, 'inactive_user', 401); + throw new OAuthServerException( + Message::USER_INACTIVE, + 6, + 'inactive_user', + StatusCodeInterface::STATUS_UNAUTHORIZED + ); } return new UserEntity($username); diff --git a/src/User/src/Service/UserRoleService.php b/src/User/src/Service/UserRoleService.php index 514d045f..b8b3a248 100644 --- a/src/User/src/Service/UserRoleService.php +++ b/src/User/src/Service/UserRoleService.php @@ -32,7 +32,7 @@ public function findOneBy(array $params = []): UserRole { $role = $this->roleRepository->findOneBy($params); if (! $role instanceof UserRole) { - throw new NotFoundException(Message::ROLE_NOT_FOUND); + throw NotFoundException::create(Message::USER_ROLE_NOT_FOUND); } return $role; @@ -51,7 +51,7 @@ public function getRoles(array $params = []): UserRoleCollection $params['order'] = $params['order'] ?? 'role.created'; if (! in_array($params['order'], $values)) { - throw (new BadRequestException())->setMessages([sprintf(Message::INVALID_VALUE, 'order')]); + throw BadRequestException::create(sprintf(Message::INVALID_VALUE, 'order')); } return $this->roleRepository->getRoles($params); diff --git a/src/User/src/Service/UserService.php b/src/User/src/Service/UserService.php index a08b1712..0687a841 100644 --- a/src/User/src/Service/UserService.php +++ b/src/User/src/Service/UserService.php @@ -22,7 +22,6 @@ use Dot\Mail\Exception\MailException; use Dot\Mail\Service\MailService; use Mezzio\Template\TemplateRendererInterface; -use RuntimeException; use function date; use function in_array; @@ -68,11 +67,11 @@ public function activateUser(User $user): User public function createUser(array $data = []): User { if ($this->exists($data['identity'])) { - throw new ConflictException(Message::DUPLICATE_IDENTITY); + throw ConflictException::create(Message::DUPLICATE_IDENTITY); } if ($this->emailExists($data['detail']['email'])) { - throw new ConflictException(Message::DUPLICATE_EMAIL); + throw ConflictException::create(Message::DUPLICATE_EMAIL); } $detail = (new UserDetail()) @@ -107,9 +106,6 @@ public function revokeTokens(User $user): void } } - /** - * @throws RuntimeException - */ public function deleteUser(User $user): User { $this->revokeTokens($user); @@ -117,9 +113,6 @@ public function deleteUser(User $user): User return $this->anonymizeUser($user->markAsDeleted()); } - /** - * @throws RuntimeException - */ public function anonymizeUser(User $user): User { $placeholder = $this->getAnonymousPlaceholder(); @@ -185,7 +178,7 @@ public function findResetPasswordByHash(?string $hash): UserResetPassword { $userResetPassword = $this->userResetPasswordRepository->findOneBy(['hash' => $hash]); if (! $userResetPassword instanceof UserResetPassword) { - throw new NotFoundException(sprintf(Message::RESET_PASSWORD_NOT_FOUND, (string) $hash)); + throw NotFoundException::create(Message::RESET_PASSWORD_NOT_FOUND); } return $userResetPassword; @@ -198,7 +191,7 @@ public function findByEmail(string $email): User { $user = $this->userDetailRepository->findOneBy(['email' => $email])?->getUser(); if (! $user instanceof User) { - throw new NotFoundException(Message::USER_NOT_FOUND); + throw NotFoundException::create(Message::USER_NOT_FOUND); } return $user; @@ -219,7 +212,7 @@ public function findOneBy(array $params = []): User { $user = $this->userRepository->findOneBy($params); if (! $user instanceof User) { - throw new NotFoundException(Message::USER_NOT_FOUND); + throw NotFoundException::create(Message::USER_NOT_FOUND); } return $user; @@ -230,16 +223,24 @@ public function findOneBy(array $params = []): User */ public function getUsers(array $params = []): UserCollection { - $values = [ - 'user.identity', - 'user.status', - 'user.created', - 'user.updated', - ]; + $orders = ['user.uuid', 'user.identity', 'user.status', 'user.created', 'user.updated']; $params['order'] = $params['order'] ?? 'user.created'; - if (! in_array($params['order'], $values)) { - throw (new BadRequestException())->setMessages([sprintf(Message::INVALID_VALUE, 'order')]); + if (! in_array($params['order'], $orders)) { + throw BadRequestException::create( + detail: sprintf(Message::INVALID_VALUE_USE_ONE_OF, 'order'), + additional: ['order' => $orders], + ); + } + + $dirs = ['asc', 'desc']; + + $params['dir'] = $params['dir'] ?? 'desc'; + if (! in_array($params['dir'], $dirs)) { + throw BadRequestException::create( + detail: sprintf(Message::INVALID_VALUE_USE_ONE_OF, 'dir'), + additional: ['dir' => $dirs], + ); } return $this->userRepository->getUsers($params); @@ -350,14 +351,14 @@ public function updateUser(User $user, array $data = []): User { if (isset($data['identity'])) { if ($this->existsOther($data['identity'], $user->getUuid()->toString())) { - throw new ConflictException(Message::DUPLICATE_IDENTITY); + throw ConflictException::create(Message::DUPLICATE_IDENTITY); } $user->setIdentity($data['identity']); } if (isset($data['detail']['email'])) { if ($this->emailExistsOther($data['detail']['email'], $user->getUuid()->toString())) { - throw new ConflictException(Message::DUPLICATE_EMAIL); + throw ConflictException::create(Message::DUPLICATE_EMAIL); } } @@ -401,7 +402,7 @@ public function updateUser(User $user, array $data = []): User } if (! $user->hasRoles()) { - throw (new BadRequestException())->setMessages([Message::RESTRICTION_ROLES]); + throw BadRequestException::create(Message::RESTRICTION_ROLES); } return $this->userRepository->saveUser($user); diff --git a/src/User/src/Service/UserServiceInterface.php b/src/User/src/Service/UserServiceInterface.php index 3e9285b5..9ce74bd9 100644 --- a/src/User/src/Service/UserServiceInterface.php +++ b/src/User/src/Service/UserServiceInterface.php @@ -11,7 +11,6 @@ use Api\User\Entity\User; use Api\User\Entity\UserResetPassword; use Dot\Mail\Exception\MailException; -use RuntimeException; interface UserServiceInterface { @@ -25,14 +24,8 @@ public function createUser(array $data = []): User; public function revokeTokens(User $user): void; - /** - * @throws RuntimeException - */ public function deleteUser(User $user): User; - /** - * @throws RuntimeException - */ public function anonymizeUser(User $user): User; public function exists(string $identity = ''): bool; diff --git a/test/Functional/AdminTest.php b/test/Functional/AdminTest.php index 6d01f861..24c1677a 100644 --- a/test/Functional/AdminTest.php +++ b/test/Functional/AdminTest.php @@ -155,9 +155,8 @@ public function testCannotCreateDuplicateAdminAccount(): void $data = json_decode($response->getBody()->getContents(), true); $this->assertResponseConflict($response); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertSame(Message::DUPLICATE_IDENTITY, $data['error']['messages'][0]); + $this->assertArrayHasKey('detail', $data); + $this->assertSame(Message::DUPLICATE_IDENTITY, $data['detail']); } /** @@ -349,9 +348,8 @@ public function testAdminCreateUserAccountDuplicateEmail(): void $this->assertResponseConflict($response); $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertSame(Message::DUPLICATE_EMAIL, $data['error']['messages'][0]); + $this->assertArrayHasKey('detail', $data); + $this->assertSame(Message::DUPLICATE_EMAIL, $data['detail']); } /** @@ -493,9 +491,8 @@ public function testAdminUpdateUserAccountDuplicateEmail(): void $this->assertResponseConflict($response); $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertSame(Message::DUPLICATE_EMAIL, $data['error']['messages'][0]); + $this->assertArrayHasKey('detail', $data); + $this->assertSame(Message::DUPLICATE_EMAIL, $data['detail']); $userDetailRepository = $this->getEntityManager()->getRepository(UserDetail::class); $userDetail = $userDetailRepository->find($user2->getDetail()->getUuid()); diff --git a/test/Functional/UserTest.php b/test/Functional/UserTest.php index e0cce27a..7748b7df 100644 --- a/test/Functional/UserTest.php +++ b/test/Functional/UserTest.php @@ -50,10 +50,8 @@ public function testRegisterAccountDuplicateIdentity(): void $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertNotEmpty($data['error']); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertContains(Message::DUPLICATE_IDENTITY, $data['error']['messages']); + $this->assertArrayHasKey('detail', $data); + $this->assertSame(Message::DUPLICATE_IDENTITY, $data['detail']); } /** @@ -76,10 +74,8 @@ public function testRegisterAccountDuplicateEmail(): void $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertNotEmpty($data['error']); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertContains(Message::DUPLICATE_EMAIL, $data['error']['messages']); + $this->assertArrayHasKey('detail', $data); + $this->assertSame(Message::DUPLICATE_EMAIL, $data['detail']); } /** @@ -271,11 +267,11 @@ public function testActivateAccountByEmail(): void $this->assertResponseCreated($response); $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('info', $data); - $this->assertArrayHasKey('messages', $data['info']); + $this->assertArrayHasKey('messages', $data); + $this->assertNotEmpty($data['messages']); $this->assertSame( sprintf(Message::MAIL_SENT_USER_ACTIVATION, $user->getDetail()->getEmail()), - $data['info']['messages'][0] + $data['messages'][0] ); } @@ -332,12 +328,10 @@ public function testRequestResetPasswordExpired(): void $this->assertResponseGone($response); $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertNotEmpty($data['error']['messages'][0]); + $this->assertArrayHasKey('detail', $data); $this->assertSame( sprintf(Message::RESET_PASSWORD_EXPIRED, $resetPassword->getHash()), - $data['error']['messages'][0] + $data['detail'] ); } @@ -371,13 +365,8 @@ public function testRequestResetPasswordAlreadyUsed(): void $this->assertResponseConflict($response); $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertNotEmpty($data['error']['messages'][0]); - $this->assertSame( - sprintf(Message::RESET_PASSWORD_USED, $resetPassword->getHash()), - $data['error']['messages'][0] - ); + $this->assertArrayHasKey('detail', $data); + $this->assertSame(Message::RESET_PASSWORD_USED, $data['detail']); } /** @@ -410,10 +399,9 @@ public function testResetPassword(): void $this->assertResponseOk($response); $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('info', $data); - $this->assertArrayHasKey('messages', $data['info']); - $this->assertNotEmpty($data['info']['messages'][0]); - $this->assertSame(Message::RESET_PASSWORD_OK, $data['info']['messages'][0]); + $this->assertArrayHasKey('messages', $data); + $this->assertNotEmpty($data['messages']); + $this->assertSame(Message::RESET_PASSWORD_OK, $data['messages'][0]); } /** @@ -492,10 +480,9 @@ public function testRecoverAccountByIdentity(): void $this->assertResponseOk($response); $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('info', $data); - $this->assertArrayHasKey('messages', $data['info']); - $this->assertNotEmpty($data['info']['messages'][0]); - $this->assertSame(Message::MAIL_SENT_RECOVER_IDENTITY, $data['info']['messages'][0]); + $this->assertArrayHasKey('messages', $data); + $this->assertNotEmpty($data['messages']); + $this->assertSame(Message::MAIL_SENT_RECOVER_IDENTITY, $data['messages'][0]); } private function createUploadedFile(): UploadedFileInterface diff --git a/test/Unit/Admin/Service/AdminServiceTest.php b/test/Unit/Admin/Service/AdminServiceTest.php index 5dd4a6fe..ccda7411 100644 --- a/test/Unit/Admin/Service/AdminServiceTest.php +++ b/test/Unit/Admin/Service/AdminServiceTest.php @@ -9,7 +9,8 @@ use Api\Admin\Repository\AdminRepository; use Api\Admin\Service\AdminRoleService; use Api\Admin\Service\AdminService as Subject; -use Api\App\Message; +use Api\App\Exception\ConflictException; +use Api\App\Exception\NotFoundException; use Exception; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -42,12 +43,12 @@ public function setUp(): void } /** - * @throws Exception + * @throws ConflictException + * @throws NotFoundException */ public function testCreateAdminThrowsDuplicateIdentity(): void { - $this->expectException(Exception::class); - $this->expectExceptionMessage(Message::DUPLICATE_IDENTITY); + $this->expectException(ConflictException::class); $this->subject->method('exists')->willReturn(true); diff --git a/test/Unit/App/Middleware/AuthorizationMiddlewareTest.php b/test/Unit/App/Middleware/AuthorizationMiddlewareTest.php index 8b48a672..d2ba9460 100644 --- a/test/Unit/App/Middleware/AuthorizationMiddlewareTest.php +++ b/test/Unit/App/Middleware/AuthorizationMiddlewareTest.php @@ -7,14 +7,14 @@ use Api\Admin\Entity\Admin; use Api\Admin\Entity\AdminRole; use Api\Admin\Repository\AdminRepository; -use Api\App\Message; +use Api\App\Exception\ForbiddenException; +use Api\App\Exception\UnauthorizedException; use Api\App\Middleware\AuthorizationMiddleware as Subject; use Api\App\UserIdentity; use Api\User\Entity\User; use Api\User\Entity\UserRole; use Api\User\Repository\UserRepository; use Laminas\Diactoros\ServerRequest; -use Laminas\Http\Response; use Mezzio\Authentication\UserInterface; use Mezzio\Authorization\AuthorizationInterface; use PHPUnit\Framework\MockObject\Exception; @@ -23,9 +23,6 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use function json_decode; -use function sprintf; - class AuthorizationMiddlewareTest extends TestCase { private Subject $subject; @@ -59,13 +56,8 @@ public function testAuthorizationInvalidClientIdProvided(): void $identity = new UserIdentity('test@dotkernel.com', ['user'], ['oauth_client_id' => 'invalid_client_id']); $this->request = $this->request->withAttribute(UserInterface::class, $identity); - $response = $this->subject->process($this->request, $this->handler); - $this->assertSame(Response::STATUS_CODE_403, $response->getStatusCode()); - - $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertContains(Message::INVALID_CLIENT_ID, $data['error']['messages']); + $this->expectException(UnauthorizedException::class); + $this->subject->process($this->request, $this->handler); } public function testAuthorizationInactiveAdmin(): void @@ -79,13 +71,8 @@ public function testAuthorizationInactiveAdmin(): void $identity = new UserIdentity('admin@dotkernel.com', ['admin'], ['oauth_client_id' => 'admin']); $this->request = $this->request->withAttribute(UserInterface::class, $identity); - $response = $this->subject->process($this->request, $this->handler); - $this->assertSame(Response::STATUS_CODE_403, $response->getStatusCode()); - - $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertContains(Message::ADMIN_NOT_ACTIVATED, $data['error']['messages']); + $this->expectException(UnauthorizedException::class); + $this->subject->process($this->request, $this->handler); } public function testAuthorizationInactiveUser(): void @@ -95,13 +82,8 @@ public function testAuthorizationInactiveUser(): void $identity = new UserIdentity('test@dotkernel.com', ['user'], ['oauth_client_id' => 'frontend']); $this->request = $this->request->withAttribute(UserInterface::class, $identity); - $response = $this->subject->process($this->request, $this->handler); - $this->assertSame(Response::STATUS_CODE_403, $response->getStatusCode()); - - $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertContains(Message::USER_NOT_ACTIVATED, $data['error']['messages']); + $this->expectException(UnauthorizedException::class); + $this->subject->process($this->request, $this->handler); } public function testAuthorizationUserNotFoundOrDeleted(): void @@ -113,16 +95,8 @@ public function testAuthorizationUserNotFoundOrDeleted(): void $identity = new UserIdentity('test@dotkernel.com', ['user'], ['oauth_client_id' => 'frontend']); $this->request = $this->request->withAttribute(UserInterface::class, $identity); - $response = $this->subject->process($this->request, $this->handler); - $this->assertSame(Response::STATUS_CODE_403, $response->getStatusCode()); - - $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertContains( - sprintf(Message::USER_NOT_FOUND_BY_IDENTITY, $identity->getIdentity()), - $data['error']['messages'] - ); + $this->expectException(UnauthorizedException::class); + $this->subject->process($this->request, $this->handler); } public function testAuthorizationNotGranted(): void @@ -136,16 +110,8 @@ public function testAuthorizationNotGranted(): void $identity = new UserIdentity('test@dotkernel.com', ['user'], ['oauth_client_id' => 'frontend']); $this->request = $this->request->withAttribute(UserInterface::class, $identity); - $response = $this->subject->process($this->request, $this->handler); - $this->assertSame(Response::STATUS_CODE_403, $response->getStatusCode()); - - $data = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('error', $data); - $this->assertArrayHasKey('messages', $data['error']); - $this->assertContains( - Message::RESOURCE_NOT_ALLOWED, - $data['error']['messages'] - ); + $this->expectException(ForbiddenException::class); + $this->subject->process($this->request, $this->handler); } public function testAuthorizationAccessGranted(): void diff --git a/test/Unit/App/Middleware/DeprecationMiddlewareTest.php b/test/Unit/App/Middleware/DeprecationMiddlewareTest.php index e293dfa1..c853941d 100644 --- a/test/Unit/App/Middleware/DeprecationMiddlewareTest.php +++ b/test/Unit/App/Middleware/DeprecationMiddlewareTest.php @@ -7,7 +7,7 @@ use Api\App\Attribute\MethodDeprecation; use Api\App\Attribute\ResourceDeprecation; use Api\App\Exception\DeprecationConflictException; -use Api\App\Handler\HandlerTrait; +use Api\App\Handler\AbstractHandler; use Api\App\Message; use Api\App\Middleware\DeprecationMiddleware as Subject; use Fig\Http\Message\RequestMethodInterface; @@ -213,9 +213,7 @@ public function process( */ public function testDeprecationMethodUsesRequestMethod(): void { - $handler = new class implements RequestHandlerInterface { - use HandlerTrait; - + $handler = new class extends AbstractHandler implements RequestHandlerInterface { #[MethodDeprecation( sunset: '2038-01-01', link: 'get-test-link',