Skip to content

Commit

Permalink
Merge pull request #24 from nextcloud/techdebt/20/openapi-scopes
Browse files Browse the repository at this point in the history
feat(scopes): Allow apps to define different API scopes for different…
  • Loading branch information
nickvergessen authored Jan 17, 2024
2 parents 3fb48b2 + a479a34 commit bfe4acc
Show file tree
Hide file tree
Showing 11 changed files with 4,909 additions and 2,067 deletions.
571 changes: 358 additions & 213 deletions generate-spec

Large diffs are not rendered by default.

127 changes: 126 additions & 1 deletion src/Helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@

use Exception;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\AttributeGroup;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use stdClass;

class Helpers {
public const OPENAPI_ATTRIBUTE_CLASSNAME = 'OpenAPI';

public static function generateReadableAppID(string $appID): string {
return implode("", array_map(fn (string $s) => ucfirst($s), explode("_", $appID)));
}
Expand Down Expand Up @@ -133,7 +140,7 @@ public static function classMethodHasAnnotationOrAttribute(ClassMethod|Class_|No
return true;
}

/** @var Node\AttributeGroup $attrGroup */
/** @var AttributeGroup $attrGroup */
foreach ($node->attrGroups as $attrGroup) {
foreach ($attrGroup->attrs as $attr) {
if ($attr->name->getLast() == $annotation) {
Expand All @@ -149,4 +156,122 @@ public static function cleanSchemaName(string $name): string {
global $readableAppID;
return substr($name, strlen($readableAppID));
}

protected static function getScopeNameFromAttributeArgument(Arg $arg, int $key, string $routeName): ?string {
if ($arg->name?->name === 'scope' || ($arg->name === null && $key === 0)) {
if ($arg->value instanceof ClassConstFetch) {
if ($arg->value->class->getLast() === self::OPENAPI_ATTRIBUTE_CLASSNAME) {
return self::getScopeNameFromConst($arg->value);
}
} elseif ($arg->value instanceof String_) {
return $arg->value->value;
} else {
Logger::panic($routeName, 'Can not interpret value of scope provided in OpenAPI(scope: …) attribute. Please use string or OpenAPI::SCOPE_* constants');
}
}

return null;
}

protected static function getScopeNameFromConst(ClassConstFetch $scope): string {
return match ($scope->name->name) {
'SCOPE_DEFAULT' => 'default',
'SCOPE_ADMINISTRATION' => 'administration',
'SCOPE_FEDERATION' => 'federation',
'SCOPE_IGNORE' => 'ignore',
// Fall back for future scopes assuming we follow the pattern (cut of 'SCOPE_' and lower case)
default => strtolower(substr($scope->name->name, 6)),
};
}

public static function getOpenAPIAttributeScopes(ClassMethod|Class_|Node $node, string $routeName): array {
$scopes = [];

/** @var AttributeGroup $attrGroup */
foreach ($node->attrGroups as $attrGroup) {
foreach ($attrGroup->attrs as $attr) {
if ($attr->name->getLast() === self::OPENAPI_ATTRIBUTE_CLASSNAME) {
if (empty($attr->args)) {
$scopes[] = 'default';
continue;
}

foreach ($attr->args as $key => $arg) {
$scope = self::getScopeNameFromAttributeArgument($arg, (int) $key, $routeName);
if ($scope !== null) {
$scopes[] = $scope;
}
}
}
}
}

return $scopes;
}

public static function getOpenAPIAttributeTagsByScope(ClassMethod|Class_|Node $node, string $routeName, string $defaultTag, string $defaultScope): array {
$tags = [];

/** @var AttributeGroup $attrGroup */
foreach ($node->attrGroups as $attrGroup) {
foreach ($attrGroup->attrs as $attr) {
if ($attr->name->getLast() === self::OPENAPI_ATTRIBUTE_CLASSNAME) {
if (empty($attr->args)) {
$tags[$defaultScope] = [$defaultTag];
continue;
}

$foundTags = [];
$foundScopeName = null;
foreach ($attr->args as $key => $arg) {
$foundScopeName = self::getScopeNameFromAttributeArgument($arg, (int) $key, $routeName);

if ($arg->name?->name !== 'tags' && ($arg->name !== null || $key !== 1)) {
continue;
}

if (!$arg->value instanceof Array_) {
Logger::panic($routeName, 'Can not read value of tags provided in OpenAPI attribute for route ' . $routeName);
}

foreach ($arg->value->items as $item) {
if ($item?->value instanceof String_) {
$foundTags[] = $item->value->value;
}
}
}

if (!empty($foundTags)) {
$tags[$foundScopeName ?: $defaultScope] = $foundTags;
}
}
}
}

return $tags;
}

public static function collectUsedRefs(array $data): array {
$refs = [];
if (isset($data['$ref'])) {
$refs[] = [$data['$ref']];
}

foreach (['allOf', 'oneOf', 'anyOf', 'properties', 'additionalProperties'] as $group) {
if (!isset($data[$group]) || !is_array($data[$group])) {
continue;
}

foreach ($data[$group] as $property) {
if (is_array($property)) {
$refs[] = self::collectUsedRefs($property);
}
}
}

if (isset($data['items'])) {
$refs[] = self::collectUsedRefs($data['items']);
}
return array_merge(...$refs);
}
}
2 changes: 1 addition & 1 deletion src/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
class Route {
public function __construct(
public string $name,
public string $tag,
public array $tags,
public string $methodName,
public ?string $postfix,
public string $verb,
Expand Down
7 changes: 5 additions & 2 deletions tests/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@
['name' => 'AdminSettings#adminScopeImplicitFromAdminRequired', 'url' => '/api/{apiVersion}/default-admin', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'AdminSettings#movedToDefaultScope', 'url' => '/api/{apiVersion}/default-admin-overwritten', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'AdminSettings#movedToSettingsTag', 'url' => '/api/{apiVersion}/moved-with-tag', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'AdminSettings#movedToSettingsTagUnnamed', 'url' => '/api/{apiVersion}/moved-with-unnamed-tag', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],

['name' => 'Federation#federationByController', 'url' => '/api/{apiVersion}/controller-scope', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Federation#movedToDefaultScope', 'url' => '/api/{apiVersion}/default-scope', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],

['name' => 'Settings#federationByController', 'url' => '/api/{apiVersion}/controller-scope', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#ignoreByDeprecatedAttributeOnMethod', 'url' => '/api/{apiVersion}/ignore-openapi-attribute', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#ignoreByScopeOnMethod', 'url' => '/api/{apiVersion}/ignore-method-scope', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#movedToDefaultScope', 'url' => '/api/{apiVersion}/default-scope', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#ignoreByUnnamedScopeOnMethod', 'url' => '/api/{apiVersion}/ignore-method-scope-unnamed', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#movedToAdminScope', 'url' => '/api/{apiVersion}/admin-scope', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#defaultAndAdminScope', 'url' => '/api/{apiVersion}/default-and-admin-scope', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#nestedSchemas', 'url' => '/api/{apiVersion}/nested-schemas', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
Expand Down
12 changes: 12 additions & 0 deletions tests/lib/Controller/AdminSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,16 @@ public function movedToDefaultScope(): DataResponse {
public function movedToSettingsTag(): DataResponse {
return new DataResponse();
}

/**
* Route in default scope with tags but without named parameters on the attribute
*
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>
*
* 200: Personal settings updated
*/
#[OpenAPI(OpenAPI::SCOPE_ADMINISTRATION, ['settings', 'admin-settings'])]
public function movedToSettingsTagUnnamed(): DataResponse {
return new DataResponse();
}
}
69 changes: 69 additions & 0 deletions tests/lib/Controller/FederationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2021, Julien Barnoin <[email protected]>
*
* @author Julien Barnoin <[email protected]>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OCA\Notifications\Controller;

use OCA\Notifications\ResponseDefinitions;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;

/**
* @psalm-import-type NotificationsPushDevice from ResponseDefinitions
* @psalm-import-type NotificationsNotification from ResponseDefinitions
* @psalm-import-type NotificationsCollection from ResponseDefinitions
*/
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
class FederationController extends OCSController {

/**
* @NoAdminRequired
*
* Route is in federation scope as per controller scope
*
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>
*
* 200: OK
*/
public function federationByController(): DataResponse {
return new DataResponse();
}

/**
* @NoAdminRequired
*
* Route is only in the default scope (moved from federation)
*
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>
*
* 200: Personal settings updated
*/
#[OpenAPI]
public function movedToDefaultScope(): DataResponse {
return new DataResponse();
}
}
27 changes: 6 additions & 21 deletions tests/lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,7 @@
* @psalm-import-type NotificationsNotification from ResponseDefinitions
* @psalm-import-type NotificationsCollection from ResponseDefinitions
*/
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
class SettingsController extends OCSController {

/**
* @NoAdminRequired
*
* Route is ignored because of scope on the controller
*
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>
*
* 200: OK
*/
public function federationByController(): DataResponse {
return new DataResponse();
}

/**
* @NoAdminRequired
*
Expand Down Expand Up @@ -85,14 +70,14 @@ public function ignoreByScopeOnMethod(): DataResponse {
/**
* @NoAdminRequired
*
* Route is only in the default scope
* Route is ignored because of scope on the method but without `scope: ` name
*
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>
*
* 200: Personal settings updated
* 200: OK
*/
#[OpenAPI]
public function movedToDefaultScope(): DataResponse {
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
public function ignoreByUnnamedScopeOnMethod(): DataResponse {
return new DataResponse();
}

Expand Down Expand Up @@ -139,7 +124,7 @@ public function defaultAndAdminScope(): DataResponse {
/**
* @NoAdminRequired
*
* Route is ignored because of scope on the controller
* Route is referencing nested schemas
*
* @return DataResponse<Http::STATUS_OK, list<NotificationsNotification>, array{}>
*
Expand All @@ -152,7 +137,7 @@ public function nestedSchemas(): DataResponse {
/**
* @NoAdminRequired
*
* Route is ignored because of scope on the controller
* Route is referencing a schema which is a list of schemas
*
* @return DataResponse<Http::STATUS_OK, NotificationsCollection, array{}>
*
Expand Down
Loading

0 comments on commit bfe4acc

Please sign in to comment.