Skip to content

Commit ad2777c

Browse files
Merge pull request #377 from nextcloud/carl/psr4-enums
feat: Resolve enums and classes with PSR-4
2 parents 2b7d6ed + d26420b commit ad2777c

9 files changed

Lines changed: 532 additions & 66 deletions

File tree

generate-spec.php

Lines changed: 7 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@
2222
use PhpParser\Node\Name;
2323
use PhpParser\Node\Stmt\Class_;
2424
use PhpParser\Node\Stmt\ClassMethod;
25-
use PhpParser\Node\Stmt\Enum_;
26-
use PhpParser\Node\Stmt\EnumCase;
2725
use PhpParser\Node\Stmt\Throw_;
2826
use PhpParser\NodeFinder;
2927
use PhpParser\NodeTraverser;
@@ -144,68 +142,15 @@
144142
$schemas = [];
145143
$tags = [];
146144

147-
$enumsByFqcn = [];
148-
$enumSourceDirs = [$sourceDir];
145+
// Namespace prefixes used to lazily resolve classes referenced as native parameter types, PSR-4 style.
146+
$namespaceRoots = [
147+
$appNamespace => $sourceDir,
148+
];
149149
if ($appIsCore) {
150-
$enumSourceDirs[] = $sourceDir . '/../lib/private';
151-
}
152-
foreach ($enumSourceDirs as $enumSourceDir) {
153-
if (!is_dir($enumSourceDir)) {
154-
continue;
155-
}
156-
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($enumSourceDir));
157-
foreach ($iterator as $file) {
158-
$path = $file->getPathname();
159-
if (!str_ends_with((string)$path, '.php')) {
160-
continue;
161-
}
162-
$contents = file_get_contents($path);
163-
if (!str_contains($contents, 'enum ')) {
164-
// Cheap pre-filter to avoid parsing every file in the app just to look for enums.
165-
continue;
166-
}
167-
foreach ($nodeFinder->findInstanceOf($nodeTraverser->traverse($astParser->parse($contents)), Enum_::class) as $node) {
168-
$name = $node->name->name;
169-
if ($node->scalarType === null) {
170-
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.");
171-
continue;
172-
}
173-
174-
$values = [];
175-
foreach ($node->stmts as $stmt) {
176-
if ($stmt instanceof EnumCase && $stmt->expr !== null) {
177-
$values[] = Helpers::exprToValue($path . ': ' . $name . '::' . $stmt->name->name, $stmt->expr);
178-
}
179-
}
180-
181-
$description = null;
182-
$doc = $node->getDocComment()?->getText();
183-
if ($doc != null) {
184-
$descriptionLines = [];
185-
$docNodes = $phpDocParser->parse(new TokenIterator($lexer->tokenize($doc)))->children;
186-
foreach ($docNodes as $docNode) {
187-
if ($docNode instanceof PhpDocTextNode) {
188-
$block = Helpers::cleanDocComment($docNode->text);
189-
if ($block !== '') {
190-
$descriptionLines[] = $block;
191-
}
192-
}
193-
}
194-
if ($descriptionLines !== []) {
195-
$description = implode("\n", $descriptionLines);
196-
}
197-
}
198-
199-
$enumsByFqcn[$node->namespacedName->toString()] = new OpenApiType(
200-
context: $path,
201-
type: $node->scalarType->name === 'int' ? 'integer' : 'string',
202-
format: $node->scalarType->name === 'int' ? 'int64' : null,
203-
description: $description,
204-
enum: $values,
205-
);
206-
}
207-
}
150+
$namespaceRoots['OC'] = $sourceDir . '/../lib/private';
208151
}
152+
$classResolver = new ClassResolver($astParser, $nodeTraverser, $nodeFinder, $namespaceRoots);
153+
$enumResolver = new EnumResolver($classResolver, $phpDocParser, $lexer);
209154

210155
$definitions = [];
211156
$definitionsPath = $sourceDir . '/ResponseDefinitions.php';

