diff --git a/generate-spec b/generate-spec index b4c5a65..f8027a1 100755 --- a/generate-spec +++ b/generate-spec @@ -353,6 +353,8 @@ foreach ($parsedRoutes as $key => $value) { } } + $routeTags = Helpers::getAttributeTagsByScope($classMethod, 'OpenAPI', $routeName, $tagName, reset($scopes)); + if ($isOCS && !array_key_exists("OCSMeta", $schemas)) { $schemas["OCSMeta"] = [ "type" => "object", @@ -404,7 +406,7 @@ foreach ($parsedRoutes as $key => $value) { $routes[$scope] ??= []; $routes[$scope][] = new Route( $routeName, - $tagName, + $routeTags[$scope] ?? [$tagName], $controllerName, $methodName, $postfix, @@ -427,8 +429,10 @@ $tagNames = []; if ($useTags) { foreach ($routes as $scope => $scopeRoutes) { foreach ($scopeRoutes as $route) { - if (!in_array($route->tag, $tagNames)) { - $tagNames[] = $route->tag; + foreach ($route->tags as $tag) { + if (!in_array($tag, $tagNames)) { + $tagNames[] = $tag; + } } } } @@ -581,7 +585,7 @@ foreach ($routes as $scope => $scopeRoutes) { ); } - $operationId = [$route->tag]; + $operationId = $route->tags; $operationId = array_merge($operationId, array_map(fn(string $s) => Helpers::mapVerb(strtolower($s)), Helpers::splitOnUppercaseFollowedByNonUppercase($route->methodName))); if ($route->postfix != null) { $operationId[] = $route->postfix; @@ -606,7 +610,7 @@ foreach ($routes as $scope => $scopeRoutes) { $route->controllerMethod->summary != null ? ["summary" => $route->controllerMethod->summary] : [], count($route->controllerMethod->description) > 0 ? ["description" => implode("\n", $route->controllerMethod->description)] : [], $route->controllerMethod->isDeprecated ? ["deprecated" => true] : [], - $useTags ? ["tags" => [$route->tag]] : [], + $useTags ? ["tags" => $route->tags] : [], count($security) > 0 ? ["security" => $security] : [], count($queryParameters) > 0 || count($pathParameters) > 0 || $route->isOCS ? [ "parameters" => array_merge( diff --git a/src/Helpers.php b/src/Helpers.php index b464b00..3c3b2e4 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -4,6 +4,8 @@ use Exception; use PhpParser\Node; +use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\ArrayItem; use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\ClassMethod; @@ -190,6 +192,63 @@ static function getAttributeScopes(ClassMethod|Class_|Node $node, string $annota return $scopes; } + static function getAttributeTagsByScope(ClassMethod|Class_|Node $node, string $annotation, string $routeName, string $defaultTag, string $defaultScope): array { + $tags = []; + + /** @var Node\AttributeGroup $attrGroup */ + foreach ($node->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->getLast() === $annotation) { + if (empty($attr->args)) { + $tags[$defaultScope] = [$defaultTag]; + continue; + } + + $foundsTags = []; + $foundScopeName = null; + foreach ($attr->args as $arg) { + if ($arg->name->name === 'scope') { + if ($arg->value instanceof ClassConstFetch) { + if ($arg->value->class->getLast() === 'OpenAPI') { + $foundScopeName = match ($arg->value->name->name) { + 'SCOPE_DEFAULT' => 'default', + 'SCOPE_ADMINISTRATION' => 'administration', + 'SCOPE_FEDERATION' => 'federation', + 'SCOPE_IGNORE' => 'ignore', + // Fall back for future scopes assuming we follow the pattern (cut of 'SCOPE_' and lower case) + default => strtolower(substr($arg->value->name->name, 6)), + }; + } + } elseif ($arg->value instanceof String_) { + $foundScopeName = $arg->value->value; + } else { + Logger::panic($routeName, 'Can not interpret value of scope provided in OpenAPI(scope: …) attribute. Please use string or OpenAPI::SCOPE_* constants'); + } + } + + if ($arg->name->name === 'tags') { + if ($arg->value instanceof Array_) { + foreach ($arg->value->items as $item) { + if ($item instanceof ArrayItem) { + if ($item->value instanceof String_) { + $foundsTags[] = $item->value->value; + } + } + } + } + } + } + + if (!empty($foundsTags)) { + $tags[$foundScopeName ?: $defaultScope] = $foundsTags; + } + } + } + } + + return $tags; + } + static function collectUsedRefs(array $data): array { $refs = []; if (isset($data['$ref'])) { diff --git a/src/Route.php b/src/Route.php index 3c7117f..20b9f96 100644 --- a/src/Route.php +++ b/src/Route.php @@ -5,7 +5,7 @@ class Route { public function __construct( public string $name, - public string $tag, + public array $tags, public string $controllerName, public string $methodName, public ?string $postfix, diff --git a/tests/appinfo/routes.php b/tests/appinfo/routes.php index c739c82..b157fd4 100644 --- a/tests/appinfo/routes.php +++ b/tests/appinfo/routes.php @@ -34,5 +34,6 @@ ['name' => 'Settings2#defaultAdminScopeOverwritten', 'url' => '/api/{apiVersion}/default-admin-overwritten', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], ['name' => 'Settings2#defaultAdminScope', 'url' => '/api/{apiVersion}/default-admin', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], + ['name' => 'Settings2#movedToSettingsTag', 'url' => '/api/{apiVersion}/moved-with-tag', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], ], ]; diff --git a/tests/lib/Controller/Settings2Controller.php b/tests/lib/Controller/Settings2Controller.php index e2bd2e5..a5a324f 100644 --- a/tests/lib/Controller/Settings2Controller.php +++ b/tests/lib/Controller/Settings2Controller.php @@ -55,4 +55,16 @@ public function defaultAdminScope(): DataResponse { public function defaultAdminScopeOverwritten(): DataResponse { return new DataResponse(); } + + /** + * Route is only in the admin scope because there is no "NoAdminRequired" annotation or attribute + * + * @return DataResponse, array{}> + * + * 200: Personal settings updated + */ + #[OpenAPI(tags: ['settings', 'admin-settings'])] + public function movedToSettingsTag(): DataResponse { + return new DataResponse(); + } } diff --git a/tests/openapi-administration.json b/tests/openapi-administration.json index 07cb371..c55dec4 100644 --- a/tests/openapi-administration.json +++ b/tests/openapi-administration.json @@ -281,6 +281,79 @@ } } } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/moved-with-tag": { + "post": { + "operationId": "settings-admin-settings-moved-to-settings-tag", + "summary": "Route is only in the admin scope because there is no \"NoAdminRequired\" annotation or attribute", + "description": "This endpoint requires admin access", + "tags": [ + "settings", + "admin-settings" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Personal settings updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } } }, "tags": []