diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..35630aa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/generate-spec b/generate-spec index 7d27449..feebf96 100755 --- a/generate-spec +++ b/generate-spec @@ -289,6 +289,12 @@ foreach ($parsedRoutes as $key => $value) { continue; } + $controllerScope = Helpers::getAttributeScope($controllerClass, 'OpenAPI', $routeName); + if ($controllerScope === 'ignore') { + Logger::info($routeName, "Controller '" . $controllerName . "' ignored because of OpenAPI attribute"); + continue; + } + $tagName = implode("_", array_map(fn(string $s) => strtolower($s), Helpers::splitOnUppercaseFollowedByNonUppercase($controllerName))); $doc = $controllerClass->getDocComment()?->getText(); if ($doc != null && count(array_filter($tags, fn(array $tag) => $tag["name"] == $tagName)) == 0) { @@ -343,6 +349,20 @@ foreach ($parsedRoutes as $key => $value) { continue; } + $scope = Helpers::getAttributeScope($classMethod, 'OpenAPI', $routeName); + if ($scope === 'ignore') { + Logger::info($routeName, "Route ignored because of OpenAPI attribute"); + continue; + } + + if ($scope === null) { + if ($controllerScope !== null) { + $scope = $controllerScope; + } else { + $scope = 'default'; + } + } + if ($isOCS && !array_key_exists("OCSMeta", $schemas)) { $schemas["OCSMeta"] = [ "type" => "object", @@ -390,7 +410,8 @@ foreach ($parsedRoutes as $key => $value) { continue; } - $routes[] = new Route( + $routes[$scope] ??= []; + $routes[$scope][] = new Route( $routeName, $tagName, $controllerName, @@ -412,217 +433,223 @@ foreach ($parsedRoutes as $key => $value) { $tagNames = []; if ($useTags) { - foreach ($routes as $route) { - if (!in_array($route->tag, $tagNames)) { - $tagNames[] = $route->tag; + foreach ($routes as $scope => $scopeRoutes) { + foreach ($scopeRoutes as $route) { + if (!in_array($route->tag, $tagNames)) { + $tagNames[] = $route->tag; + } } } } -foreach ($routes as $route) { - $pathParameters = []; - $urlParameters = []; - - preg_match_all("/{[^}]*}/", $route->url, $urlParameters); - $urlParameters = array_map(fn(string $name) => substr($name, 1, -1), $urlParameters[0]); - - foreach ($urlParameters as $urlParameter) { - $matchingParameters = array_filter($route->controllerMethod->parameters, function (ControllerMethodParameter $param) use ($urlParameter) { - return $param->name == $urlParameter; - }); - $requirement = array_key_exists($urlParameter, $route->requirements) ? $route->requirements[$urlParameter] : null; - if (count($matchingParameters) == 1) { - $parameter = $matchingParameters[array_keys($matchingParameters)[0]]; - if ($parameter?->methodParameter == null && ($route->requirements == null || !array_key_exists($urlParameter, $route->requirements))) { - Logger::error($route->name, "Unable to find parameter for '" . $urlParameter . "'"); - continue; - } - - $schema = $parameter->type->toArray($openapiVersion, true); - $description = $parameter?->docParameter != null && $parameter->docParameter->description != "" ? Helpers::cleanDocComment($parameter->docParameter->description) : null; - } else { - $schema = [ - "type" => "string", - ]; - $description = null; - } +$scopePaths = []; + +foreach ($routes as $scope => $scopeRoutes) { + foreach ($scopeRoutes as $route) { + $pathParameters = []; + $urlParameters = []; + + preg_match_all("/{[^}]*}/", $route->url, $urlParameters); + $urlParameters = array_map(fn(string $name) => substr($name, 1, -1), $urlParameters[0]); + + foreach ($urlParameters as $urlParameter) { + $matchingParameters = array_filter($route->controllerMethod->parameters, function (ControllerMethodParameter $param) use ($urlParameter) { + return $param->name == $urlParameter; + }); + $requirement = array_key_exists($urlParameter, $route->requirements) ? $route->requirements[$urlParameter] : null; + if (count($matchingParameters) == 1) { + $parameter = $matchingParameters[array_keys($matchingParameters)[0]]; + if ($parameter?->methodParameter == null && ($route->requirements == null || !array_key_exists($urlParameter, $route->requirements))) { + Logger::error($route->name, "Unable to find parameter for '" . $urlParameter . "'"); + continue; + } - if ($requirement != null) { - if (!str_starts_with($requirement, "^")) { - $requirement = "^" . $requirement; - } - if (!str_ends_with($requirement, "$")) { - $requirement = $requirement . "$"; + $schema = $parameter->type->toArray($openapiVersion, true); + $description = $parameter?->docParameter != null && $parameter->docParameter->description != "" ? Helpers::cleanDocComment($parameter->docParameter->description) : null; + } else { + $schema = [ + "type" => "string", + ]; + $description = null; } - } - if ($schema["type"] == "string") { - if ($urlParameter == "apiVersion") { - if ($requirement == null) { - Logger::error($route->name, "Missing requirement for apiVersion"); - continue; + if ($requirement != null) { + if (!str_starts_with($requirement, "^")) { + $requirement = "^" . $requirement; } - preg_match("/^\^\(([v0-9-.|]*)\)\\$$/m", $requirement, $matches); - if (count($matches) == 2) { - $enum = explode("|", $matches[1]); - } else { - Logger::error($route->name, "Invalid requirement for apiVersion"); - continue; + if (!str_ends_with($requirement, "$")) { + $requirement = $requirement . "$"; } - $schema["enum"] = $enum; - $schema["default"] = end($enum); - } else if ($requirement != null) { - $schema["pattern"] = $requirement; } - } - $pathParameters[] = array_merge( - [ - "name" => $urlParameter, - "in" => "path", - ], - $description != null ? ["description" => $description] : [], - [ - "required" => true, - "schema" => $schema, - ], - ); - } - - $queryParameters = []; - foreach ($route->controllerMethod->parameters as $parameter) { - $alreadyInPath = false; - foreach ($pathParameters as $pathParameter) { - if ($pathParameter["name"] == $parameter->name) { - $alreadyInPath = true; - break; + if ($schema["type"] == "string") { + if ($urlParameter == "apiVersion") { + if ($requirement == null) { + Logger::error($route->name, "Missing requirement for apiVersion"); + continue; + } + preg_match("/^\^\(([v0-9-.|]*)\)\\$$/m", $requirement, $matches); + if (count($matches) == 2) { + $enum = explode("|", $matches[1]); + } else { + Logger::error($route->name, "Invalid requirement for apiVersion"); + continue; + } + $schema["enum"] = $enum; + $schema["default"] = end($enum); + } else if ($requirement != null) { + $schema["pattern"] = $requirement; + } } - } - if (!$alreadyInPath) { - $queryParameters[] = $parameter; - } - } - $mergedResponses = []; - foreach (array_unique(array_map(fn(ControllerMethodResponse $response) => $response->statusCode, array_filter($route->controllerMethod->responses, fn(?ControllerMethodResponse $response) => $response != null))) as $statusCode) { - if ($firstStatusCode && count($mergedResponses) > 0) { - break; + $pathParameters[] = array_merge( + [ + "name" => $urlParameter, + "in" => "path", + ], + $description != null ? ["description" => $description] : [], + [ + "required" => true, + "schema" => $schema, + ], + ); } - $statusCodeResponses = array_filter($route->controllerMethod->responses, fn(?ControllerMethodResponse $response) => $response != null && $response->statusCode == $statusCode); - $headers = array_merge(...array_map(fn(ControllerMethodResponse $response) => $response->headers ?? [], $statusCodeResponses)); + $queryParameters = []; + foreach ($route->controllerMethod->parameters as $parameter) { + $alreadyInPath = false; + foreach ($pathParameters as $pathParameter) { + if ($pathParameter["name"] == $parameter->name) { + $alreadyInPath = true; + break; + } + } + if (!$alreadyInPath) { + $queryParameters[] = $parameter; + } + } - $mergedContentTypeResponses = []; - foreach (array_unique(array_map(fn(ControllerMethodResponse $response) => $response->contentType, array_filter($statusCodeResponses, fn(ControllerMethodResponse $response) => $response->contentType != null))) as $contentType) { - if ($firstContentType && count($mergedContentTypeResponses) > 0) { + $mergedResponses = []; + foreach (array_unique(array_map(fn(ControllerMethodResponse $response) => $response->statusCode, array_filter($route->controllerMethod->responses, fn(?ControllerMethodResponse $response) => $response != null))) as $statusCode) { + if ($firstStatusCode && count($mergedResponses) > 0) { break; } - /** @var ControllerMethodResponse[] $contentTypeResponses */ - $contentTypeResponses = array_values(array_filter($statusCodeResponses, fn(ControllerMethodResponse $response) => $response->contentType == $contentType)); + $statusCodeResponses = array_filter($route->controllerMethod->responses, fn(?ControllerMethodResponse $response) => $response != null && $response->statusCode == $statusCode); + $headers = array_merge(...array_map(fn(ControllerMethodResponse $response) => $response->headers ?? [], $statusCodeResponses)); - $hasEmpty = count(array_filter($contentTypeResponses, fn(ControllerMethodResponse $response) => $response->type == null)) > 0; - $uniqueResponses = array_values(array_intersect_key($contentTypeResponses, array_unique(array_map(fn(ControllerMethodResponse $response) => $response->type->toArray($openapiVersion), array_filter($contentTypeResponses, fn(ControllerMethodResponse $response) => $response->type != null)), SORT_REGULAR))); - if (count($uniqueResponses) == 1) { - if ($hasEmpty) { - $mergedContentTypeResponses[$contentType] = []; + $mergedContentTypeResponses = []; + foreach (array_unique(array_map(fn(ControllerMethodResponse $response) => $response->contentType, array_filter($statusCodeResponses, fn(ControllerMethodResponse $response) => $response->contentType != null))) as $contentType) { + if ($firstContentType && count($mergedContentTypeResponses) > 0) { + break; + } + + /** @var ControllerMethodResponse[] $contentTypeResponses */ + $contentTypeResponses = array_values(array_filter($statusCodeResponses, fn(ControllerMethodResponse $response) => $response->contentType == $contentType)); + + $hasEmpty = count(array_filter($contentTypeResponses, fn(ControllerMethodResponse $response) => $response->type == null)) > 0; + $uniqueResponses = array_values(array_intersect_key($contentTypeResponses, array_unique(array_map(fn(ControllerMethodResponse $response) => $response->type->toArray($openapiVersion), array_filter($contentTypeResponses, fn(ControllerMethodResponse $response) => $response->type != null)), SORT_REGULAR))); + if (count($uniqueResponses) == 1) { + if ($hasEmpty) { + $mergedContentTypeResponses[$contentType] = []; + } else { + $schema = $contentTypeResponses[0]->type->toArray($openapiVersion); + $mergedContentTypeResponses[$contentType] = ["schema" => Helpers::wrapOCSResponse($route, $contentTypeResponses[0], $schema)]; + } } else { - $schema = $contentTypeResponses[0]->type->toArray($openapiVersion); - $mergedContentTypeResponses[$contentType] = ["schema" => Helpers::wrapOCSResponse($route, $contentTypeResponses[0], $schema)]; + $mergedContentTypeResponses[$contentType] = [ + "schema" => [ + [$hasEmpty ? "anyOf" : "oneOf" => array_map(fn(ControllerMethodResponse $response) => Helpers::wrapOCSResponse($route, $response, $response->type->toArray($openapiVersion)), $uniqueResponses)], + ], + ]; } - } else { - $mergedContentTypeResponses[$contentType] = [ - "schema" => [ - [$hasEmpty ? "anyOf" : "oneOf" => array_map(fn(ControllerMethodResponse $response) => Helpers::wrapOCSResponse($route, $response, $response->type->toArray($openapiVersion)), $uniqueResponses)], - ], - ]; } - } - $mergedResponses[$statusCode] = array_merge( - [ - "description" => array_key_exists($statusCode, $route->controllerMethod->responseDescription) ? $route->controllerMethod->responseDescription[$statusCode] : "", - ], - count($headers) > 0 ? [ - "headers" => array_combine( - array_keys($headers), - array_map( - fn(OpenApiType $type) => [ - "schema" => $type->toArray($openapiVersion), - ], - array_values($headers), + $mergedResponses[$statusCode] = array_merge( + [ + "description" => array_key_exists($statusCode, $route->controllerMethod->responseDescription) ? $route->controllerMethod->responseDescription[$statusCode] : "", + ], + count($headers) > 0 ? [ + "headers" => array_combine( + array_keys($headers), + array_map( + fn(OpenApiType $type) => [ + "schema" => $type->toArray($openapiVersion), + ], + array_values($headers), + ), ), + ] : [], + count($mergedContentTypeResponses) > 0 ? [ + "content" => $mergedContentTypeResponses, + ] : [], + ); + } + + $operationId = [$route->tag]; + $operationId = array_merge($operationId, array_map(fn(string $s) => Helpers::mapVerb(strtolower($s)), Helpers::splitOnUppercaseFollowedByNonUppercase($route->methodName))); + if ($route->postfix != null) { + $operationId[] = $route->postfix; + } + + $security = []; + if ($route->isPublic) { + // Add empty authentication, meaning that it's optional. We can't know if there is a difference in behaviour for authenticated vs. unauthenticated access on public pages (e.g. capabilities) + $security[] = new stdClass(); + } + if (!$route->isCORS) { + // Bearer auth is not allowed on CORS routes + $security[] = ["bearer_auth" => []]; + } + if (!$route->isCSRFRequired || $route->isOCS) { + // Add basic auth last, so it's only fallback if bearer is available + $security[] = ["basic_auth" => []]; + } + + $operation = array_merge( + ["operationId" => implode("-", $operationId)], + $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]] : [], + count($security) > 0 ? ["security" => $security] : [], + count($queryParameters) > 0 || count($pathParameters) > 0 || $route->isOCS ? [ + "parameters" => array_merge( + array_map(fn(ControllerMethodParameter $parameter) => array_merge( + [ + "name" => $parameter->name . ($parameter->type->type == "array" ? "[]" : ""), + "in" => "query", + ], + $parameter->docParameter != null && $parameter->docParameter->description != "" ? ["description" => Helpers::cleanDocComment($parameter->docParameter->description)] : [], + !$parameter->type->nullable && !$parameter->type->hasDefaultValue ? ["required" => true] : [], + ["schema" => $parameter->type->toArray($openapiVersion, true),], + ), $queryParameters), + $pathParameters, + $route->isOCS ? [[ + "name" => "OCS-APIRequest", + "in" => "header", + "description" => "Required to be true for the API request to pass", + "required" => true, + "schema" => [ + "type" => "boolean", + "default" => true, + ], + ]] : [], ), ] : [], - count($mergedContentTypeResponses) > 0 ? [ - "content" => $mergedContentTypeResponses, - ] : [], + ["responses" => $mergedResponses], ); - } - $operationId = [$route->tag]; - $operationId = array_merge($operationId, array_map(fn(string $s) => Helpers::mapVerb(strtolower($s)), Helpers::splitOnUppercaseFollowedByNonUppercase($route->methodName))); - if ($route->postfix != null) { - $operationId[] = $route->postfix; - } + $scopePaths[$scope] ??= []; + $scopePaths[$scope][$route->url] ??= []; + $path = &$scopePaths[$scope][$route->url]; - $security = []; - if ($route->isPublic) { - // Add empty authentication, meaning that it's optional. We can't know if there is a difference in behaviour for authenticated vs. unauthenticated access on public pages (e.g. capabilities) - $security[] = new stdClass(); - } - if (!$route->isCORS) { - // Bearer auth is not allowed on CORS routes - $security[] = ["bearer_auth" => []]; - } - if (!$route->isCSRFRequired || $route->isOCS) { - // Add basic auth last, so it's only fallback if bearer is available - $security[] = ["basic_auth" => []]; - } - - $operation = array_merge( - ["operationId" => implode("-", $operationId)], - $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]] : [], - count($security) > 0 ? ["security" => $security] : [], - count($queryParameters) > 0 || count($pathParameters) > 0 || $route->isOCS ? [ - "parameters" => array_merge( - array_map(fn(ControllerMethodParameter $parameter) => array_merge( - [ - "name" => $parameter->name . ($parameter->type->type == "array" ? "[]" : ""), - "in" => "query", - ], - $parameter->docParameter != null && $parameter->docParameter->description != "" ? ["description" => Helpers::cleanDocComment($parameter->docParameter->description)] : [], - !$parameter->type->nullable && !$parameter->type->hasDefaultValue ? ["required" => true] : [], - ["schema" => $parameter->type->toArray($openapiVersion, true),], - ), $queryParameters), - $pathParameters, - $route->isOCS ? [[ - "name" => "OCS-APIRequest", - "in" => "header", - "description" => "Required to be true for the API request to pass", - "required" => true, - "schema" => [ - "type" => "boolean", - "default" => true, - ], - ]] : [], - ), - ] : [], - ["responses" => $mergedResponses], - ); - if (!array_key_exists($route->url, $openapi["paths"])) { - $openapi["paths"][$route->url] = []; - } - $path = &$openapi["paths"][$route->url]; - - $verb = strtolower($route->verb); - if (!array_key_exists($verb, $path)) { - $path[$verb] = $operation; - } else { - Logger::error($route->name, "Operation '" . $route->verb . "' already set for path '" . $route->url . "'"); + $verb = strtolower($route->verb); + if (!array_key_exists($verb, $path)) { + $path[$verb] = $operation; + } else { + Logger::error($route->name, "Operation '" . $route->verb . "' already set for path '" . $route->url . "'"); + } } } @@ -666,7 +693,7 @@ if ($appIsCore) { ], ], ]; - $openapi["paths"]["/status.php"] = [ + $scopePaths['default']['/status.php'] = [ "get" => [ "operationId" => "get-status", "responses" => [ @@ -689,10 +716,6 @@ if (count($schemas) == 0 && count($routes) == 0) { Logger::error("app", "No spec generated"); } -if (count($openapi["paths"]) == 0) { - $openapi["paths"] = new stdClass(); -} - ksort($schemas); $openapi["components"]["schemas"] = count($schemas) == 0 ? new stdClass() : $schemas; @@ -700,7 +723,30 @@ if ($useTags) { $openapi["tags"] = $tags; } -file_put_contents($out, json_encode($openapi, Helpers::jsonFlags())); +foreach ($scopePaths as $scope => $paths) { + $openapiScope = $openapi; + + if (count($paths) == 0) { + $paths = new stdClass(); + } + + $scopeSuffix = $scope === 'default' ? '' : '-' . $scope; + $openapiScope['info']['title'] .= $scopeSuffix; + $openapiScope['paths'] = $paths; + + $startExtension = strrpos($out, '.'); + if ($startExtension !== false) { + // Path + filename (without extension) + $path = substr($out, 0, $startExtension); + // Extension + $extension = substr($out, $startExtension); + $scopeOut = $path . $scopeSuffix . $extension; + } else { + $scopeOut = $out . $scopeSuffix; + } + + file_put_contents($scopeOut, json_encode($openapiScope, Helpers::jsonFlags())); +} function cleanSchemaName(string $name): string { global $readableAppID; diff --git a/src/Helpers.php b/src/Helpers.php index ab8b7fc..d27ac76 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -4,6 +4,8 @@ use Exception; use PhpParser\Node; +use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Class_; use stdClass; @@ -140,4 +142,39 @@ static function classMethodHasAnnotationOrAttribute(ClassMethod|Class_|Node $nod return false; } + + static function getAttributeScope(ClassMethod|Class_|Node $node, string $annotation, string $routeName): ?string { + /** @var Node\AttributeGroup $attrGroup */ + foreach ($node->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->getLast() === $annotation) { + if (empty($attr->args)) { + return 'default'; + } + + foreach ($attr->args as $arg) { + if ($arg->name->name === 'scope') { + if ($arg->value instanceof ClassConstFetch) { + if ($arg->value->class->getLast() === 'OpenAPI') { + return 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_) { + return $arg->value->value; + } + Logger::panic($routeName, 'Can not interpret value of scope provided in OpenAPI(scope: …) attribute. Please use string or OpenAPI::SCOPE_* constants'); + } + } + } + } + } + + return null; + } }