src/ClassResolver.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OpenAPIExtractor;
9+
10+
use PhpParser\Node\Stmt\ClassLike;
11+
use PhpParser\NodeFinder;
12+
use PhpParser\NodeTraverser;
13+
use PhpParser\Parser;
14+
15+
/**
16+
* Lazily resolves a class, interface, trait or enum by its fully qualified name,
17+
* by mapping namespace prefixes to source directories, PSR-4 style.
18+
*/
19+
class ClassResolver {
20+
/** @var array<string, ClassLike|false> */
21+
private array $cache = [];
22+
23+
/** @var array<string, string> Namespace prefix => source directory */
24+
private readonly array $namespaceRoots;
25+
26+
/** @param array<string, string> $namespaceRoots Namespace prefix => source directory */
27+
public function __construct(
28+
private readonly Parser $astParser,
29+
private readonly NodeTraverser $nodeTraverser,
30+
private readonly NodeFinder $nodeFinder,
31+
array $namespaceRoots,
32+
) {
33+
$this->namespaceRoots = array_combine(
34+
array_map(static fn (string $prefix): string => trim($prefix, '\\'), array_keys($namespaceRoots)),
35+
array_values($namespaceRoots),
36+
);
37+
}
38+
39+
/** Returns null if the class can not be found, e.g. because it is outside of the known namespace roots. */
40+
public function resolve(string $fqcn): ?ClassLike {
41+
$fqcn = ltrim($fqcn, '\\');
42+
if (!array_key_exists($fqcn, $this->cache)) {
43+
$this->cache[$fqcn] = $this->load($fqcn) ?? false;
44+
}
45+
46+
$node = $this->cache[$fqcn];
47+
return $node !== false ? $node : null;
48+
}
49+
50+
private function load(string $fqcn): ?ClassLike {
51+
$path = $this->findFile($fqcn);
52+
if ($path === null || !is_file($path)) {
53+
return null;
54+
}
55+
56+
$contents = file_get_contents($path);
57+
if ($contents === false) {
58+
return null;
59+
}
60+
61+
/** @var ClassLike $node */
62+
foreach ($this->nodeFinder->findInstanceOf($this->nodeTraverser->traverse($this->astParser->parse($contents)), ClassLike::class) as $node) {
63+
if ($node->namespacedName?->toString() === $fqcn) {
64+
$node->setAttribute('sourceFile', $path);
65+
return $node;
66+
}
67+
}
68+
69+
return null;
70+
}
71+
72+
/** Maps the class name to a file path via its longest matching namespace prefix. */
73+
private function findFile(string $fqcn): ?string {
74+
$bestPrefix = null;
75+
foreach (array_keys($this->namespaceRoots) as $prefix) {
76+
if (!str_starts_with($fqcn . '\\', $prefix . '\\')) {
77+
continue;
78+
}
79+
if ($bestPrefix === null || strlen($prefix) > strlen($bestPrefix)) {
80+
$bestPrefix = $prefix;
81+
}
82+
}
83+
84+
if ($bestPrefix === null) {
85+
return null;
86+
}
87+
88+
$relativeName = substr($fqcn, strlen($bestPrefix) + 1);
89+
return $this->namespaceRoots[$bestPrefix] . '/' . str_replace('\\', '/', $relativeName) . '.php';
90+
}
91+
}

src/EnumResolver.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OpenAPIExtractor;
9+
10+
use PhpParser\Node\Stmt\Enum_;
11+
use PhpParser\Node\Stmt\EnumCase;
12+
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode;
13+
use PHPStan\PhpDocParser\Lexer\Lexer;
14+
use PHPStan\PhpDocParser\Parser\PhpDocParser;
15+
use PHPStan\PhpDocParser\Parser\TokenIterator;
16+
17+
/** Resolves a backed enum's fully qualified class name into its OpenAPI representation. */
18+
class EnumResolver {
19+
/** @var array<string, OpenApiType|false> */
20+
private array $cache = [];
21+
22+
public function __construct(
23+
private readonly ClassResolver $classResolver,
24+
private readonly PhpDocParser $phpDocParser,
25+
private readonly Lexer $lexer,
26+
) {
27+
}
28+
29+
public function resolve(string $fqcn): ?OpenApiType {
30+
$fqcn = ltrim($fqcn, '\\');
31+
if (!array_key_exists($fqcn, $this->cache)) {
32+
$this->cache[$fqcn] = $this->load($fqcn) ?? false;
33+
}
34+
35+
$enum = $this->cache[$fqcn];
36+
return $enum !== false ? $enum : null;
37+
}
38+
39+
private function load(string $fqcn): ?OpenApiType {
40+
$node = $this->classResolver->resolve($fqcn);
41+
if (!$node instanceof Enum_) {
42+
return null;
43+
}
44+
45+
$path = $node->getAttribute('sourceFile', $fqcn);
46+
47+
if ($node->scalarType === null) {
48+
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.");
49+
return null;
50+
}
51+
52+
$values = [];
53+
foreach ($node->stmts as $stmt) {
54+
if ($stmt instanceof EnumCase && $stmt->expr !== null) {
55+
$values[] = Helpers::exprToValue($path . ': ' . $fqcn . '::' . $stmt->name->name, $stmt->expr);
56+
}
57+
}
58+
59+
$description = null;
60+
$doc = $node->getDocComment()?->getText();
61+
if ($doc != null) {
62+
$descriptionLines = [];
63+
$docNodes = $this->phpDocParser->parse(new TokenIterator($this->lexer->tokenize($doc)))->children;
64+
foreach ($docNodes as $docNode) {
65+
if ($docNode instanceof PhpDocTextNode) {
66+
$block = Helpers::cleanDocComment($docNode->text);
67+
if ($block !== '') {
68+
$descriptionLines[] = $block;
69+
}
70+
}
71+
}
72+
if ($descriptionLines !== []) {
73+
$description = implode("\n", $descriptionLines);
74+
}
75+
}
76+
77+
return new OpenApiType(
78+
context: $path,
79+
type: $node->scalarType->name === 'int' || $node->scalarType->name === 'integer' ? 'integer' : 'string',
80+
format: $node->scalarType->name === 'int' || $node->scalarType->name === 'integer' ? 'int64' : null,
81+
description: $description,
82+
enum: $values,
83+
);
84+
}
85+
}

