From 8417b0d1ef3919c2310b56b7e697a8f1f782abab Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 24 Aug 2026 12:05:17 +0200 Subject: [PATCH] feat: Resolve enums and classes with PSR-4 Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Carl Schwan --- generate-spec.php | 69 +------- src/ClassResolver.php | 91 ++++++++++ src/EnumResolver.php | 85 ++++++++++ src/OpenApiType.php | 7 +- tests/appinfo/routes.php | 1 + tests/lib/Controller/SettingsController.php | 13 ++ .../lib/Notification/NotificationPriority.php | 22 +++ tests/openapi-administration.json | 155 ++++++++++++++++++ tests/openapi-full.json | 155 ++++++++++++++++++ 9 files changed, 532 insertions(+), 66 deletions(-) create mode 100644 src/ClassResolver.php create mode 100644 src/EnumResolver.php create mode 100644 tests/lib/Notification/NotificationPriority.php diff --git a/generate-spec.php b/generate-spec.php index d13f4b4..dc6a05f 100755 --- a/generate-spec.php +++ b/generate-spec.php @@ -22,8 +22,6 @@ 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; @@ -144,68 +142,15 @@ $schemas = []; $tags = []; -$enumsByFqcn = []; -$enumSourceDirs = [$sourceDir]; +// Namespace prefixes used to lazily resolve classes referenced as native parameter types, PSR-4 style. +$namespaceRoots = [ + $appNamespace => $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, - ); - } - } + $namespaceRoots['OC'] = $sourceDir . '/../lib/private'; } +$classResolver = new ClassResolver($astParser, $nodeTraverser, $nodeFinder, $namespaceRoots); +$enumResolver = new EnumResolver($classResolver, $phpDocParser, $lexer); $definitions = []; $definitionsPath = $sourceDir . '/ResponseDefinitions.php'; diff --git a/src/ClassResolver.php b/src/ClassResolver.php new file mode 100644 index 0000000..a18e049 --- /dev/null +++ b/src/ClassResolver.php @@ -0,0 +1,91 @@ + */ + private array $cache = []; + + /** @var array Namespace prefix => source directory */ + private readonly array $namespaceRoots; + + /** @param array $namespaceRoots Namespace prefix => source directory */ + public function __construct( + private readonly Parser $astParser, + private readonly NodeTraverser $nodeTraverser, + private readonly NodeFinder $nodeFinder, + array $namespaceRoots, + ) { + $this->namespaceRoots = array_combine( + array_map(static fn (string $prefix): string => trim($prefix, '\\'), array_keys($namespaceRoots)), + array_values($namespaceRoots), + ); + } + + /** Returns null if the class can not be found, e.g. because it is outside of the known namespace roots. */ + public function resolve(string $fqcn): ?ClassLike { + $fqcn = ltrim($fqcn, '\\'); + if (!array_key_exists($fqcn, $this->cache)) { + $this->cache[$fqcn] = $this->load($fqcn) ?? false; + } + + $node = $this->cache[$fqcn]; + return $node !== false ? $node : null; + } + + private function load(string $fqcn): ?ClassLike { + $path = $this->findFile($fqcn); + if ($path === null || !is_file($path)) { + return null; + } + + $contents = file_get_contents($path); + if ($contents === false) { + return null; + } + + /** @var ClassLike $node */ + foreach ($this->nodeFinder->findInstanceOf($this->nodeTraverser->traverse($this->astParser->parse($contents)), ClassLike::class) as $node) { + if ($node->namespacedName?->toString() === $fqcn) { + $node->setAttribute('sourceFile', $path); + return $node; + } + } + + return null; + } + + /** Maps the class name to a file path via its longest matching namespace prefix. */ + private function findFile(string $fqcn): ?string { + $bestPrefix = null; + foreach (array_keys($this->namespaceRoots) as $prefix) { + if (!str_starts_with($fqcn . '\\', $prefix . '\\')) { + continue; + } + if ($bestPrefix === null || strlen($prefix) > strlen($bestPrefix)) { + $bestPrefix = $prefix; + } + } + + if ($bestPrefix === null) { + return null; + } + + $relativeName = substr($fqcn, strlen($bestPrefix) + 1); + return $this->namespaceRoots[$bestPrefix] . '/' . str_replace('\\', '/', $relativeName) . '.php'; + } +} diff --git a/src/EnumResolver.php b/src/EnumResolver.php new file mode 100644 index 0000000..47bbb68 --- /dev/null +++ b/src/EnumResolver.php @@ -0,0 +1,85 @@ + */ + private array $cache = []; + + public function __construct( + private readonly ClassResolver $classResolver, + private readonly PhpDocParser $phpDocParser, + private readonly Lexer $lexer, + ) { + } + + public function resolve(string $fqcn): ?OpenApiType { + $fqcn = ltrim($fqcn, '\\'); + if (!array_key_exists($fqcn, $this->cache)) { + $this->cache[$fqcn] = $this->load($fqcn) ?? false; + } + + $enum = $this->cache[$fqcn]; + return $enum !== false ? $enum : null; + } + + private function load(string $fqcn): ?OpenApiType { + $node = $this->classResolver->resolve($fqcn); + if (!$node instanceof Enum_) { + return null; + } + + $path = $node->getAttribute('sourceFile', $fqcn); + + if ($node->scalarType === null) { + Logger::debug($path, "Enum '" . $fqcn . "' is not backed and can therefore not be used as an OpenAPI type. Use 'enum " . $node->name->name . ": string' or 'enum " . $node->name->name . ": int' instead."); + return null; + } + + $values = []; + foreach ($node->stmts as $stmt) { + if ($stmt instanceof EnumCase && $stmt->expr !== null) { + $values[] = Helpers::exprToValue($path . ': ' . $fqcn . '::' . $stmt->name->name, $stmt->expr); + } + } + + $description = null; + $doc = $node->getDocComment()?->getText(); + if ($doc != null) { + $descriptionLines = []; + $docNodes = $this->phpDocParser->parse(new TokenIterator($this->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); + } + } + + return new OpenApiType( + context: $path, + type: $node->scalarType->name === 'int' ? 'integer' : 'string', + format: $node->scalarType->name === 'int' ? 'int64' : null, + description: $description, + enum: $values, + ); + } +} diff --git a/src/OpenApiType.php b/src/OpenApiType.php index 8df2ee2..9a158ac 100644 --- a/src/OpenApiType.php +++ b/src/OpenApiType.php @@ -422,13 +422,12 @@ public static function resolveNativeEnum(string $context, ?Node $node): ?OpenApi return null; } - global $enumsByFqcn; - $fqcn = ltrim($node->toString(), '\\'); - if (!array_key_exists($fqcn, $enumsByFqcn)) { + global $enumResolver; + $enum = $enumResolver->resolve($node->toString()); + if ($enum === null) { return null; } - $enum = $enumsByFqcn[$fqcn]; return new OpenApiType( context: $context, type: $enum->type, diff --git a/tests/appinfo/routes.php b/tests/appinfo/routes.php index 7fe5f55..f9ffb8f 100644 --- a/tests/appinfo/routes.php +++ b/tests/appinfo/routes.php @@ -94,6 +94,7 @@ ['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#intBackedEnumParameter', 'url' => '/api/{apiVersion}/enums/int-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 3c1ebe0..7e9a686 100644 --- a/tests/lib/Controller/SettingsController.php +++ b/tests/lib/Controller/SettingsController.php @@ -9,6 +9,7 @@ namespace OCA\Notifications\Controller; +use OCA\Notifications\Notification\NotificationPriority; use OCA\Notifications\NotificationLevel; use OCA\Notifications\ResponseDefinitions; use OCP\AppFramework\Http; @@ -865,6 +866,18 @@ public function stringBackedEnumParameter(NotificationLevel $level): DataRespons return new DataResponse(); } + /** + * A route with a backed enum declared in a sub-namespace as a native parameter type + * + * @param NotificationPriority $priority Priority + * @return DataResponse + * + * 200: OK + */ + public function intBackedEnumParameter(NotificationPriority $priority): DataResponse { + return new DataResponse(); + } + /** * A route using the built-in SortDirection enum as a native parameter type * diff --git a/tests/lib/Notification/NotificationPriority.php b/tests/lib/Notification/NotificationPriority.php new file mode 100644 index 0000000..7f12439 --- /dev/null +++ b/tests/lib/Notification/NotificationPriority.php @@ -0,0 +1,22 @@ +