diff --git a/composer.json b/composer.json index 8fe35f6..4815dfa 100644 --- a/composer.json +++ b/composer.json @@ -78,7 +78,8 @@ "mezzio/mezzio-twigrenderer": "^2.15.0", "ramsey/uuid-doctrine": "^2.1.0", "roave/psr-container-doctrine": "^5.2.1", - "symfony/filesystem": "^7.0.3" + "symfony/filesystem": "^7.0.3", + "zircote/swagger-php": "^4.10" }, "require-dev": { "laminas/laminas-coding-standard": "^2.5", diff --git a/src/Admin/src/Entity/Admin.php b/src/Admin/src/Entity/Admin.php index 09b833d..b3f89d3 100644 --- a/src/Admin/src/Entity/Admin.php +++ b/src/Admin/src/Entity/Admin.php @@ -54,6 +54,7 @@ public function __construct() { parent::__construct(); + $this->created(); $this->roles = new ArrayCollection(); } diff --git a/src/Admin/src/Entity/AdminRole.php b/src/Admin/src/Entity/AdminRole.php index 103c073..76497cb 100644 --- a/src/Admin/src/Entity/AdminRole.php +++ b/src/Admin/src/Entity/AdminRole.php @@ -24,6 +24,13 @@ class AdminRole extends AbstractEntity implements RoleInterface self::ROLE_SUPERUSER, ]; + public function __construct() + { + parent::__construct(); + + $this->created(); + } + #[ORM\Column(name: "name", type: "string", length: 30, unique: true)] protected string $name = ''; diff --git a/src/Admin/src/Handler/AdminHandler.php b/src/Admin/src/Handler/AdminHandler.php index 71ae410..18a169c 100644 --- a/src/Admin/src/Handler/AdminHandler.php +++ b/src/Admin/src/Handler/AdminHandler.php @@ -58,6 +58,9 @@ public function get(ServerRequestInterface $request): ResponseInterface return $this->createResponse($request, $admin); } + /** + * @throws BadRequestException + */ public function getCollection(ServerRequestInterface $request): ResponseInterface { return $this->createResponse($request, $this->adminService->getAdmins($request->getQueryParams())); diff --git a/src/Admin/src/Handler/AdminRoleHandler.php b/src/Admin/src/Handler/AdminRoleHandler.php index 1423417..fdc2908 100644 --- a/src/Admin/src/Handler/AdminRoleHandler.php +++ b/src/Admin/src/Handler/AdminRoleHandler.php @@ -5,6 +5,7 @@ namespace Api\Admin\Handler; use Api\Admin\Service\AdminRoleServiceInterface; +use Api\App\Exception\BadRequestException; use Api\App\Exception\NotFoundException; use Api\App\Handler\HandlerTrait; use Dot\DependencyInjection\Attribute\Inject; @@ -42,11 +43,11 @@ public function get(ServerRequestInterface $request): ResponseInterface return $this->createResponse($request, $role); } + /** + * @throws BadRequestException + */ public function getCollection(ServerRequestInterface $request): ResponseInterface { - return $this->createResponse( - $request, - $this->roleService->getAdminRoles($request->getQueryParams()) - ); + return $this->createResponse($request, $this->roleService->getAdminRoles($request->getQueryParams())); } } diff --git a/src/Admin/src/OpenAPI.php b/src/Admin/src/OpenAPI.php new file mode 100644 index 0000000..20ac50c --- /dev/null +++ b/src/Admin/src/OpenAPI.php @@ -0,0 +1,560 @@ + []]], + tags: ['Admin'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'My admin account', + content: new OA\JsonContent(ref: '#/components/schemas/Admin'), + ), + ], +)] + +/** + * @see AdminAccountHandler::patch() + */ +#[OA\Patch( + path: '/admin/my-account', + description: 'Authenticated (super)admin updates their own account data', + summary: 'Admin updates their own account', + security: [['AuthToken' => []]], + requestBody: new OA\RequestBody( + description: 'Update my admin account request', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'password', type: 'string'), + new OA\Property(property: 'passwordConfirm', type: 'string'), + new OA\Property(property: 'firstName', type: 'string'), + new OA\Property(property: 'lastName', type: 'string'), + new OA\Property( + property: 'roles', + type: 'array', + items: new OA\Items( + required: ['uuid'], + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + ], + ), + ), + ], + type: 'object', + ), + ), + tags: ['Admin'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'My admin account', + content: new OA\JsonContent(ref: '#/components/schemas/Admin'), + ), + ], +)] + +/** + * @see AdminHandler::delete() + */ +#[OA\Delete( + path: '/admin/{uuid}', + description: 'Authenticated (super)admin deletes an admin account identified by its UUID', + summary: 'Admin deletes an admin account', + security: [['AuthToken' => []]], + tags: ['Admin'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'Admin UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_NO_CONTENT, + description: 'Admin account has been deleted', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see AdminHandler::get() + */ +#[OA\Get( + path: '/admin/{uuid}', + description: 'Authenticated (super)admin fetches an admin account identified by its UUID', + summary: 'Admin fetches an admin account', + security: [['AuthToken' => []]], + tags: ['Admin'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'Admin UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'Admin account', + content: new OA\JsonContent(ref: '#/components/schemas/Admin'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see AdminHandler::getCollection() + */ +#[OA\Get( + path: '/admin', + description: 'Authenticated (super)admin fetches a list of admin accounts', + summary: 'Admin lists admin accounts', + security: [['AuthToken' => []]], + tags: ['Admin'], + parameters: [ + new OA\Parameter( + name: 'page', + description: 'Page number', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer'), + example: 1, + ), + new OA\Parameter( + name: 'limit', + description: 'Limit', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer'), + example: 10, + ), + new OA\Parameter( + name: 'order', + description: 'Sort by field', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + examples: [ + new OA\Examples(example: 'admin.identity', summary: 'Identity', value: 'admin.identity'), + new OA\Examples(example: 'admin.firstName', summary: 'Firstname', value: 'admin.firstName'), + new OA\Examples(example: 'admin.lastName', summary: 'Lastname', value: 'admin.lastName'), + new OA\Examples(example: 'admin.status', summary: 'Status', value: 'admin.status'), + new OA\Examples(example: 'admin.created', summary: 'Created', value: 'admin.created'), + new OA\Examples(example: 'admin.updated', summary: 'Updated', value: 'admin.updated'), + ], + ), + new OA\Parameter( + name: 'dir', + description: 'Sort direction', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + examples: [ + new OA\Examples(example: 'desc', summary: 'Sort descending', value: 'desc'), + new OA\Examples(example: 'asc', summary: 'Sort ascending', value: 'asc'), + ], + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'List of admin accounts', + content: new OA\JsonContent(ref: '#/components/schemas/AdminCollection'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see AdminHandler::patch() + */ +#[OA\Patch( + path: '/admin/{uuid}', + description: 'Authenticated (super)admin updates an existing admin account', + summary: 'Admin updates an admin account', + security: [['AuthToken' => []]], + requestBody: new OA\RequestBody( + description: 'Update admin account request', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'password', type: 'string'), + new OA\Property(property: 'passwordConfirm', type: 'string'), + new OA\Property(property: 'firstName', type: 'string'), + new OA\Property(property: 'lastName', type: 'string'), + new OA\Property(property: 'status', type: 'string', default: Admin::STATUS_ACTIVE), + new OA\Property( + property: 'roles', + type: 'array', + items: new OA\Items( + required: ['uuid'], + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + ], + ), + ), + ], + type: 'object', + ), + ), + tags: ['Admin'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'Admin UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'Admin account updated', + content: new OA\JsonContent(ref: '#/components/schemas/Admin'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see AdminHandler::post() + */ +#[OA\Post( + path: '/admin', + description: 'Authenticated (super)admin creates a new admin account', + summary: 'Admin creates an admin account', + security: [['AuthToken' => []]], + requestBody: new OA\RequestBody( + description: 'Create admin account request', + required: true, + content: new OA\JsonContent( + required: ['identity', 'password', 'passwordConfirm', 'firstName', 'lastName', 'roles'], + properties: [ + new OA\Property(property: 'identity', type: 'string'), + new OA\Property(property: 'password', type: 'string'), + new OA\Property(property: 'passwordConfirm', type: 'string'), + new OA\Property(property: 'firstName', type: 'string'), + new OA\Property(property: 'lastName', type: 'string'), + new OA\Property(property: 'status', type: 'string', default: Admin::STATUS_ACTIVE), + new OA\Property( + property: 'roles', + type: 'array', + items: new OA\Items( + required: ['uuid'], + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + ], + ), + ), + ], + type: 'object', + ), + ), + tags: ['Admin'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_CREATED, + description: 'Admin account created', + content: new OA\JsonContent(ref: '#/components/schemas/Admin'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see AdminRoleHandler::get() + */ +#[OA\Get( + path: '/admin/role/{uuid}', + description: 'Authenticated (super)admin fetches an admin role identified by its UUID', + summary: 'Admin fetches an admin role', + security: [['AuthToken' => []]], + tags: ['AdminRole'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'Admin role UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'Admin role', + content: new OA\JsonContent(ref: '#/components/schemas/AdminRole'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see AdminRoleHandler::getCollection() + */ +#[OA\Get( + path: '/admin/role', + description: 'Authenticated (super)admin fetches a list of admin roles', + summary: 'Admin lists admin roles', + security: [['AuthToken' => []]], + tags: ['AdminRole'], + parameters: [ + new OA\Parameter( + name: 'page', + description: 'Page number', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer'), + example: 1, + ), + new OA\Parameter( + name: 'limit', + description: 'Limit', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer'), + example: 10, + ), + new OA\Parameter( + name: 'order', + description: 'Sort by field', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + examples: [ + new OA\Examples(example: 'role.name', summary: 'Name', value: 'role.name'), + new OA\Examples(example: 'role.created', summary: 'Created', value: 'role.created'), + new OA\Examples(example: 'role.updated', summary: 'Updated', value: 'role.updated'), + ], + ), + new OA\Parameter( + name: 'dir', + description: 'Sort direction', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + examples: [ + new OA\Examples(example: 'desc', summary: 'Sort descending', value: 'desc'), + new OA\Examples(example: 'asc', summary: 'Sort ascending', value: 'asc'), + ], + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'List of admin accounts', + content: new OA\JsonContent(ref: '#/components/schemas/AdminRoleCollection'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see Admin + */ +#[OA\Schema( + schema: 'Admin', + properties: [ + new OA\Property(property: 'uuid', type: 'string', example: '1234abcd-abcd-4321-12ab-123456abcdef'), + new OA\Property(property: 'identity', type: 'string'), + new OA\Property(property: 'firstName', type: 'string'), + new OA\Property(property: 'lastName', type: 'string'), + new OA\Property(property: 'status', type: 'string', example: Admin::STATUS_ACTIVE), + new OA\Property( + property: 'roles', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string', example: AdminRole::ROLE_ADMIN), + ], + type: 'object', + ), + ), + new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), + new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + new OA\Property( + property: '_links', + properties: [ + new OA\Property( + property: 'self', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/admin/1234abcd-abcd-4321-12ab-123456abcdef', + ), + ], + type: 'object', + ), + ], + type: 'object', + ), + ], + type: 'object', +)] + +/** + * @see AdminRole + */ +#[OA\Schema( + schema: 'AdminRole', + properties: [ + new OA\Property(property: 'uuid', type: 'string', example: '1234abcd-abcd-4321-12ab-123456abcdef'), + new OA\Property(property: 'name', type: 'string', example: AdminRole::ROLE_ADMIN), + new OA\Property( + property: '_links', + properties: [ + new OA\Property( + property: 'self', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/admin/role/1234abcd-abcd-4321-12ab-123456abcdef', + ), + ], + type: 'object', + ), + ], + type: 'object', + ), + ], + type: 'object', +)] + +/** + * @see AdminCollection + */ +#[OA\Schema( + schema: 'AdminCollection', + properties: [ + new OA\Property( + property: '_embedded', + properties: [ + new OA\Property( + property: 'admins', + type: 'array', + items: new OA\Items( + ref: '#/components/schemas/Admin', + ), + ), + ], + type: 'object', + ), + ], + type: 'object', + allOf: [ + new OA\Schema(ref: '#/components/schemas/Collection'), + ], +)] +/** + * @see AdminRoleCollection + */ +#[OA\Schema( + schema: 'AdminRoleCollection', + properties: [ + new OA\Property( + property: '_embedded', + properties: [ + new OA\Property( + property: 'roles', + type: 'array', + items: new OA\Items( + ref: '#/components/schemas/AdminRole', + ), + ), + ], + type: 'object', + ), + ], + type: 'object', + allOf: [ + new OA\Schema(ref: '#/components/schemas/Collection'), + ], +)] + +class OpenAPI +{ +} diff --git a/src/Admin/src/Repository/AdminRepository.php b/src/Admin/src/Repository/AdminRepository.php index 15d7e07..5526f24 100644 --- a/src/Admin/src/Repository/AdminRepository.php +++ b/src/Admin/src/Repository/AdminRepository.php @@ -6,9 +6,7 @@ use Api\Admin\Collection\AdminCollection; use Api\Admin\Entity\Admin; -use Api\App\Exception\ConflictException; use Api\App\Helper\PaginationHelper; -use Api\App\Message; use Doctrine\ORM\EntityRepository; use Dot\DependencyInjection\Attribute\Entity; @@ -24,15 +22,8 @@ public function deleteAdmin(Admin $admin): void $this->getEntityManager()->flush(); } - /** - * @throws ConflictException - */ public function saveAdmin(Admin $admin): Admin { - if (! $admin->hasRoles()) { - throw new ConflictException(Message::RESTRICTION_ROLES); - } - $this->getEntityManager()->persist($admin); $this->getEntityManager()->flush(); diff --git a/src/Admin/src/Service/AdminRoleService.php b/src/Admin/src/Service/AdminRoleService.php index b38d033..bace975 100644 --- a/src/Admin/src/Service/AdminRoleService.php +++ b/src/Admin/src/Service/AdminRoleService.php @@ -7,10 +7,14 @@ use Api\Admin\Collection\AdminRoleCollection; use Api\Admin\Entity\AdminRole; use Api\Admin\Repository\AdminRoleRepository; +use Api\App\Exception\BadRequestException; use Api\App\Exception\NotFoundException; use Api\App\Message; use Dot\DependencyInjection\Attribute\Inject; +use function in_array; +use function sprintf; + class AdminRoleService implements AdminRoleServiceInterface { #[Inject( @@ -34,8 +38,22 @@ public function findOneBy(array $params = []): AdminRole return $role; } + /** + * @throws BadRequestException + */ public function getAdminRoles(array $params = []): AdminRoleCollection { + $values = [ + 'role.name', + 'role.created', + 'role.updated', + ]; + + $params['order'] = $params['order'] ?? 'role.created'; + if (! in_array($params['order'], $values)) { + throw (new BadRequestException())->setMessages([sprintf(Message::INVALID_VALUE, 'order')]); + } + return $this->adminRoleRepository->getAdminRoles($params); } } diff --git a/src/Admin/src/Service/AdminRoleServiceInterface.php b/src/Admin/src/Service/AdminRoleServiceInterface.php index 4a95eed..a2d1dca 100644 --- a/src/Admin/src/Service/AdminRoleServiceInterface.php +++ b/src/Admin/src/Service/AdminRoleServiceInterface.php @@ -6,6 +6,7 @@ use Api\Admin\Collection\AdminRoleCollection; use Api\Admin\Entity\AdminRole; +use Api\App\Exception\BadRequestException; use Api\App\Exception\NotFoundException; interface AdminRoleServiceInterface @@ -15,5 +16,8 @@ interface AdminRoleServiceInterface */ public function findOneBy(array $params = []): AdminRole; + /** + * @throws BadRequestException + */ public function getAdminRoles(array $params = []): AdminRoleCollection; } diff --git a/src/Admin/src/Service/AdminService.php b/src/Admin/src/Service/AdminService.php index d596776..5d07def 100644 --- a/src/Admin/src/Service/AdminService.php +++ b/src/Admin/src/Service/AdminService.php @@ -6,13 +6,16 @@ use Api\Admin\Collection\AdminCollection; use Api\Admin\Entity\Admin; -use Api\Admin\Entity\AdminRole; use Api\Admin\Repository\AdminRepository; +use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; use Api\App\Message; use Dot\DependencyInjection\Attribute\Inject; +use function in_array; +use function sprintf; + class AdminService implements AdminServiceInterface { #[Inject( @@ -42,15 +45,9 @@ public function createAdmin(array $data = []): Admin ->setLastName($data['lastName']) ->setStatus($data['status'] ?? Admin::STATUS_ACTIVE); - if (! empty($data['roles'])) { - foreach ($data['roles'] as $roleData) { - $admin->addRole( - $this->adminRoleService->findOneBy(['uuid' => $roleData['uuid']]) - ); - } - } else { + foreach ($data['roles'] as $roleData) { $admin->addRole( - $this->adminRoleService->findOneBy(['name' => AdminRole::ROLE_ADMIN]) + $this->adminRoleService->findOneBy(['uuid' => $roleData['uuid']]) ); } @@ -73,6 +70,17 @@ public function exists(string $identity = ''): bool } } + public function existsOther(string $identity = '', string $uuid = ''): bool + { + try { + $admin = $this->findOneBy(['identity' => $identity]); + + return $admin->getUuid()->toString() !== $uuid; + } catch (NotFoundException) { + return false; + } + } + /** * @throws NotFoundException */ @@ -86,17 +94,39 @@ public function findOneBy(array $params = []): Admin return $admin; } + /** + * @throws BadRequestException + */ public function getAdmins(array $params = []): AdminCollection { + $values = [ + 'admin.identity', + 'admin.firstName', + 'admin.lastName', + 'admin.status', + 'admin.created', + 'admin.updated', + ]; + + $params['order'] = $params['order'] ?? 'admin.created'; + if (! in_array($params['order'], $values)) { + throw (new BadRequestException())->setMessages([sprintf(Message::INVALID_VALUE, 'order')]); + } + return $this->adminRepository->getAdmins($params); } /** + * @throws BadRequestException * @throws ConflictException * @throws NotFoundException */ 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); + } + if (! empty($data['password'])) { $admin->usePassword($data['password']); } @@ -122,6 +152,10 @@ public function updateAdmin(Admin $admin, array $data = []): Admin } } + if (! $admin->hasRoles()) { + throw (new BadRequestException())->setMessages([Message::RESTRICTION_ROLES]); + } + return $this->adminRepository->saveAdmin($admin); } } diff --git a/src/Admin/src/Service/AdminServiceInterface.php b/src/Admin/src/Service/AdminServiceInterface.php index cea80a3..1f0ada4 100644 --- a/src/Admin/src/Service/AdminServiceInterface.php +++ b/src/Admin/src/Service/AdminServiceInterface.php @@ -6,6 +6,7 @@ use Api\Admin\Collection\AdminCollection; use Api\Admin\Entity\Admin; +use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; @@ -26,9 +27,13 @@ public function exists(string $identity = ''): bool; */ public function findOneBy(array $params = []): Admin; + /** + * @throws BadRequestException + */ public function getAdmins(array $params = []): AdminCollection; /** + * @throws BadRequestException * @throws ConflictException * @throws NotFoundException */ diff --git a/src/App/src/Handler/ErrorReportHandler.php b/src/App/src/Handler/ErrorReportHandler.php index 3952514..5f2e4b2 100644 --- a/src/App/src/Handler/ErrorReportHandler.php +++ b/src/App/src/Handler/ErrorReportHandler.php @@ -6,6 +6,7 @@ use Api\App\Attribute\MethodDeprecation; use Api\App\Exception\ForbiddenException; +use Api\App\Exception\UnauthorizedException; use Api\App\Message; use Api\App\Service\ErrorReportServiceInterface; use Dot\DependencyInjection\Attribute\Inject; @@ -38,6 +39,7 @@ public function __construct( /** * @throws ForbiddenException * @throws RuntimeException + * @throws UnauthorizedException */ #[MethodDeprecation( sunset: '2038-01-01', diff --git a/src/App/src/OpenAPI.php b/src/App/src/OpenAPI.php new file mode 100644 index 0000000..07634f9 --- /dev/null +++ b/src/App/src/OpenAPI.php @@ -0,0 +1,304 @@ + []]], + requestBody: new OA\RequestBody( + description: 'Error reporting request', + required: true, + content: new OA\JsonContent( + required: ['message'], + properties: [ + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object', + ) + ), + tags: ['ErrorReport'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_CREATED, + description: 'Created', + content: new OA\JsonContent(ref: '#/components/schemas/InfoMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_UNAUTHORIZED, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_FORBIDDEN, + description: 'Forbidden', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + description: 'Error', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see TokenEndpointHandler::handle() + */ +#[OA\Post( + path: '/security/generate-token', + description: 'Client generates access token using username and password', + summary: 'Generate access token', + requestBody: new OA\RequestBody( + description: 'Access token generation request', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'grant_type', type: 'string', default: 'password'), + new OA\Property(property: 'client_id', type: 'string', enum: ['admin', 'frontend']), + new OA\Property(property: 'client_secret', type: 'string', enum: ['admin', 'frontend']), + new OA\Property(property: 'scope', type: 'string', default: 'api'), + new OA\Property(property: 'username', type: 'string'), + new OA\Property(property: 'password', type: 'string'), + ], + type: 'object', + ), + ), + tags: ['AccessToken'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'OK', + content: new OA\JsonContent(ref: '#/components/schemas/OAuth2SuccessMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/OAuth2GenerateErrorMessage'), + ), + ], +)] + +/** + * @see TokenEndpointHandler::handle() + */ +#[OA\Post( + path: '/security/refresh-token', + description: 'Client refreshes access token using refresh token', + summary: 'Refresh access token', + requestBody: new OA\RequestBody( + description: 'Access token refresh request', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'grant_type', type: 'string', default: 'refresh_token'), + new OA\Property(property: 'client_id', type: 'string', enum: ['admin', 'frontend']), + new OA\Property(property: 'client_secret', type: 'string', enum: ['admin', 'frontend']), + new OA\Property(property: 'scope', type: 'string', default: 'api'), + new OA\Property(property: 'refresh_token', type: 'string'), + ], + type: 'object', + ), + ), + tags: ['AccessToken'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'OK', + content: new OA\JsonContent(ref: '#/components/schemas/OAuth2SuccessMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_UNAUTHORIZED, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/OAuth2RefreshErrorMessage'), + ), + ], +)] + +#[OA\Schema( + schema: 'OAuth2GenerateErrorMessage', + properties: [ + new OA\Property(property: 'error', type: 'string'), + new OA\Property(property: 'error_description', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object', +)] + +#[OA\Schema( + schema: 'OAuth2RefreshErrorMessage', + properties: [ + new OA\Property(property: 'hint', type: 'string'), + ], + type: 'object', + allOf: [ + new OA\Schema(ref: '#/components/schemas/OAuth2GenerateErrorMessage'), + ], +)] + +#[OA\Schema( + schema: 'OAuth2SuccessMessage', + properties: [ + new OA\Property(property: 'token_type', type: 'string', default: 'Bearer'), + new OA\Property(property: 'expires_in', type: 'integer', default: 86400), + new OA\Property(property: 'access_token', type: 'string'), + new OA\Property(property: 'refresh_token', type: 'string'), + ], + type: 'object', +)] + +#[OA\Schema( + schema: 'HomeMessage', + properties: [ + new OA\Property(property: 'message', type: 'string', default: 'DotKernel API version 5'), + ], + type: 'object' +)] + +#[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', + ), + ], + type: 'object', +)] + +#[OA\Schema( + schema: 'InfoMessage', + properties: [ + new OA\Property( + property: 'info', + properties: [ + new OA\Property(property: 'messages', type: 'array', items: new OA\Items(type: 'string')), + ], + type: 'object', + ), + ], + type: 'object', +)] + +#[OA\Schema( + schema: 'Collection', + description: 'Base collection providing common structure to be extended by entity-specific collections', + properties: [ + new OA\Property(property: '_total_items', type: 'integer', example: 1), + new OA\Property(property: '_page', type: 'integer', example: 1), + new OA\Property(property: '_page_count', type: 'integer', example: 1), + new OA\Property( + property: '_links', + required: ['self'], + properties: [ + new OA\Property( + property: 'first', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/resource?page=1', + ), + ], + type: 'object', + ), + new OA\Property( + property: 'prev', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/resource?page=2', + ), + ], + type: 'object', + ), + new OA\Property( + property: 'self', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/resource?page=3', + ), + ], + type: 'object', + ), + new OA\Property( + property: 'next', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/resource?page=4', + ), + ], + type: 'object', + ), + new OA\Property( + property: 'last', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/resource?page=5', + ), + ], + type: 'object', + ), + ], + type: 'object', + ), + ], + type: 'object', +)] + +class OpenAPI +{ +} diff --git a/src/App/src/Service/ErrorReportServiceInterface.php b/src/App/src/Service/ErrorReportServiceInterface.php index 2fa343a..5d9a20f 100644 --- a/src/App/src/Service/ErrorReportServiceInterface.php +++ b/src/App/src/Service/ErrorReportServiceInterface.php @@ -5,6 +5,7 @@ namespace Api\App\Service; use Api\App\Exception\ForbiddenException; +use Api\App\Exception\UnauthorizedException; use Psr\Http\Message\ServerRequestInterface; use RuntimeException; use Symfony\Component\Filesystem\Exception\IOException; @@ -19,6 +20,7 @@ public function appendMessage(string $message): void; /** * @throws ForbiddenException * @throws RuntimeException + * @throws UnauthorizedException */ public function checkRequest(ServerRequestInterface $request): self; diff --git a/src/User/src/Entity/User.php b/src/User/src/Entity/User.php index 9bb33dd..c3d46e5 100644 --- a/src/User/src/Entity/User.php +++ b/src/User/src/Entity/User.php @@ -33,13 +33,13 @@ class User extends AbstractEntity implements UserEntityInterface self::STATUS_ACTIVE, ]; - #[ORM\OneToOne(mappedBy: "user", targetEntity: UserAvatar::class, cascade: ['persist', 'remove'])] + #[ORM\OneToOne(targetEntity: UserAvatar::class, mappedBy: "user", cascade: ['persist', 'remove'])] protected ?UserAvatar $avatar = null; - #[ORM\OneToOne(mappedBy: "user", targetEntity: UserDetail::class, cascade: ['persist', 'remove'])] + #[ORM\OneToOne(targetEntity: UserDetail::class, mappedBy: "user", cascade: ['persist', 'remove'])] protected UserDetail $detail; - #[ORM\OneToMany(mappedBy: "user", targetEntity: UserResetPassword::class, cascade: ['persist', 'remove'])] + #[ORM\OneToMany(targetEntity: UserResetPassword::class, mappedBy: "user", cascade: ['persist', 'remove'])] protected Collection $resetPasswords; #[ORM\ManyToMany(targetEntity: UserRole::class)] @@ -70,6 +70,7 @@ public function __construct() $this->roles = new ArrayCollection(); $this->resetPasswords = new ArrayCollection(); + $this->created(); $this->renewHash(); } diff --git a/src/User/src/Entity/UserAvatar.php b/src/User/src/Entity/UserAvatar.php index abc76fc..fa9d9a2 100644 --- a/src/User/src/Entity/UserAvatar.php +++ b/src/User/src/Entity/UserAvatar.php @@ -18,7 +18,7 @@ class UserAvatar extends AbstractEntity { use TimestampsTrait; - #[ORM\OneToOne(inversedBy: "avatar", targetEntity: User::class)] + #[ORM\OneToOne(targetEntity: User::class, inversedBy: "avatar")] #[ORM\JoinColumn(name: "userUuid", referencedColumnName: "uuid")] protected User $user; @@ -27,6 +27,13 @@ class UserAvatar extends AbstractEntity protected ?string $url = null; + public function __construct() + { + parent::__construct(); + + $this->created(); + } + public function getUser(): User { return $this->user; diff --git a/src/User/src/Entity/UserDetail.php b/src/User/src/Entity/UserDetail.php index a1711a5..707c29c 100644 --- a/src/User/src/Entity/UserDetail.php +++ b/src/User/src/Entity/UserDetail.php @@ -16,7 +16,7 @@ class UserDetail extends AbstractEntity { use TimestampsTrait; - #[ORM\OneToOne(inversedBy: "detail", targetEntity: User::class)] + #[ORM\OneToOne(targetEntity: User::class, inversedBy: "detail")] #[ORM\JoinColumn(name: "userUuid", referencedColumnName: "uuid")] protected User $user; @@ -29,6 +29,13 @@ class UserDetail extends AbstractEntity #[ORM\Column(name: "email", type: "string", length: 191)] protected string $email; + public function __construct() + { + parent::__construct(); + + $this->created(); + } + public function getUser(): User { return $this->user; @@ -80,6 +87,7 @@ public function setEmail(string $email): self public function getArrayCopy(): array { return [ + 'uuid' => $this->getUuid()->toString(), 'firstName' => $this->getFirstName(), 'lastName' => $this->getLastName(), 'email' => $this->getEmail(), diff --git a/src/User/src/Entity/UserResetPassword.php b/src/User/src/Entity/UserResetPassword.php index 11014bf..46bac3f 100644 --- a/src/User/src/Entity/UserResetPassword.php +++ b/src/User/src/Entity/UserResetPassword.php @@ -44,6 +44,7 @@ public function __construct() { parent::__construct(); + $this->created(); $this->expires = DateTimeImmutable::createFromMutable( (new DateTime())->add(new DateInterval('P1D')) ); diff --git a/src/User/src/Entity/UserRole.php b/src/User/src/Entity/UserRole.php index cc6ff4e..649e55b 100644 --- a/src/User/src/Entity/UserRole.php +++ b/src/User/src/Entity/UserRole.php @@ -27,6 +27,13 @@ class UserRole extends AbstractEntity implements RoleInterface #[ORM\Column(name: "name", type: "string", length: 20, unique: true)] protected ?string $name = null; + public function __construct() + { + parent::__construct(); + + $this->created(); + } + public function getName(): ?string { return $this->name; diff --git a/src/User/src/Handler/AccountHandler.php b/src/User/src/Handler/AccountHandler.php index b32bd27..9713299 100644 --- a/src/User/src/Handler/AccountHandler.php +++ b/src/User/src/Handler/AccountHandler.php @@ -57,7 +57,7 @@ public function get(ServerRequestInterface $request): ResponseInterface /** * @throws BadRequestException * @throws ConflictException - * @throws RuntimeException + * @throws NotFoundException */ public function patch(ServerRequestInterface $request): ResponseInterface { diff --git a/src/User/src/Handler/AccountResetPasswordHandler.php b/src/User/src/Handler/AccountResetPasswordHandler.php index 5a4ee31..52977ce 100644 --- a/src/User/src/Handler/AccountResetPasswordHandler.php +++ b/src/User/src/Handler/AccountResetPasswordHandler.php @@ -16,6 +16,7 @@ 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; @@ -123,6 +124,6 @@ public function post(ServerRequestInterface $request): ResponseInterface $this->userService->updateUser($user->createResetPassword()); $this->userService->sendResetPasswordRequestedMail($user); - return $this->infoResponse(Message::MAIL_SENT_RESET_PASSWORD); + return $this->infoResponse(Message::MAIL_SENT_RESET_PASSWORD, StatusCodeInterface::STATUS_CREATED); } } diff --git a/src/User/src/Handler/UserActivateHandler.php b/src/User/src/Handler/UserActivateHandler.php index 78a63df..b837a17 100644 --- a/src/User/src/Handler/UserActivateHandler.php +++ b/src/User/src/Handler/UserActivateHandler.php @@ -40,7 +40,7 @@ public function __construct( * @throws MailException * @throws NotFoundException */ - public function post(ServerRequestInterface $request): ResponseInterface + public function patch(ServerRequestInterface $request): ResponseInterface { $user = $this->userService->findOneBy(['uuid' => $request->getAttribute('uuid')]); if ($user->isActive()) { diff --git a/src/User/src/Handler/UserHandler.php b/src/User/src/Handler/UserHandler.php index cd8a190..f6b4755 100644 --- a/src/User/src/Handler/UserHandler.php +++ b/src/User/src/Handler/UserHandler.php @@ -61,6 +61,9 @@ public function get(ServerRequestInterface $request): ResponseInterface return $this->createResponse($request, $user); } + /** + * @throws BadRequestException + */ public function getCollection(ServerRequestInterface $request): ResponseInterface { return $this->createResponse($request, $this->userService->getUsers($request->getQueryParams())); @@ -70,7 +73,6 @@ public function getCollection(ServerRequestInterface $request): ResponseInterfac * @throws BadRequestException * @throws ConflictException * @throws NotFoundException - * @throws RuntimeException */ public function patch(ServerRequestInterface $request): ResponseInterface { diff --git a/src/User/src/Handler/UserRoleHandler.php b/src/User/src/Handler/UserRoleHandler.php index 754d884..70ddefe 100644 --- a/src/User/src/Handler/UserRoleHandler.php +++ b/src/User/src/Handler/UserRoleHandler.php @@ -4,6 +4,7 @@ namespace Api\User\Handler; +use Api\App\Exception\BadRequestException; use Api\App\Exception\NotFoundException; use Api\App\Handler\HandlerTrait; use Api\User\Service\UserRoleServiceInterface; @@ -42,6 +43,9 @@ public function get(ServerRequestInterface $request): ResponseInterface return $this->createResponse($request, $role); } + /** + * @throws BadRequestException + */ public function getCollection(ServerRequestInterface $request): ResponseInterface { return $this->createResponse($request, $this->roleService->getRoles($request->getQueryParams())); diff --git a/src/User/src/InputFilter/CreateUserInputFilter.php b/src/User/src/InputFilter/CreateUserInputFilter.php index 2a5d656..d29c0cf 100644 --- a/src/User/src/InputFilter/CreateUserInputFilter.php +++ b/src/User/src/InputFilter/CreateUserInputFilter.php @@ -20,7 +20,7 @@ public function __construct() { $roles = (new CollectionInputFilter()) ->setInputFilter(new UserRoleInputFilter()) - ->setIsRequired(false); + ->setIsRequired(true); $this ->add(new IdentityInput('identity')) diff --git a/src/User/src/InputFilter/UpdateUserInputFilter.php b/src/User/src/InputFilter/UpdateUserInputFilter.php index f84a127..842ffef 100644 --- a/src/User/src/InputFilter/UpdateUserInputFilter.php +++ b/src/User/src/InputFilter/UpdateUserInputFilter.php @@ -4,6 +4,7 @@ namespace Api\User\InputFilter; +use Api\User\InputFilter\Input\IdentityInput; use Api\User\InputFilter\Input\PasswordConfirmInput; use Api\User\InputFilter\Input\PasswordInput; use Api\User\InputFilter\Input\StatusInput; @@ -22,6 +23,7 @@ public function __construct() ->setIsRequired(false); $this + ->add(new IdentityInput('identity', false)) ->add(new PasswordInput('password', false)) ->add(new PasswordConfirmInput('passwordConfirm', false)) ->add(new StatusInput('status', false)) diff --git a/src/User/src/OpenAPI.php b/src/User/src/OpenAPI.php new file mode 100644 index 0000000..f595147 --- /dev/null +++ b/src/User/src/OpenAPI.php @@ -0,0 +1,1270 @@ + []]], + tags: ['User'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'User UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_NO_CONTENT, + description: 'User account has been deleted (anonymized)', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see UserHandler::get() + */ +#[OA\Get( + path: '/user/{uuid}', + description: 'Authenticated (super)admin fetches a user account identified by its UUID', + summary: 'Admin views user account', + security: [['AuthToken' => []]], + tags: ['User'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'User UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'User account', + content: new OA\JsonContent(ref: '#/components/schemas/User'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see UserHandler::getCollection() + */ +#[OA\Get( + path: '/user', + description: 'Authenticated (super)admin fetches a list of user accounts', + summary: 'Admin lists user accounts', + security: [['AuthToken' => []]], + tags: ['User'], + parameters: [ + new OA\Parameter( + name: 'page', + description: 'Page number', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer'), + example: 1, + ), + new OA\Parameter( + name: 'limit', + description: 'Limit', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer'), + example: 10, + ), + new OA\Parameter( + name: 'order', + description: 'Sort by field', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + examples: [ + new OA\Examples(example: 'user.identity', summary: 'Identity', value: 'user.identity'), + new OA\Examples(example: 'user.status', summary: 'Status', value: 'user.status'), + new OA\Examples(example: 'user.created', summary: 'Created', value: 'user.created'), + new OA\Examples(example: 'user.updated', summary: 'Updated', value: 'user.updated'), + ], + ), + new OA\Parameter( + name: 'dir', + description: 'Sort direction', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + examples: [ + new OA\Examples(example: 'desc', summary: 'Sort descending', value: 'desc'), + new OA\Examples(example: 'asc', summary: 'Sort ascending', value: 'asc'), + ], + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'List of user accounts', + content: new OA\JsonContent(ref: '#/components/schemas/UserCollection'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see UserHandler::patch() + */ +#[OA\Patch( + path: '/user/{uuid}', + description: 'Authenticated (super)admin updates an existing user account', + summary: 'Admin updates user account', + security: [['AuthToken' => []]], + requestBody: new OA\RequestBody( + description: 'Update user account request', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'identity', type: 'string'), + new OA\Property(property: 'password', type: 'string'), + new OA\Property(property: 'passwordConfirm', type: 'string'), + new OA\Property(property: 'status', type: 'string', default: User::STATUS_ACTIVE), + new OA\Property( + property: 'detail', + properties: [ + new OA\Property(property: 'firstName', type: 'string'), + new OA\Property(property: 'lastName', type: 'string'), + new OA\Property(property: 'email', type: 'string'), + ], + type: 'object', + ), + new OA\Property( + property: 'roles', + type: 'array', + items: new OA\Items( + required: ['uuid'], + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + ], + ), + ), + ], + type: 'object', + ), + ), + tags: ['User'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'User UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'User account updated', + content: new OA\JsonContent(ref: '#/components/schemas/User'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see UserHandler::post() + */ +#[OA\Post( + path: '/user', + description: 'Authenticated (super)admin creates a new user account', + summary: 'Admin creates user account', + security: [['AuthToken' => []]], + requestBody: new OA\RequestBody( + description: 'Create user account request', + required: true, + content: new OA\JsonContent( + required: ['identity', 'password', 'passwordConfirm', 'roles'], + properties: [ + new OA\Property(property: 'identity', type: 'string'), + new OA\Property(property: 'password', type: 'string'), + new OA\Property(property: 'passwordConfirm', type: 'string'), + new OA\Property(property: 'status', type: 'string', default: User::STATUS_ACTIVE), + new OA\Property( + property: 'detail', + required: ['email'], + properties: [ + new OA\Property(property: 'firstName', type: 'string'), + new OA\Property(property: 'lastName', type: 'string'), + new OA\Property(property: 'email', type: 'string'), + ], + type: 'object', + ), + new OA\Property( + property: 'roles', + type: 'array', + items: new OA\Items( + required: ['uuid'], + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + ], + ), + ), + ], + type: 'object', + ), + ), + tags: ['User'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_CREATED, + description: 'User account created', + content: new OA\JsonContent(ref: '#/components/schemas/User'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + description: 'Mail error', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see UserActivateHandler::patch() + */ +#[OA\Patch( + path: '/user/{uuid}/activate', + description: 'Authenticated (super)admin activates an existing user account', + summary: 'Admin activates user account', + security: [['AuthToken' => []]], + tags: ['User'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'User UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'User account activated', + content: new OA\JsonContent(ref: '#/components/schemas/InfoMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + description: 'Mail error', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see UserAvatarHandler::delete() + */ +#[OA\Delete( + path: '/user/{uuid}/avatar', + description: 'Authenticated (super)admin deletes a user avatar identified by user UUID', + summary: 'Admin deletes user avatar', + security: [['AuthToken' => []]], + tags: ['UserAvatar'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'User UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_NO_CONTENT, + description: 'User avatar has been deleted', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see UserAvatarHandler::get() + */ +#[OA\Get( + path: '/user/{uuid}/avatar', + description: 'Authenticated (super)admin fetches a user avatar identified by user UUID', + summary: 'Admin views user avatar', + security: [['AuthToken' => []]], + tags: ['UserAvatar'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'User UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'User avatar', + content: new OA\JsonContent(ref: '#/components/schemas/UserAvatar'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see UserAvatarHandler::post() + */ +#[OA\Post( + path: '/user/{uuid}/avatar', + description: 'Authenticated (super)admin creates user avatar for user identified by user UUID', + summary: 'Admin creates user avatar', + security: [['AuthToken' => []]], + requestBody: new OA\RequestBody( + description: 'Create user avatar request', + required: true, + content: new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema( + required: ['avatar'], + properties: [ + new OA\Property(property: 'avatar', type: 'file', format: 'binary'), + ], + type: 'object', + ), + ), + ), + tags: ['UserAvatar'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'User UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_CREATED, + description: 'User avatar created', + content: new OA\JsonContent(ref: '#/components/schemas/UserAvatar'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see UserRoleHandler::get() + */ +#[OA\Get( + path: '/user/role/{uuid}', + description: 'Authenticated (super)admin fetches a user role identified by its UUID', + summary: 'Admin views user role', + security: [['AuthToken' => []]], + tags: ['UserRole'], + parameters: [ + new OA\Parameter( + name: 'uuid', + description: 'UserRole UUID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'User role', + content: new OA\JsonContent(ref: '#/components/schemas/UserRole'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see UserRoleHandler::getCollection() + */ +#[OA\Get( + path: '/user/role', + description: 'Authenticated (super)admin fetches a list of user roles', + summary: 'Admin lists user roles', + security: [['AuthToken' => []]], + tags: ['UserRole'], + parameters: [ + new OA\Parameter( + name: 'page', + description: 'Page number', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer'), + example: 1, + ), + new OA\Parameter( + name: 'limit', + description: 'Limit', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer'), + example: 10, + ), + new OA\Parameter( + name: 'order', + description: 'Sort by field', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + examples: [ + new OA\Examples(example: 'role.name', summary: 'Name', value: 'role.name'), + new OA\Examples(example: 'role.created', summary: 'Created', value: 'role.created'), + new OA\Examples(example: 'role.updated', summary: 'Updated', value: 'role.updated'), + ], + ), + new OA\Parameter( + name: 'dir', + description: 'Sort direction', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + examples: [ + new OA\Examples(example: 'desc', summary: 'Sort descending', value: 'desc'), + new OA\Examples(example: 'asc', summary: 'Sort ascending', value: 'asc'), + ], + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'List of user roles', + content: new OA\JsonContent(ref: '#/components/schemas/UserRoleCollection'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see AccountHandler::delete() + */ +#[OA\Delete( + path: '/user/my-account', + description: 'Authenticated user deletes (anonymizes) their own account', + summary: 'User deletes (anonymizes) their own account', + security: [['AuthToken' => []]], + tags: ['User'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_NO_CONTENT, + description: 'User account has been deleted (anonymized)', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + description: 'Error', + ), + ], +)] + +/** + * @see AccountHandler::get() + */ +#[OA\Get( + path: '/user/my-account', + description: 'Authenticated user fetches their own account data', + summary: 'User fetches their own account', + security: [['AuthToken' => []]], + tags: ['User'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'My user account', + content: new OA\JsonContent(ref: '#/components/schemas/User'), + ), + ], +)] + +/** + * @see AccountHandler::patch() + */ +#[OA\Patch( + path: '/user/my-account', + description: 'Authenticated user updates their own account data', + summary: 'User updates their own account', + security: [['AuthToken' => []]], + requestBody: new OA\RequestBody( + description: 'Update my user account request', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'password', type: 'string'), + new OA\Property(property: 'passwordConfirm', type: 'string'), + new OA\Property( + property: 'detail', + properties: [ + new OA\Property(property: 'firstName', type: 'string'), + new OA\Property(property: 'lastName', type: 'string'), + new OA\Property(property: 'email', type: 'string'), + ], + type: 'object', + ), + ], + type: 'object', + ), + ), + tags: ['User'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'My admin account', + content: new OA\JsonContent(ref: '#/components/schemas/User'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see AccountHandler::post() + */ +#[OA\Post( + path: '/account/register', + description: 'Register user account', + summary: 'Unauthenticated user registers new user account', + requestBody: new OA\RequestBody( + description: 'Create user account request', + required: true, + content: new OA\JsonContent( + required: ['identity', 'password', 'passwordConfirm', 'detail'], + properties: [ + new OA\Property(property: 'identity', type: 'string'), + new OA\Property(property: 'password', type: 'string'), + new OA\Property(property: 'passwordConfirm', type: 'string'), + new OA\Property( + property: 'detail', + required: ['email'], + properties: [ + new OA\Property(property: 'firstName', type: 'string'), + new OA\Property(property: 'lastName', type: 'string'), + new OA\Property(property: 'email', type: 'string'), + ], + type: 'object', + ), + ], + type: 'object', + ), + ), + tags: ['User'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_CREATED, + description: 'User account created', + content: new OA\JsonContent(ref: '#/components/schemas/User'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + description: 'Mail error', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see AccountAvatarHandler::delete() + */ +#[OA\Delete( + path: '/user/my-avatar', + description: 'Authenticated user deletes their user avatar', + summary: 'User deletes their own avatar', + security: [['AuthToken' => []]], + tags: ['UserAvatar'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_NO_CONTENT, + description: 'User avatar has been deleted', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see AccountAvatarHandler::get() + */ +#[OA\Get( + path: '/user/my-avatar', + description: 'Authenticated user fetches their own avatar', + summary: 'User fetches their own avatar', + security: [['AuthToken' => []]], + tags: ['UserAvatar'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'User avatar', + content: new OA\JsonContent(ref: '#/components/schemas/UserAvatar'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see AccountAvatarHandler::post() + */ +#[OA\Post( + path: '/user/my-avatar', + description: 'Authenticated user creates their own avatar', + summary: 'User creates their own avatar', + security: [['AuthToken' => []]], + requestBody: new OA\RequestBody( + description: 'Create user avatar request', + required: true, + content: new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema( + required: ['avatar'], + properties: [ + new OA\Property(property: 'avatar', type: 'file', format: 'binary'), + ], + type: 'object', + ), + ), + ), + tags: ['UserAvatar'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_CREATED, + description: 'User avatar created', + content: new OA\JsonContent(ref: '#/components/schemas/UserAvatar'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see AccountResetPasswordHandler::get() + */ +#[OA\Get( + path: '/account/reset-password/{hash}', + description: 'Unauthenticated user fetches a reset password by its hash', + summary: 'Unauthenticated user fetches reset password', + tags: ['ResetPassword'], + parameters: [ + new OA\Parameter( + name: 'hash', + description: 'Reset password hash', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'Reset password status', + content: new OA\JsonContent(ref: '#/components/schemas/InfoMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_GONE, + description: 'Gone (expired)', + ), + ], +)] + +/** + * @see AccountResetPasswordHandler::patch() + */ +#[OA\Patch( + path: '/account/reset-password/{hash}', + description: 'Unauthenticated user modifies their password using a reset password identified by its hash', + summary: 'Unauthenticated user modifies their password', + requestBody: new OA\RequestBody( + description: 'Modify password request', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'password', type: 'string'), + new OA\Property(property: 'passwordConfirm', type: 'string'), + ], + type: 'object', + ), + ), + tags: ['ResetPassword'], + parameters: [ + new OA\Parameter( + name: 'hash', + description: 'Reset password hash', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'Reset password status', + content: new OA\JsonContent(ref: '#/components/schemas/InfoMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_GONE, + description: 'Gone (expired)', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + description: 'Mail error', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see AccountResetPasswordHandler::post() + */ +#[OA\Post( + path: '/account/reset-password', + description: 'Unauthenticated user requests to reset their password by providing their email/identity', + summary: 'Unauthenticated user requests to modify their password', + requestBody: new OA\RequestBody( + description: 'Reset password request', + required: true, + content: new OA\JsonContent( + type: 'object', + oneOf: [ + new OA\Schema( + properties: [ + new OA\Property(property: 'email', type: 'string'), + ], + ), + new OA\Schema( + properties: [ + new OA\Property(property: 'identity', type: 'string'), + ], + ), + ], + ), + ), + tags: ['ResetPassword'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_CREATED, + description: 'Reset password created', + content: new OA\JsonContent(ref: '#/components/schemas/InfoMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + description: 'Mail error', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see AccountRecoveryHandler::post() + */ +#[OA\Post( + path: '/account/recover-identity', + description: 'Unauthenticated user recovers their identity by providing their email', + summary: 'Unauthenticated user recovers their identity', + requestBody: new OA\RequestBody( + description: 'Recover identity request', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'email', type: 'string'), + ], + type: 'object', + ), + ), + tags: ['RecoverIdentity'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_CREATED, + description: 'Identity sent via email', + content: new OA\JsonContent(ref: '#/components/schemas/InfoMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + description: 'Mail error', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see AccountActivateHandler::patch() + */ +#[OA\Patch( + path: '/account/activate/{hash}', + description: 'Unauthenticated user activates their account using the hash from an activation link', + summary: 'Unauthenticated user activates their account', + tags: ['ActivateUser'], + parameters: [ + new OA\Parameter( + name: 'hash', + description: 'User activation hash', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + ), + ], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_OK, + description: 'Account activated', + content: new OA\JsonContent(ref: '#/components/schemas/InfoMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + ], +)] + +/** + * @see AccountActivateHandler::post() + */ +#[OA\Post( + path: '/account/activate', + description: 'Unauthenticated user requests an account activation link by providing their email', + summary: 'Unauthenticated user requests to activate account', + requestBody: new OA\RequestBody( + description: 'Account activation request', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'email', type: 'string'), + ], + type: 'object', + ), + ), + tags: ['ActivateUser'], + responses: [ + new OA\Response( + response: StatusCodeInterface::STATUS_CREATED, + description: 'Account activation requested', + content: new OA\JsonContent(ref: '#/components/schemas/InfoMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_BAD_REQUEST, + description: 'Bad Request', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_CONFLICT, + description: 'Conflict', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + new OA\Response( + response: StatusCodeInterface::STATUS_NOT_FOUND, + description: 'Not Found', + ), + new OA\Response( + response: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + description: 'Mail error', + content: new OA\JsonContent(ref: '#/components/schemas/ErrorMessage'), + ), + ], +)] + +/** + * @see User + */ +#[OA\Schema( + schema: 'User', + properties: [ + new OA\Property(property: 'uuid', type: 'string', example: '1234abcd-abcd-4321-12ab-123456abcdef'), + new OA\Property(property: 'hash', type: 'string'), + new OA\Property(property: 'identity', type: 'string'), + new OA\Property(property: 'status', type: 'string', example: User::STATUS_ACTIVE), + new OA\Property(property: 'isDeleted', type: 'boolean', example: false), + new OA\Property(property: 'avatar', ref: '#/components/schemas/UserAvatar', nullable: true), + new OA\Property(property: 'detail', ref: '#/components/schemas/UserDetail'), + new OA\Property( + property: 'roles', + type: 'array', + items: new OA\Items( + ref: '#/components/schemas/UserRole', + ), + ), + new OA\Property( + property: 'resetPasswords', + type: 'array', + items: new OA\Items( + ref: '#/components/schemas/UserResetPassword', + ), + ), + new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), + new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + new OA\Property( + property: '_links', + properties: [ + new OA\Property( + property: 'self', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/user/1234abcd-abcd-4321-12ab-123456abcdef', + ), + ], + type: 'object', + ), + ], + type: 'object', + ), + ], + type: 'object', +)] + +/** + * @see UserAvatar + */ +#[OA\Schema( + schema: 'UserAvatar', + properties: [ + new OA\Property(property: 'uuid', type: 'string', example: '1234abcd-abcd-4321-12ab-123456abcdef'), + new OA\Property( + property: 'url', + type: 'string', + example: 'https://example.com/uploads/user/1234abcd-abcd-4321-12ab-123456abcdef/' + . 'avatar-1234abcd-abcd-4321-12ab-123456abcdef.jpg', + ), + new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), + new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + new OA\Property( + property: '_links', + properties: [ + new OA\Property( + property: 'self', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/user/1234abcd-abcd-4321-12ab-123456abcdef/avatar', + ), + ], + type: 'object', + ), + ], + type: 'object', + ), + ], + type: 'object', +)] + +/** + * @see UserDetail + */ +#[OA\Schema( + schema: 'UserDetail', + properties: [ + new OA\Property(property: 'uuid', type: 'string', example: '1234abcd-abcd-4321-12ab-123456abcdef'), + new OA\Property(property: 'firstName', type: 'string'), + new OA\Property(property: 'lastName', type: 'string'), + new OA\Property(property: 'email', type: 'string'), + new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), + new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + ], + type: 'object', +)] + +/** + * @see UserResetPassword + */ +#[OA\Schema( + schema: 'UserResetPassword', + properties: [ + new OA\Property(property: 'uuid', type: 'string', example: '1234abcd-abcd-4321-12ab-123456abcdef'), + new OA\Property(property: 'expires', type: 'object', example: new DateTimeImmutable()), + new OA\Property(property: 'hash', type: 'string'), + new OA\Property(property: 'status', type: 'string', example: UserResetPassword::STATUS_REQUESTED), + new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), + new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + new OA\Property( + property: '_links', + properties: [ + new OA\Property( + property: 'self', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/user/1234abcd-abcd-4321-12ab-123456abcdef', + ), + ], + type: 'object', + ), + ], + type: 'object', + ), + ], + type: 'object', +)] + +/** + * @see UserRole + */ +#[OA\Schema( + schema: 'UserRole', + properties: [ + new OA\Property(property: 'uuid', type: 'string', example: '1234abcd-abcd-4321-12ab-123456abcdef'), + new OA\Property(property: 'name', type: 'string', example: UserRole::ROLE_USER), + new OA\Property( + property: '_links', + properties: [ + new OA\Property( + property: 'self', + properties: [ + new OA\Property( + property: 'href', + type: 'string', + example: 'https://example.com/user/role/1234abcd-abcd-4321-12ab-123456abcdef', + ), + ], + type: 'object', + ), + ], + type: 'object', + ), + ], + type: 'object', +)] + +#[OA\Schema( + schema: 'UserCollection', + properties: [ + new OA\Property( + property: '_embedded', + properties: [ + new OA\Property( + property: 'users', + type: 'array', + items: new OA\Items( + ref: '#/components/schemas/User', + ), + ), + ], + type: 'object', + ), + ], + type: 'object', + allOf: [ + new OA\Schema(ref: '#/components/schemas/Collection'), + ], +)] + +#[OA\Schema( + schema: 'UserRoleCollection', + properties: [ + new OA\Property( + property: '_embedded', + properties: [ + new OA\Property( + property: 'roles', + type: 'array', + items: new OA\Items( + ref: '#/components/schemas/UserRole', + ), + ), + ], + type: 'object', + ), + ], + type: 'object', + allOf: [ + new OA\Schema(ref: '#/components/schemas/Collection'), + ], +)] + +class OpenAPI +{ +} diff --git a/src/User/src/Repository/UserRepository.php b/src/User/src/Repository/UserRepository.php index b26ee0a..b357761 100644 --- a/src/User/src/Repository/UserRepository.php +++ b/src/User/src/Repository/UserRepository.php @@ -16,7 +16,6 @@ use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\UserRepositoryInterface; use Mezzio\Authentication\OAuth2\Entity\UserEntity; -use RuntimeException; use function password_verify; @@ -83,15 +82,8 @@ public function getUsers(array $filters = []): UserCollection return new UserCollection($qb, false); } - /** - * @throws RuntimeException - */ public function saveUser(User $user): User { - if (! $user->hasRoles()) { - throw new RuntimeException(Message::RESTRICTION_ROLES); - } - $this->getEntityManager()->persist($user); $this->getEntityManager()->flush(); diff --git a/src/User/src/RoutesDelegator.php b/src/User/src/RoutesDelegator.php index 3a4af0c..2769d0f 100644 --- a/src/User/src/RoutesDelegator.php +++ b/src/User/src/RoutesDelegator.php @@ -57,7 +57,7 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal 'user.view' ); - $app->post( + $app->patch( '/user/' . $uuid . '/activate', UserActivateHandler::class, 'user.activate' @@ -127,7 +127,7 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal ); /** - * Guests manage their accounts + * Unauthenticated users manage their accounts */ $app->post( diff --git a/src/User/src/Service/UserRoleService.php b/src/User/src/Service/UserRoleService.php index 112afc8..514d045 100644 --- a/src/User/src/Service/UserRoleService.php +++ b/src/User/src/Service/UserRoleService.php @@ -4,6 +4,7 @@ namespace Api\User\Service; +use Api\App\Exception\BadRequestException; use Api\App\Exception\NotFoundException; use Api\App\Message; use Api\User\Collection\UserRoleCollection; @@ -11,6 +12,9 @@ use Api\User\Repository\UserRoleRepository; use Dot\DependencyInjection\Attribute\Inject; +use function in_array; +use function sprintf; + class UserRoleService implements UserRoleServiceInterface { #[Inject( @@ -34,8 +38,22 @@ public function findOneBy(array $params = []): UserRole return $role; } + /** + * @throws BadRequestException + */ public function getRoles(array $params = []): UserRoleCollection { + $values = [ + 'role.name', + 'role.created', + 'role.updated', + ]; + + $params['order'] = $params['order'] ?? 'role.created'; + if (! in_array($params['order'], $values)) { + throw (new BadRequestException())->setMessages([sprintf(Message::INVALID_VALUE, 'order')]); + } + return $this->roleRepository->getRoles($params); } } diff --git a/src/User/src/Service/UserRoleServiceInterface.php b/src/User/src/Service/UserRoleServiceInterface.php index 0927d81..caf9b4a 100644 --- a/src/User/src/Service/UserRoleServiceInterface.php +++ b/src/User/src/Service/UserRoleServiceInterface.php @@ -4,6 +4,7 @@ namespace Api\User\Service; +use Api\App\Exception\BadRequestException; use Api\App\Exception\NotFoundException; use Api\User\Collection\UserRoleCollection; use Api\User\Entity\UserRole; @@ -15,5 +16,8 @@ interface UserRoleServiceInterface */ public function findOneBy(array $params = []): UserRole; + /** + * @throws BadRequestException + */ public function getRoles(array $params = []): UserRoleCollection; } diff --git a/src/User/src/Service/UserService.php b/src/User/src/Service/UserService.php index b883f09..a8b41d5 100644 --- a/src/User/src/Service/UserService.php +++ b/src/User/src/Service/UserService.php @@ -4,6 +4,7 @@ namespace Api\User\Service; +use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; use Api\App\Message; @@ -13,7 +14,6 @@ use Api\User\Entity\User; use Api\User\Entity\UserDetail; use Api\User\Entity\UserResetPassword; -use Api\User\Entity\UserRole; use Api\User\Repository\UserDetailRepository; use Api\User\Repository\UserRepository; use Api\User\Repository\UserResetPasswordRepository; @@ -25,6 +25,7 @@ use RuntimeException; use function date; +use function in_array; use function sprintf; class UserService implements UserServiceInterface @@ -55,9 +56,6 @@ public function __construct( ) { } - /** - * @throws RuntimeException - */ public function activateUser(User $user): User { return $this->userRepository->saveUser($user->activate()); @@ -66,7 +64,6 @@ public function activateUser(User $user): User /** * @throws ConflictException * @throws NotFoundException - * @throws RuntimeException */ public function createUser(array $data = []): User { @@ -96,10 +93,6 @@ public function createUser(array $data = []): User $this->userRoleService->findOneBy(['uuid' => $roleData['uuid']]) ); } - } else { - $user->addRole( - $this->userRoleService->findOneBy(['name' => UserRole::ROLE_USER]) - ); } return $this->userRepository->saveUser($user); @@ -232,8 +225,23 @@ public function findOneBy(array $params = []): User return $user; } + /** + * @throws BadRequestException + */ public function getUsers(array $params = []): UserCollection { + $values = [ + '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')]); + } + return $this->userRepository->getUsers($params); } @@ -334,9 +342,9 @@ public function sendWelcomeMail(User $user): bool } /** + * @throws BadRequestException * @throws ConflictException * @throws NotFoundException - * @throws RuntimeException */ public function updateUser(User $user, array $data = []): User { @@ -344,6 +352,7 @@ public function updateUser(User $user, array $data = []): User if ($this->existsOther($data['identity'], $user->getUuid()->toString())) { throw new ConflictException(Message::DUPLICATE_IDENTITY); } + $user->setIdentity($data['identity']); } if (isset($data['detail']['email'])) { @@ -391,6 +400,10 @@ public function updateUser(User $user, array $data = []): User } } + if (! $user->hasRoles()) { + throw (new BadRequestException())->setMessages([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 2c6a963..3e9285b 100644 --- a/src/User/src/Service/UserServiceInterface.php +++ b/src/User/src/Service/UserServiceInterface.php @@ -4,6 +4,7 @@ namespace Api\User\Service; +use Api\App\Exception\BadRequestException; use Api\App\Exception\ConflictException; use Api\App\Exception\NotFoundException; use Api\User\Collection\UserCollection; @@ -14,15 +15,11 @@ interface UserServiceInterface { - /** - * @throws RuntimeException - */ public function activateUser(User $user): User; /** * @throws ConflictException * @throws NotFoundException - * @throws RuntimeException */ public function createUser(array $data = []): User; @@ -63,6 +60,9 @@ public function findByIdentity(string $identity): ?User; */ public function findOneBy(array $params = []): User; + /** + * @throws BadRequestException + */ public function getUsers(array $params = []): UserCollection; /** @@ -86,8 +86,9 @@ public function sendResetPasswordCompletedMail(User $user): bool; public function sendWelcomeMail(User $user): bool; /** + * @throws BadRequestException * @throws ConflictException - * @throws RuntimeException + * @throws NotFoundException */ public function updateUser(User $user, array $data = []): User; diff --git a/test/Functional/AbstractFunctionalTest.php b/test/Functional/AbstractFunctionalTest.php index 173f997..bba7ca4 100644 --- a/test/Functional/AbstractFunctionalTest.php +++ b/test/Functional/AbstractFunctionalTest.php @@ -367,6 +367,10 @@ protected function replaceService(string $service, object $mockInstance): void $this->getContainer()->setAllowOverride(false); } + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ protected function getValidUserData(array $data = []): array { return [ @@ -379,6 +383,9 @@ protected function getValidUserData(array $data = []): array 'password' => $data['password'] ?? self::DEFAULT_PASSWORD, 'passwordConfirm' => $data['password'] ?? self::DEFAULT_PASSWORD, 'status' => $data['status'] ?? User::STATUS_ACTIVE, + 'roles' => [ + ['uuid' => $this->findUserRole(UserRole::ROLE_USER)->getUuid()->toString()], + ], ]; } @@ -523,11 +530,7 @@ protected function createAdmin(): Admin */ protected function createUser(array $data = []): User { - $userRoleRepository = $this->getEntityManager()->getRepository(UserRole::class); - - /** @var RoleInterface $userRole */ - $userRole = $userRoleRepository->findOneBy(['name' => UserRole::ROLE_USER]); - + $userRole = $this->findUserRole(UserRole::ROLE_USER); $userData = $this->getValidUserData(); $user = new User(); @@ -549,4 +552,15 @@ protected function createUser(array $data = []): User return $user; } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function findUserRole(string $name): ?UserRole + { + $userRoleRepository = $this->getEntityManager()->getRepository(UserRole::class); + + return $userRoleRepository->findOneBy(['name' => $name]); + } } diff --git a/test/Functional/AdminTest.php b/test/Functional/AdminTest.php index 80a0e85..6d01f86 100644 --- a/test/Functional/AdminTest.php +++ b/test/Functional/AdminTest.php @@ -340,6 +340,9 @@ public function testAdminCreateUserAccountDuplicateEmail(): void 'lastName' => 'Test', 'email' => 'user1@test.com', ], + 'roles' => [ + ['uuid' => $this->findUserRole(UserRole::ROLE_USER)->getUuid()->toString()], + ], ]; $response = $this->post('/user', $userData); @@ -377,6 +380,9 @@ public function testAdminCanCreateUserAccount(): void 'lastName' => 'Test', 'email' => 'test@user.com', ], + 'roles' => [ + ['uuid' => $this->findUserRole(UserRole::ROLE_USER)->getUuid()->toString()], + ], ]; $response = $this->post('/user', $userData); @@ -422,7 +428,7 @@ public function testAdminCanActiveUserAccount(): void $this->loginAs($admin->getIdentity(), self::DEFAULT_PASSWORD, 'admin', 'admin'); $this->assertFalse($user->isActive()); - $response = $this->post(sprintf('/user/%s/activate', $user->getUuid()->toString())); + $response = $this->patch(sprintf('/user/%s/activate', $user->getUuid()->toString())); $this->assertResponseOk($response); diff --git a/test/Functional/UserTest.php b/test/Functional/UserTest.php index 8886462..e0cce27 100644 --- a/test/Functional/UserTest.php +++ b/test/Functional/UserTest.php @@ -431,7 +431,7 @@ public function testResetPasswordByEmail(): void $response = $this->post('/account/reset-password', [ 'email' => $user->getDetail()->getEmail(), ]); - $this->assertResponseOk($response); + $this->assertResponseCreated($response); $this->assertCount(1, $user->getResetPasswords()); } diff --git a/test/Unit/User/Service/UserServiceTest.php b/test/Unit/User/Service/UserServiceTest.php index bced1fd..b80ff88 100644 --- a/test/Unit/User/Service/UserServiceTest.php +++ b/test/Unit/User/Service/UserServiceTest.php @@ -4,6 +4,8 @@ namespace ApiTest\Unit\User\Service; +use Api\App\Exception\ConflictException; +use Api\App\Exception\NotFoundException; use Api\App\Repository\OAuthAccessTokenRepository; use Api\App\Repository\OAuthRefreshTokenRepository; use Api\User\Entity\User; @@ -54,6 +56,10 @@ public function setUp(): void ); } + /** + * @throws NotFoundException + * @throws ConflictException + */ public function testCreateUserThrowsExceptionDuplicateIdentity(): void { $this->userRepository->method('findOneBy')->willReturn( @@ -194,6 +200,9 @@ private function getUser(array $data = []): array 'lastName' => 'last', 'email' => 'test@dotkernel2.com', ], + 'roles' => [ + ['uuid' => 'uuid', 'name' => UserRole::ROLE_USER], + ], ]; return array_merge($user, $data);