src/OpenApiType.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -422,13 +422,12 @@ public static function resolveNativeEnum(string $context, ?Node $node): ?OpenApi
422422
return null;
423423
}
424424

425-
global $enumsByFqcn;
426-
$fqcn = ltrim($node->toString(), '\\');
427-
if (!array_key_exists($fqcn, $enumsByFqcn)) {
425+
global $enumResolver;
426+
$enum = $enumResolver->resolve($node->toString());
427+
if ($enum === null) {
428428
return null;
429429
}
430430

431-
$enum = $enumsByFqcn[$fqcn];
432431
return new OpenApiType(
433432
context: $context,
434433
type: $enum->type,

tests/appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
['name' => 'Settings#custom401', 'url' => '/api/{apiVersion}/custom/401', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
9595
['name' => 'Settings#custom403', 'url' => '/api/{apiVersion}/custom/403', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
9696
['name' => 'Settings#stringBackedEnumParameter', 'url' => '/api/{apiVersion}/enums/string-backed', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
97+
['name' => 'Settings#intBackedEnumParameter', 'url' => '/api/{apiVersion}/enums/int-backed', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
9798
['name' => 'Settings#sortDirectionParameter', 'url' => '/api/{apiVersion}/enums/sort-direction', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
9899
['name' => 'Settings#injectedServiceParameter', 'url' => '/api/{apiVersion}/injected-service', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
99100
['name' => 'V1\SubDir#subDirRoute', 'url' => '/sub-dir', 'verb' => 'GET'],

tests/lib/Controller/SettingsController.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace OCA\Notifications\Controller;
1111

12+
use OCA\Notifications\Notification\NotificationPriority;
1213
use OCA\Notifications\NotificationLevel;
1314
use OCA\Notifications\ResponseDefinitions;
1415
use OCP\AppFramework\Http;
@@ -865,6 +866,18 @@ public function stringBackedEnumParameter(NotificationLevel $level): DataRespons
865866
return new DataResponse();
866867
}
867868

869+
/**
870+
* A route with a backed enum declared in a sub-namespace as a native parameter type
871+
*
872+
* @param NotificationPriority $priority Priority
873+
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
874+
*
875+
* 200: OK
876+
*/
877+
public function intBackedEnumParameter(NotificationPriority $priority): DataResponse {
878+
return new DataResponse();
879+
}
880+
868881
/**
869882
* A route using the built-in SortDirection enum as a native parameter type
870883
*
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Notifications\Notification;
11+
12+
/**
13+
* The priority of a notification
14+
*
15+
* Declared in a sub-namespace/sub-directory to confirm that enums are resolved
16+
* by mapping their namespace to a file path instead of relying on a directory scan.
17+
*/
18+
enum NotificationPriority: integer {
19+
case Low = 0;
20+
case Normal = 1;
21+
case High = 2;
22+
}

0 commit comments

Comments
 (0)