From fe2cf698d3ca060f75db2baf8a92863763ce7a23 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 17 Aug 2026 23:15:11 +0200 Subject: [PATCH 1/2] feat: Add support for backed enums Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Carl Schwan --- CHANGELOG.md | 3 + generate-spec.php | 69 +++++ src/OpenApiType.php | 13 + tests/appinfo/routes.php | 2 + tests/lib/Controller/SettingsController.php | 25 ++ tests/lib/NotificationLevel.php | 19 ++ tests/lib/NotificationPriority.php | 19 ++ tests/lib/NotificationUnbackedEnum.php | 19 ++ tests/lib/ResponseDefinitions.php | 5 + tests/openapi-administration.json | 318 ++++++++++++++++++++ tests/openapi-full.json | 318 ++++++++++++++++++++ 11 files changed, 810 insertions(+) create mode 100644 tests/lib/NotificationLevel.php create mode 100644 tests/lib/NotificationPriority.php create mode 100644 tests/lib/NotificationUnbackedEnum.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e2be03..964584a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ 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 + ### 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..8788869a 100644 --- a/src/OpenApiType.php +++ b/src/OpenApiType.php @@ -474,6 +474,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..d3b9bc98 100644 --- a/tests/appinfo/routes.php +++ b/tests/appinfo/routes.php @@ -93,6 +93,8 @@ ['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' => 'V1\SubDir#subDirRoute', 'url' => '/sub-dir', 'verb' => 'GET'], ], ]; diff --git a/tests/lib/Controller/SettingsController.php b/tests/lib/Controller/SettingsController.php index 843514f0..d8e9a9c6 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,27 @@ 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(); + } } 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 @@ + Date: Tue, 18 Aug 2026 16:55:21 +0200 Subject: [PATCH 2/2] feat: Add support for SortDirection enum Signed-off-by: Carl Schwan --- CHANGELOG.md | 1 + src/OpenApiType.php | 1 + tests/appinfo/routes.php | 1 + tests/lib/Controller/SettingsController.php | 12 ++ tests/openapi-administration.json | 157 ++++++++++++++++++++ tests/openapi-full.json | 157 ++++++++++++++++++++ 6 files changed, 329 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 964584a7..ec38d166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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/src/OpenApiType.php b/src/OpenApiType.php index 8788869a..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( diff --git a/tests/appinfo/routes.php b/tests/appinfo/routes.php index d3b9bc98..ecd7d5ea 100644 --- a/tests/appinfo/routes.php +++ b/tests/appinfo/routes.php @@ -95,6 +95,7 @@ ['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 d8e9a9c6..246a862c 100644 --- a/tests/lib/Controller/SettingsController.php +++ b/tests/lib/Controller/SettingsController.php @@ -875,4 +875,16 @@ public function stringBackedEnumParameter(NotificationLevel $level): DataRespons 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/openapi-administration.json b/tests/openapi-administration.json index 0c5d11bc..7be6a5f5 100644 --- a/tests/openapi-administration.json +++ b/tests/openapi-administration.json @@ -9577,6 +9577,163 @@ } } }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/enums/sort-direction": { + "post": { + "operationId": "settings-sort-direction-parameter", + "summary": "A route using the built-in SortDirection enum as a native parameter type and return type", + "description": "This endpoint requires admin access", + "tags": [ + "settings" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "direction" + ], + "properties": { + "direction": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "description": "Direction" + } + } + } + } + } + }, + "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": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ] + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Logged in account must be an admin", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/notifications/optional-parameters": { "post": { "operationId": "admin_settings-optional-parameters", diff --git a/tests/openapi-full.json b/tests/openapi-full.json index 73c7b3b2..ce53b1f3 100644 --- a/tests/openapi-full.json +++ b/tests/openapi-full.json @@ -9777,6 +9777,163 @@ } } }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/enums/sort-direction": { + "post": { + "operationId": "settings-sort-direction-parameter", + "summary": "A route using the built-in SortDirection enum as a native parameter type and return type", + "description": "This endpoint requires admin access", + "tags": [ + "settings" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "direction" + ], + "properties": { + "direction": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "description": "Direction" + } + } + } + } + } + }, + "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": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ] + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Logged in account must be an admin", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/notifications/optional-parameters": { "post": { "operationId": "admin_settings-optional-parameters",