diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e2be03..3d4943ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Support backed enums (`enum Foo: string`/`enum Foo: int`) as OpenAPI types, resolved from their native parameter type hint +- Support the built-in `SortDirection` enum from PHP 8.6 (unbacked, so its cases are mapped to the `'ASC'`/`'DESC'` strings) +- Ignore controller method parameters typed as an injected service (e.g. `\OCP\IUser`) instead of requiring docs for them and listing them in the generated OpenAPI file + ### Fixed - Clean whitespace in description fields diff --git a/generate-spec.php b/generate-spec.php index 38eae091..d13f4b4c 100755 --- a/generate-spec.php +++ b/generate-spec.php @@ -22,6 +22,8 @@ use PhpParser\Node\Name; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Enum_; +use PhpParser\Node\Stmt\EnumCase; use PhpParser\Node\Stmt\Throw_; use PhpParser\NodeFinder; use PhpParser\NodeTraverser; @@ -142,6 +144,69 @@ $schemas = []; $tags = []; +$enumsByFqcn = []; +$enumSourceDirs = [$sourceDir]; +if ($appIsCore) { + $enumSourceDirs[] = $sourceDir . '/../lib/private'; +} +foreach ($enumSourceDirs as $enumSourceDir) { + if (!is_dir($enumSourceDir)) { + continue; + } + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($enumSourceDir)); + foreach ($iterator as $file) { + $path = $file->getPathname(); + if (!str_ends_with((string)$path, '.php')) { + continue; + } + $contents = file_get_contents($path); + if (!str_contains($contents, 'enum ')) { + // Cheap pre-filter to avoid parsing every file in the app just to look for enums. + continue; + } + foreach ($nodeFinder->findInstanceOf($nodeTraverser->traverse($astParser->parse($contents)), Enum_::class) as $node) { + $name = $node->name->name; + if ($node->scalarType === null) { + Logger::debug($path, "Enum '" . $name . "' is not backed and can therefore not be used as an OpenAPI type. Use 'enum " . $name . ": string' or 'enum " . $name . ": int' instead."); + continue; + } + + $values = []; + foreach ($node->stmts as $stmt) { + if ($stmt instanceof EnumCase && $stmt->expr !== null) { + $values[] = Helpers::exprToValue($path . ': ' . $name . '::' . $stmt->name->name, $stmt->expr); + } + } + + $description = null; + $doc = $node->getDocComment()?->getText(); + if ($doc != null) { + $descriptionLines = []; + $docNodes = $phpDocParser->parse(new TokenIterator($lexer->tokenize($doc)))->children; + foreach ($docNodes as $docNode) { + if ($docNode instanceof PhpDocTextNode) { + $block = Helpers::cleanDocComment($docNode->text); + if ($block !== '') { + $descriptionLines[] = $block; + } + } + } + if ($descriptionLines !== []) { + $description = implode("\n", $descriptionLines); + } + } + + $enumsByFqcn[$node->namespacedName->toString()] = new OpenApiType( + context: $path, + type: $node->scalarType->name === 'int' ? 'integer' : 'string', + format: $node->scalarType->name === 'int' ? 'int64' : null, + description: $description, + enum: $values, + ); + } + } +} + $definitions = []; $definitionsPath = $sourceDir . '/ResponseDefinitions.php'; if (file_exists($definitionsPath)) { diff --git a/src/ControllerMethod.php b/src/ControllerMethod.php index 8a7789fb..e92a45c7 100644 --- a/src/ControllerMethod.php +++ b/src/ControllerMethod.php @@ -468,6 +468,13 @@ public static function parse(string $context, foreach ($methodParameters as $methodParameter) { $methodParameterName = $methodParameter->var->name; + // Services like `\OCP\IUser` are injected by the dispatcher, never + // filled from the request - they need no docs and aren't part of + // the API surface. + if (OpenApiType::isInjectedParameter($methodParameter->type)) { + continue; + } + $paramTag = null; $psalmParamTag = null; foreach ($docParameters as $docParameterType => $typeDocParameters) { @@ -498,7 +505,15 @@ public static function parse(string $context, // Only keep lines that don't match the status code pattern in the description $description = Helpers::cleanDocComment(implode("\n", array_filter(array_filter(explode("\n", $description), static fn (string $line): bool => trim($line) !== ''), static fn (string $line): bool => in_array(preg_match(self::STATUS_CODE_DESCRIPTION_PATTERN, $line), [0, false], true)))); - if ($paramTag instanceof ParamTagValueNode && $psalmParamTag instanceof ParamTagValueNode) { + $nativeEnumType = OpenApiType::resolveNativeEnum($context . ': @param: ' . $methodParameterName, $methodParameter->type); + + if ($nativeEnumType !== null) { + if (!$paramTag instanceof ParamTagValueNode && !$psalmParamTag instanceof ParamTagValueNode && !$allowMissingDocs) { + Logger::error($context, "Missing doc parameter for '" . $methodParameterName . "'"); + continue; + } + $type = $nativeEnumType; + } elseif ($paramTag instanceof ParamTagValueNode && $psalmParamTag instanceof ParamTagValueNode) { try { $type = OpenApiType::resolve( $context . ': @param: ' . $psalmParamTag->parameterName, diff --git a/src/OpenApiType.php b/src/OpenApiType.php index 6caeda1c..8df2ee23 100644 --- a/src/OpenApiType.php +++ b/src/OpenApiType.php @@ -7,6 +7,7 @@ namespace OpenAPIExtractor; +use PhpParser\Node; use PhpParser\Node\Identifier; use PhpParser\Node\IntersectionType; use PhpParser\Node\Name; @@ -155,7 +156,7 @@ public static function resolve(string $context, array $definitions, ParamTagValu return $type; } if ($node instanceof Name) { - return self::resolveIdentifier($context, $definitions, $node->getLast()); + return self::resolveNativeEnum($context, $node) ?? self::resolveIdentifier($context, $definitions, $node->getLast()); } if ($node instanceof IdentifierTypeNode || $node instanceof Identifier) { return self::resolveIdentifier($context, $definitions, $node->name); @@ -411,6 +412,53 @@ enum: [(int)$node->constExpr->value], Logger::panic($context, "Unable to resolve OpenAPI type:\n" . var_export($node, true) . "\nPlease open an issue at https://github.com/nextcloud/openapi-extractor/issues/new with the error message and a link to your source code."); } + public static function resolveNativeEnum(string $context, ?Node $node): ?OpenApiType { + $nullable = false; + if ($node instanceof NullableType) { + $nullable = true; + $node = $node->type; + } + if (!$node instanceof Name) { + return null; + } + + global $enumsByFqcn; + $fqcn = ltrim($node->toString(), '\\'); + if (!array_key_exists($fqcn, $enumsByFqcn)) { + return null; + } + + $enum = $enumsByFqcn[$fqcn]; + return new OpenApiType( + context: $context, + type: $enum->type, + format: $enum->format, + description: $enum->description, + enum: $enum->enum, + nullable: $nullable, + ); + } + + public static function isInjectedParameter(?Node $node): bool { + if ($node instanceof NullableType) { + $node = $node->type; + } + if (!$node instanceof Name) { + return false; + } + if (self::resolveNativeEnum('', $node) !== null) { + return false; + } + // Anything else resolveIdentifier recognizes (e.g. the built-in + // `SortDirection`) is a real, documentable type, not a service. + try { + self::resolveIdentifier('', [], $node->getLast()); + return false; + } catch (LoggerException) { + return true; + } + } + /** * @param OpenApiType[] $types * @return OpenApiType[] @@ -467,6 +515,7 @@ private static function resolveIdentifier(string $context, array $definitions, s 'mixed', 'empty', 'array' => new OpenApiType(context: $context, type: 'object'), 'object', 'stdClass' => new OpenApiType(context: $context, type: 'object', additionalProperties: true), 'null' => new OpenApiType(context: $context, nullable: true), + 'SortDirection' => new OpenApiType(context: $context, type: 'string', enum: ['ASC', 'DESC']), default => (function () use ($context, $definitions, $name) { if (array_key_exists($name, $definitions)) { return new OpenApiType( @@ -474,6 +523,7 @@ private static function resolveIdentifier(string $context, array $definitions, s ref: '#/components/schemas/' . Helpers::cleanSchemaName($name), ); } + Logger::panic($context, "Unable to resolve OpenAPI type for identifier '" . $name . "'"); })(), }; diff --git a/tests/appinfo/routes.php b/tests/appinfo/routes.php index ae06106b..7fe5f55b 100644 --- a/tests/appinfo/routes.php +++ b/tests/appinfo/routes.php @@ -93,6 +93,9 @@ ['name' => 'Settings#mergedResponses', 'url' => '/api/{apiVersion}/merged-responses', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], ['name' => 'Settings#custom401', 'url' => '/api/{apiVersion}/custom/401', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], ['name' => 'Settings#custom403', 'url' => '/api/{apiVersion}/custom/403', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], + ['name' => 'Settings#stringBackedEnumParameter', 'url' => '/api/{apiVersion}/enums/string-backed', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], + ['name' => 'Settings#sortDirectionParameter', 'url' => '/api/{apiVersion}/enums/sort-direction', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], + ['name' => 'Settings#injectedServiceParameter', 'url' => '/api/{apiVersion}/injected-service', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], ['name' => 'V1\SubDir#subDirRoute', 'url' => '/sub-dir', 'verb' => 'GET'], ], ]; diff --git a/tests/lib/Controller/SettingsController.php b/tests/lib/Controller/SettingsController.php index 843514f0..3c1ebe08 100644 --- a/tests/lib/Controller/SettingsController.php +++ b/tests/lib/Controller/SettingsController.php @@ -9,6 +9,7 @@ namespace OCA\Notifications\Controller; +use OCA\Notifications\NotificationLevel; use OCA\Notifications\ResponseDefinitions; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\CORS; @@ -22,6 +23,7 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; +use OCP\IUser; /** * @psalm-import-type NotificationsPushDevice from ResponseDefinitions @@ -850,4 +852,40 @@ public function custom401(): DataResponse { public function custom403(): DataResponse { return new DataResponse(); } + + /** + * A route with a backed enum as a native parameter type + * + * @param NotificationLevel $level Level + * @return DataResponse + * + * 200: OK + */ + public function stringBackedEnumParameter(NotificationLevel $level): DataResponse { + return new DataResponse(); + } + + /** + * A route using the built-in SortDirection enum as a native parameter type + * + * @param \SortDirection $direction Direction + * @return DataResponse + * + * 200: OK + */ + public function sortDirectionParameter(\SortDirection $direction): DataResponse { + return new DataResponse(); + } + + /** + * A route with an injected service parameter, which needs no docs and isn't part of the API surface + * + * @param string $path Path of the file + * @return DataResponse + * + * 200: OK + */ + public function injectedServiceParameter(IUser $user, string $path): DataResponse { + return new DataResponse(); + } } diff --git a/tests/lib/NotificationLevel.php b/tests/lib/NotificationLevel.php new file mode 100644 index 00000000..e84088b6 --- /dev/null +++ b/tests/lib/NotificationLevel.php @@ -0,0 +1,19 @@ +