diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e2be03..ec38d166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ 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 +- Support the built-in `SortDirection` enum from PHP 8.6 (unbacked, so its cases are mapped to the `'ASC'`/`'DESC'` strings) + ### Fixed - Clean whitespace in description fields diff --git a/generate-spec.php b/generate-spec.php index 38eae091..b91bd3f8 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,73 @@ $schemas = []; $tags = []; +$enums = []; +$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($astParser->parse($contents), Enum_::class) as $node) { + $name = $node->name->name; + if (array_key_exists($name, $enums)) { + Logger::error($path, "Duplicate enum name '" . $name . "'. Enum names have to be unique within an app."); + continue; + } + 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); + } + } + + $enums[$name] = 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/OpenApiType.php b/src/OpenApiType.php index 6caeda1c..18e81f79 100644 --- a/src/OpenApiType.php +++ b/src/OpenApiType.php @@ -467,6 +467,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 +475,19 @@ private static function resolveIdentifier(string $context, array $definitions, s ref: '#/components/schemas/' . Helpers::cleanSchemaName($name), ); } + + global $enums; + if (array_key_exists($name, $enums)) { + $enum = $enums[$name]; + return new OpenApiType( + context: $context, + type: $enum->type, + format: $enum->format, + description: $enum->description, + enum: $enum->enum, + ); + } + 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..ecd7d5ea 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#intBackedEnumResponse', 'url' => '/api/{apiVersion}/enums/int-backed-response', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], + ['name' => 'Settings#sortDirectionParameter', 'url' => '/api/{apiVersion}/enums/sort-direction', '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..246a862c 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; @@ -27,6 +28,7 @@ * @psalm-import-type NotificationsPushDevice from ResponseDefinitions * @psalm-import-type NotificationsNotification from ResponseDefinitions * @psalm-import-type NotificationsCollection from ResponseDefinitions + * @psalm-import-type NotificationsBackedEnums from ResponseDefinitions */ class SettingsController extends OCSController { /** @@ -850,4 +852,39 @@ public function custom401(): DataResponse { public function custom403(): DataResponse { return new DataResponse(); } + + /** + * A route with a backed string enum as a native parameter type and return type + * + * @param NotificationLevel $level Level + * @return DataResponse + * + * 200: OK + */ + public function stringBackedEnumParameter(NotificationLevel $level): DataResponse { + return new DataResponse($level); + } + + /** + * A route with a backed int enum used in a psalm-type + * + * @return DataResponse + * + * 200: OK + */ + public function intBackedEnumResponse(): DataResponse { + return new DataResponse(); + } + + /** + * A route using the built-in SortDirection enum as a native parameter type and return type + * + * @param \SortDirection $direction Direction + * @return DataResponse + * + * 200: OK + */ + public function sortDirectionParameter(\SortDirection $direction): DataResponse { + return new DataResponse($direction); + } } 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 @@ +