Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
69 changes: 69 additions & 0 deletions generate-spec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down
14 changes: 14 additions & 0 deletions src/OpenApiType.php
Original file line number Diff line number Diff line change
Expand Up @@ -467,13 +467,27 @@ 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(
context: $context,
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 . "'");
})(),
};
Expand Down
3 changes: 3 additions & 0 deletions tests/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
];
37 changes: 37 additions & 0 deletions tests/lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
/**
Expand Down Expand Up @@ -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<Http::STATUS_OK, NotificationLevel, array{}>
*
* 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<Http::STATUS_OK, NotificationsBackedEnums, array{}>
*
* 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<Http::STATUS_OK, \SortDirection, array{}>
*
* 200: OK
*/
public function sortDirectionParameter(\SortDirection $direction): DataResponse {
return new DataResponse($direction);
}
}
19 changes: 19 additions & 0 deletions tests/lib/NotificationLevel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Notifications;

/**
* The severity level of a notification
*/
enum NotificationLevel: string {
case Info = 'info';
case Warning = 'warning';
case Error = 'error';
}
19 changes: 19 additions & 0 deletions tests/lib/NotificationPriority.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Notifications;

/**
* The priority of a notification
*/
enum NotificationPriority: int {
case Low = 0;
case Medium = 1;
case High = 2;
}
19 changes: 19 additions & 0 deletions tests/lib/NotificationUnbackedEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Notifications;

/**
* A pure enum, which is not backed by a scalar value and therefore can not be used as an OpenAPI type.
* This file only exists to make sure the extractor does not choke on non-backed enums.
*/
enum NotificationUnbackedEnum {
case A;
case B;
}
5 changes: 5 additions & 0 deletions tests/lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@
* @psalm-type NotificationsSchemaOnlyInCapabilities = array{
* key: string,
* }
*
* @psalm-type NotificationsBackedEnums = array{
* level: NotificationLevel,
* priority: NotificationPriority,
* }
*/
class ResponseDefinitions {
}
Loading
Loading