Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(scopes): Allow apps to define different API scopes for different… #24

Merged
merged 29 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0993565
feat(scopes): Allow apps to define different API scopes for different…
nickvergessen Jan 11, 2024
36ab954
feat(scopes): Allow multiple scopes
nickvergessen Jan 11, 2024
fff1bd9
fix(scopes): Only list schemas that are used in this Scope
nickvergessen Jan 11, 2024
288914d
feat(scopes): Move admin-only routes to administration scope when def…
nickvergessen Jan 11, 2024
e065a7c
feat(scopes): Handle tags defined by the scopes
nickvergessen Jan 11, 2024
481ca1c
fix(scopes): Don't break on files responses
nickvergessen Jan 11, 2024
a369adb
fix(scopes): Deduplicate logic
nickvergessen Jan 11, 2024
f6ad18f
fix(scopes): Also export schemas that are only referenced by other sc…
nickvergessen Jan 11, 2024
6ef6a4b
fix(scopes): Don't warn when we deal with binary return types
nickvergessen Jan 11, 2024
7aa0bac
fix(scopes): Correctly add schemas references by nested schemas
nickvergessen Jan 11, 2024
886b55f
fix(scopes): Don't break with \stdClass returns
nickvergessen Jan 11, 2024
59826d7
fix(scopes): Handle all possible cases of other Schemas when importin…
nickvergessen Jan 11, 2024
2cf5e13
fix(scopes): Add tests for the scope and tag features
nickvergessen Jan 11, 2024
2cd80cf
fix(scopes): Fix tag variable name
nickvergessen Jan 12, 2024
f76700f
fix(scopes): Remove attribute name from parameter as it's always the …
nickvergessen Jan 12, 2024
42f15fb
fix(scopes): Panic when a controller/route is ignored but has other s…
nickvergessen Jan 12, 2024
03ccc56
fix(scopes): Better log levels for schema detection
nickvergessen Jan 12, 2024
3729e5a
fix(scopes): Reduce nesting of the scope loops
nickvergessen Jan 12, 2024
9833a75
fix(scopes): Deduplicate finding used schemas
nickvergessen Jan 12, 2024
a698329
fix(scopes): Allow scopes and tags without parameter names
nickvergessen Jan 12, 2024
2125e85
feat(scopes): Add a "full" scope when more than 1 is used
nickvergessen Jan 12, 2024
84c976b
fix(tests): Adjust some copy-paste description and add openapi-full.json
nickvergessen Jan 12, 2024
0d8fb4b
fix(scopes): Improve help when not reading schemas
nickvergessen Jan 12, 2024
5d78669
fix(tags): Panic when the tags are not an array list
nickvergessen Jan 17, 2024
4d20003
fix(scopes): Inspect all responses for schemas
nickvergessen Jan 17, 2024
ed40c15
fix(scopes): Fix counting the routes
nickvergessen Jan 17, 2024
79517db
fix(scopes): Fix empty path arrays
nickvergessen Jan 17, 2024
443740f
fix(schemas): Make sure schemas are always a dictionary
nickvergessen Jan 17, 2024
a479a34
fix(scopes): Don't generate "full" scope when there is none
nickvergessen Jan 17, 2024
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
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)),
provokateurin marked this conversation as resolved.
Show resolved Hide resolved
};
}

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 {
nickvergessen marked this conversation as resolved.
Show resolved Hide resolved
$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