From 8e39a843afb42c5fbea15fd4ff95b5c9afe1f7e7 Mon Sep 17 00:00:00 2001 From: bidi47 Date: Fri, 9 Oct 2026 15:34:11 +0300 Subject: [PATCH 1/3] added operationId Signed-off-by: bidi47 --- src/Admin/src/OpenAPI.php | 9 +++++++++ src/App/src/OpenAPI.php | 2 ++ src/Security/src/OpenAPI.php | 2 ++ src/User/src/OpenAPI.php | 25 +++++++++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/src/Admin/src/OpenAPI.php b/src/Admin/src/OpenAPI.php index 7ca94efc..61330ee4 100644 --- a/src/Admin/src/OpenAPI.php +++ b/src/Admin/src/OpenAPI.php @@ -27,6 +27,7 @@ */ #[OA\Get( path: '/admin', + operationId: 'getAdminCollection', description: 'Authenticated (super)admin fetches a list of admin accounts', summary: 'Admin lists admin accounts', security: [['AuthToken' => []]], @@ -98,6 +99,7 @@ */ #[OA\Post( path: '/admin', + operationId: 'postAdminResource', description: 'Authenticated (super)admin creates a new admin account', summary: 'Admin creates an admin account', security: [['AuthToken' => []]], @@ -157,6 +159,7 @@ */ #[OA\Delete( path: '/admin/{id}', + operationId: 'deleteAdminResource', description: 'Authenticated (super)admin deletes an admin account identified by its id', summary: 'Admin deletes an admin account', security: [['AuthToken' => []]], @@ -187,6 +190,7 @@ */ #[OA\Get( path: '/admin/{id}', + operationId: 'getAdminResource', description: 'Authenticated (super)admin fetches an admin account identified by its id', summary: 'Admin fetches an admin account', security: [['AuthToken' => []]], @@ -218,6 +222,7 @@ */ #[OA\Patch( path: '/admin/{id}', + operationId: 'patchAdminResource', description: 'Authenticated (super)admin updates an existing admin account', summary: 'Admin updates an admin account', security: [['AuthToken' => []]], @@ -284,6 +289,7 @@ */ #[OA\Get( path: '/admin/role', + operationId: 'getAdminRoleCollection', description: 'Authenticated (super)admin fetches a list of admin roles', summary: 'Admin lists admin roles', security: [['AuthToken' => []]], @@ -352,6 +358,7 @@ */ #[OA\Get( path: '/admin/role/{id}', + operationId: 'getAdminRoleResource', description: 'Authenticated (super)admin fetches an admin role identified by its id', summary: 'Admin fetches an admin role', security: [['AuthToken' => []]], @@ -383,6 +390,7 @@ */ #[OA\Get( path: '/admin/account', + operationId: 'getAdminAccountResource', description: 'Authenticated (super)admin fetches their own account data', summary: 'Admin fetches their own account', security: [['AuthToken' => []]], @@ -401,6 +409,7 @@ */ #[OA\Patch( path: '/admin/account', + operationId: 'patchAdminAccountResource', description: 'Authenticated (super)admin updates their own account data', summary: 'Admin updates their own account', security: [['AuthToken' => []]], diff --git a/src/App/src/OpenAPI.php b/src/App/src/OpenAPI.php index 793e5367..14370687 100644 --- a/src/App/src/OpenAPI.php +++ b/src/App/src/OpenAPI.php @@ -14,6 +14,7 @@ */ #[OA\Get( path: '/', + operationId: 'getIndexResource', description: 'API home page outputting default message', summary: 'API home page', tags: ['Home'], @@ -35,6 +36,7 @@ */ #[OA\Post( path: '/error-report', + operationId: 'postErrorReportResource', description: 'Third-party application reports an error to the API', summary: 'Report an error to the API', security: [['ErrorReportingToken' => []]], diff --git a/src/Security/src/OpenAPI.php b/src/Security/src/OpenAPI.php index c59688d9..4942a041 100644 --- a/src/Security/src/OpenAPI.php +++ b/src/Security/src/OpenAPI.php @@ -13,6 +13,7 @@ */ #[OA\Post( path: '/security/generate-token', + operationId: 'postGenerateToken', description: 'Client generates access token using username and password', summary: 'Generate access token', requestBody: new OA\RequestBody( @@ -50,6 +51,7 @@ */ #[OA\Post( path: '/security/refresh-token', + operationId: 'postRefreshToken', description: 'Client refreshes access token using refresh token', summary: 'Refresh access token', requestBody: new OA\RequestBody( diff --git a/src/User/src/OpenAPI.php b/src/User/src/OpenAPI.php index 97b8a7de..20234a4f 100644 --- a/src/User/src/OpenAPI.php +++ b/src/User/src/OpenAPI.php @@ -46,6 +46,7 @@ */ #[OA\Get( path: '/user', + operationId: 'getUserCollection', description: 'Authenticated (super)admin fetches a list of user accounts', summary: 'Admin lists user accounts', security: [['AuthToken' => []]], @@ -115,6 +116,7 @@ */ #[OA\Post( path: '/user', + operationId: 'postUserResource', description: 'Authenticated (super)admin creates a new user account', summary: 'Admin creates user account', security: [['AuthToken' => []]], @@ -187,6 +189,7 @@ */ #[OA\Delete( path: '/user/{id}', + operationId: 'deleteUserResource', description: 'Authenticated (super)admin deletes (anonymizes) a user account identified by its id', summary: 'Admin deletes (anonymizes) user account', security: [['AuthToken' => []]], @@ -217,6 +220,7 @@ */ #[OA\Get( path: '/user/{id}', + operationId: 'getUserResource', description: 'Authenticated (super)admin fetches a user account identified by its id', summary: 'Admin views user account', security: [['AuthToken' => []]], @@ -248,6 +252,7 @@ */ #[OA\Patch( path: '/user/{id}', + operationId: 'patchUserResource', description: 'Authenticated (super)admin updates an existing user account', summary: 'Admin updates user account', security: [['AuthToken' => []]], @@ -322,6 +327,7 @@ */ #[OA\Delete( path: '/user/{id}/avatar', + operationId: 'deleteUserAvatarResource', description: 'Authenticated (super)admin deletes a user avatar identified by user id', summary: 'Admin deletes user avatar', security: [['AuthToken' => []]], @@ -352,6 +358,7 @@ */ #[OA\Get( path: '/user/{id}/avatar', + operationId: 'getUserAvatarResource', description: 'Authenticated (super)admin fetches a user avatar identified by user id', summary: 'Admin views user avatar', security: [['AuthToken' => []]], @@ -383,6 +390,7 @@ */ #[OA\Post( path: '/user/{id}/avatar', + operationId: 'postUserAvatarResource', description: 'Authenticated (super)admin creates user avatar for user identified by user id', summary: 'Admin creates user avatar', security: [['AuthToken' => []]], @@ -434,6 +442,7 @@ */ #[OA\Get( path: '/user/role', + operationId: 'getUserRoleCollection', description: 'Authenticated (super)admin fetches a list of user roles', summary: 'Admin lists user roles', security: [['AuthToken' => []]], @@ -502,6 +511,7 @@ */ #[OA\Get( path: '/user/role/{id}', + operationId: 'getUserRoleResource', description: 'Authenticated (super)admin fetches a user role identified by its id', summary: 'Admin views user role', security: [['AuthToken' => []]], @@ -533,6 +543,7 @@ */ #[OA\Patch( path: '/user/{id}/activate', + operationId: 'patchUserActivate', description: 'Authenticated (super)admin activates an existing user account', summary: 'Admin activates user account', security: [['AuthToken' => []]], @@ -575,6 +586,7 @@ */ #[OA\Patch( path: '/user/{id}/deactivate', + operationId: 'patchUserDeactivate', description: 'Authenticated (super)admin deactivates an existing user account', summary: 'Admin deactivates user account', security: [['AuthToken' => []]], @@ -612,6 +624,7 @@ */ #[OA\Delete( path: '/user/account', + operationId: 'deleteUserAccountResource', description: 'Authenticated user deletes (anonymizes) their own account', summary: 'User deletes (anonymizes) their own account', security: [['AuthToken' => []]], @@ -633,6 +646,7 @@ */ #[OA\Get( path: '/user/account', + operationId: 'getUserAccountResource', description: 'Authenticated user fetches their own account data', summary: 'User fetches their own account', security: [['AuthToken' => []]], @@ -651,6 +665,7 @@ */ #[OA\Patch( path: '/user/account', + operationId: 'patchUserAccountResource', description: 'Authenticated user updates their own account data', summary: 'User updates their own account', security: [['AuthToken' => []]], @@ -704,6 +719,7 @@ */ #[OA\Post( path: '/user/account', + operationId: 'postUserAccountResource', description: 'Register user account', summary: 'Unauthenticated user registers new user account', requestBody: new OA\RequestBody( @@ -764,6 +780,7 @@ */ #[OA\Delete( path: '/user/account/avatar', + operationId: 'deleteUserAccountAvatarResource', description: 'Authenticated user deletes their user avatar', summary: 'User deletes their own avatar', security: [['AuthToken' => []]], @@ -785,6 +802,7 @@ */ #[OA\Get( path: '/user/account/avatar', + operationId: 'getUserAccountAvatarResource', description: 'Authenticated user fetches their own avatar', summary: 'User fetches their own avatar', security: [['AuthToken' => []]], @@ -807,6 +825,7 @@ */ #[OA\Post( path: '/user/account/avatar', + operationId: 'postUserAccountAvatarResource', description: 'Authenticated user creates their own avatar', summary: 'User creates their own avatar', security: [['AuthToken' => []]], @@ -844,6 +863,7 @@ */ #[OA\Patch( path: '/user/account/activate/{hash}', + operationId: 'patchUserAccountActivate', description: 'Unauthenticated user activates their account using the hash from an activation link', summary: 'Unauthenticated user activates their account', tags: ['ActivateUser'], @@ -879,6 +899,7 @@ */ #[OA\Post( path: '/user/account/activate', + operationId: 'postUserAccountActivate', description: 'Unauthenticated user requests an account activation link by providing their email', summary: 'Unauthenticated user requests to activate account', requestBody: new OA\RequestBody( @@ -925,6 +946,7 @@ */ #[OA\Post( path: '/user/account/recover', + operationId: 'postUserAccountRecover', description: 'Unauthenticated user recovers their identity by providing their email', summary: 'Unauthenticated user recovers their identity', requestBody: new OA\RequestBody( @@ -966,6 +988,7 @@ */ #[OA\Get( path: '/user/account/reset-password/{hash}', + operationId: 'getUserAccountResetPasswordResource', description: 'Unauthenticated user fetches a reset password by its hash', summary: 'Unauthenticated user fetches reset password', tags: ['ResetPassword'], @@ -1000,6 +1023,7 @@ */ #[OA\Patch( path: '/user/account/reset-password/{hash}', + operationId: 'patchUserAccountResetPasswordResource', description: 'Unauthenticated user modifies their password using a reset password identified by its hash', summary: 'Unauthenticated user modifies their password', requestBody: new OA\RequestBody( @@ -1061,6 +1085,7 @@ */ #[OA\Post( path: '/user/account/reset-password', + operationId: 'postUserAccountResetPasswordResource', 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( From 3c98bc7db36457e8bcfd16fcbe19d08a39e3d698 Mon Sep 17 00:00:00 2001 From: bidi47 Date: Fri, 9 Oct 2026 15:42:11 +0300 Subject: [PATCH 2/3] added tests for openapi Signed-off-by: bidi47 --- test/Unit/App/OpenApi/OperationIdTest.php | 124 ++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 test/Unit/App/OpenApi/OperationIdTest.php diff --git a/test/Unit/App/OpenApi/OperationIdTest.php b/test/Unit/App/OpenApi/OperationIdTest.php new file mode 100644 index 00000000..57239f01 --- /dev/null +++ b/test/Unit/App/OpenApi/OperationIdTest.php @@ -0,0 +1,124 @@ +assertNotEmpty($this->openApiClasses(), 'No OpenAPI class was found under src/*/src/OpenAPI.php.'); + $this->assertNotEmpty($this->operations(), 'No OpenAPI operation was found in the OpenAPI classes.'); + } + + public function testEveryOperationHasAnOperationId(): void + { + $missing = []; + foreach ($this->operations() as $operation) { + if (! $this->isSet($operation['operationId'])) { + $missing[] = $operation['label']; + } + } + + $this->assertSame( + [], + $missing, + sprintf("These operations have no operationId:\n%s", implode("\n", $missing)), + ); + } + + public function testOperationIdsAreUniqueAcrossTheProject(): void + { + $labelsById = []; + foreach ($this->operations() as $operation) { + if ($this->isSet($operation['operationId'])) { + $labelsById[$operation['operationId']][] = $operation['label']; + } + } + + $duplicates = []; + foreach (array_filter($labelsById, static fn (array $labels): bool => count($labels) > 1) as $id => $labels) { + $duplicates[] = sprintf('%s is used by %s', $id, implode(', ', $labels)); + } + + $this->assertSame( + [], + $duplicates, + sprintf("These operationIds are not unique:\n%s", implode("\n", $duplicates)), + ); + } + + /** + * @phpstan-assert-if-true non-empty-string $operationId + */ + private function isSet(mixed $operationId): bool + { + return is_string($operationId) && $operationId !== '' && ! Undefined::isDefault($operationId); + } + + /** + * Finds the module OpenAPI classes: src//src/OpenAPI.php is Api\\OpenAPI. + * + * @return list + */ + private function openApiClasses(): array + { + $classes = []; + foreach (glob(dirname(__DIR__, 4) . '/src/*/src/OpenAPI.php') ?: [] as $file) { + $class = sprintf('Api\\%s\\OpenAPI', basename(dirname($file, 2))); + $this->assertTrue(class_exists($class), sprintf('%s was found at %s but cannot be loaded.', $class, $file)); + $classes[] = $class; + } + + return $classes; + } + + /** + * @return list + */ + private function operations(): array + { + $operations = []; + foreach ($this->openApiClasses() as $class) { + $attributes = (new ReflectionClass($class)) + ->getAttributes(Operation::class, ReflectionAttribute::IS_INSTANCEOF); + + foreach ($attributes as $attribute) { + /** @var Operation $operation */ + $operation = $attribute->newInstance(); + + $operations[] = [ + 'label' => strtoupper((string) $operation->method) . ' ' . $operation->path, + 'operationId' => $operation->operationId, + ]; + } + } + + return $operations; + } +} From 65e24ef417b0e956209eace4c6f047ed73afa439 Mon Sep 17 00:00:00 2001 From: bidi47 Date: Fri, 9 Oct 2026 16:16:15 +0300 Subject: [PATCH 3/3] updated composer Signed-off-by: bidi47 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c75cac7b..859d6697 100644 --- a/composer.json +++ b/composer.json @@ -80,7 +80,7 @@ "roave/psr-container-doctrine": "^5.2.2 || ^6.0.0", "symfony/filesystem": "^7.2.0", "symfony/var-exporter": "^6.4 || ^7.4", - "zircote/swagger-php": "^6.0.0" + "zircote/swagger-php": "^6.4.0" }, "require-dev": { "boundwize/structarmed": "^0.19.0",