From f8c617ca47d35bfa988c526d5a7273db49b83af7 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 9 May 2024 17:04:33 +0930 Subject: [PATCH 1/4] WIP OpenAI spec generation --- src/Endpoint/Create.php | 64 ++++++++++++++++++- src/Endpoint/Delete.php | 29 ++++++++- src/Endpoint/Index.php | 47 +++++++++++++- src/Endpoint/Show.php | 50 ++++++++++++++- src/Endpoint/Update.php | 79 +++++++++++++++++++++-- src/OpenApi/OpenApiGenerator.php | 93 ++++++++++++++++++++++++++++ src/OpenApi/OpenApiPathsProvider.php | 10 +++ src/Schema/Field/Attribute.php | 6 ++ src/Schema/Field/Field.php | 9 ++- src/Schema/Field/Relationship.php | 32 ++++++++-- src/Schema/Field/ToMany.php | 21 +++++++ src/Schema/Field/ToOne.php | 23 +++++++ src/Schema/Type/Number.php | 31 +++++++--- src/Schema/Type/Str.php | 31 +++++++--- tests/feature/OpenApiTest.php | 32 ++++++++++ 15 files changed, 524 insertions(+), 33 deletions(-) create mode 100644 src/OpenApi/OpenApiGenerator.php create mode 100644 src/OpenApi/OpenApiPathsProvider.php create mode 100644 tests/feature/OpenApiTest.php diff --git a/src/Endpoint/Create.php b/src/Endpoint/Create.php index 255b4af..e565c00 100644 --- a/src/Endpoint/Create.php +++ b/src/Endpoint/Create.php @@ -9,18 +9,23 @@ use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider; +use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Creatable; +use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use function Tobyz\JsonApiServer\has_value; use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\set_value; -class Create implements Endpoint +class Create implements Endpoint, OpenApiPathsProvider { use HasVisibility; use SavesData; use ShowsResources; + use HasDescription; public static function make(): static { @@ -78,4 +83,61 @@ private function fillDefaultValues(Context $context, array &$data): void } } } + + public function getOpenApiPaths(Collection $collection): array + { + $resourcesCreate = array_map( + fn($resource) => ['$ref' => "#/components/schemas/{$resource}Create"], + $collection->resources(), + ); + + $resources = array_map( + fn($resource) => ['$ref' => "#/components/schemas/$resource"], + $collection->resources(), + ); + + return [ + "/{$collection->name()}" => [ + 'post' => [ + 'description' => $this->getDescription(), + 'tags' => [$collection->name()], + 'requestBody' => [ + 'content' => [ + JsonApi::MEDIA_TYPE => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['data'], + 'properties' => [ + 'data' => + count($resourcesCreate) === 1 + ? $resourcesCreate[0] + : ['oneOf' => $resourcesCreate], + ], + ], + ], + ], + 'required' => true, + ], + 'responses' => [ + '200' => [ + 'content' => [ + JsonApi::MEDIA_TYPE => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['data'], + 'properties' => [ + 'data' => + count($resources) === 1 + ? $resources[0] + : ['oneOf' => $resources], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + } } diff --git a/src/Endpoint/Delete.php b/src/Endpoint/Delete.php index 5b92235..f8b20c5 100644 --- a/src/Endpoint/Delete.php +++ b/src/Endpoint/Delete.php @@ -9,17 +9,21 @@ use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; +use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider; +use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Deletable; +use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use function Tobyz\JsonApiServer\json_api_response; -class Delete implements Endpoint +class Delete implements Endpoint, OpenApiPathsProvider { use HasMeta; use HasVisibility; use FindsResources; + use HasDescription; public static function make(): static { @@ -62,4 +66,27 @@ public function handle(Context $context): ?ResponseInterface return new Response(204); } + + public function getOpenApiPaths(Collection $collection): array + { + return [ + "/{$collection->name()}/{id}" => [ + 'delete' => [ + 'description' => $this->getDescription(), + 'tags' => [$collection->name()], + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], + ], + 'responses' => [ + '204' => [], + ], + ], + ], + ]; + } } diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 5ee91c5..c75e9ee 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -11,9 +11,13 @@ use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; use Tobyz\JsonApiServer\Exception\Sourceable; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider; use Tobyz\JsonApiServer\Pagination\OffsetPagination; +use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Countable; use Tobyz\JsonApiServer\Resource\Listable; +use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use Tobyz\JsonApiServer\Serializer; @@ -22,11 +26,12 @@ use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\parse_sort_string; -class Index implements Endpoint +class Index implements Endpoint, OpenApiPathsProvider { use HasMeta; use HasVisibility; use IncludesData; + use HasDescription; public Closure $paginationResolver; public ?string $defaultSort = null; @@ -166,4 +171,44 @@ private function applyFilters($query, Context $context): void throw $e->prependSource(['parameter' => 'filter']); } } + + public function getOpenApiPaths(Collection $collection): array + { + $resources = array_map( + fn($resource) => [ + '$ref' => "#/components/schemas/$resource", + ], + $collection->resources(), + ); + + return [ + "/{$collection->name()}" => [ + 'get' => [ + 'description' => $this->getDescription(), + 'tags' => [$collection->name()], + 'responses' => [ + '200' => [ + 'content' => [ + JsonApi::MEDIA_TYPE => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['data'], + 'properties' => [ + 'data' => [ + 'type' => 'array', + 'items' => + count($resources) === 1 + ? $resources[0] + : ['oneOf' => $resources], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + } } diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index a8f98bb..cd20cf8 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -8,15 +8,20 @@ use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider; +use Tobyz\JsonApiServer\Resource\Collection; +use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use function Tobyz\JsonApiServer\json_api_response; -class Show implements Endpoint +class Show implements Endpoint, OpenApiPathsProvider { use HasVisibility; use FindsResources; use ShowsResources; + use HasDescription; public static function make(): static { @@ -43,4 +48,47 @@ public function handle(Context $context): ?ResponseInterface return json_api_response($this->showResource($context, $model)); } + + public function getOpenApiPaths(Collection $collection): array + { + $resources = array_map( + fn($resource) => ['$ref' => "#/components/schemas/$resource"], + $collection->resources(), + ); + + return [ + "/{$collection->name()}/{id}" => [ + 'get' => [ + 'description' => $this->getDescription(), + 'tags' => [$collection->name()], + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], + ], + 'responses' => [ + '200' => [ + 'content' => [ + JsonApi::MEDIA_TYPE => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['data'], + 'properties' => [ + 'data' => + count($resources) === 1 + ? $resources[0] + : ['oneOf' => $resources], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + } } diff --git a/src/Endpoint/Update.php b/src/Endpoint/Update.php index 73516ae..6bd3e04 100644 --- a/src/Endpoint/Update.php +++ b/src/Endpoint/Update.php @@ -10,17 +10,22 @@ use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider; +use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Updatable; +use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use function Tobyz\JsonApiServer\json_api_response; -class Update implements Endpoint +class Update implements Endpoint, OpenApiPathsProvider { use HasVisibility; use FindsResources; use SavesData; use ShowsResources; + use HasDescription; public static function make(): static { @@ -41,9 +46,11 @@ public function handle(Context $context): ?ResponseInterface $model = $this->findResource($context, $segments[1]); - $context = $context->withResource( - $resource = $context->resource($context->collection->resource($model, $context)), - ); + $context = $context + ->withModel($model) + ->withResource( + $resource = $context->resource($context->collection->resource($model, $context)), + ); if (!$resource instanceof Updatable) { throw new RuntimeException( @@ -51,7 +58,7 @@ public function handle(Context $context): ?ResponseInterface ); } - if (!$this->isVisible($context = $context->withModel($model))) { + if (!$this->isVisible($context)) { throw new ForbiddenException(); } @@ -68,4 +75,66 @@ public function handle(Context $context): ?ResponseInterface return json_api_response($this->showResource($context, $model)); } + + public function getOpenApiPaths(Collection $collection): array + { + $resources = array_map( + fn($resource) => [ + '$ref' => "#/components/schemas/$resource", + ], + $collection->resources(), + ); + + return [ + "/{$collection->name()}/{id}" => [ + 'patch' => [ + 'description' => $this->getDescription(), + 'tags' => [$collection->name()], + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], + ], + 'requestBody' => [ + 'content' => [ + JsonApi::MEDIA_TYPE => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['data'], + 'properties' => [ + 'data' => + count($resources) === 1 + ? $resources[0] + : ['oneOf' => $resources], + ], + ], + ], + ], + 'required' => true, + ], + 'responses' => [ + '200' => [ + 'content' => [ + JsonApi::MEDIA_TYPE => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['data'], + 'properties' => [ + 'data' => + count($resources) === 1 + ? $resources[0] + : ['oneOf' => $resources], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + } } diff --git a/src/OpenApi/OpenApiGenerator.php b/src/OpenApi/OpenApiGenerator.php new file mode 100644 index 0000000..0e44a03 --- /dev/null +++ b/src/OpenApi/OpenApiGenerator.php @@ -0,0 +1,93 @@ + [ + 'type' => 'object', + 'required' => ['type', 'id'], + 'properties' => [ + 'type' => ['type' => 'string'], + 'id' => ['type' => 'string'], + ], + ], + 'jsonApiResource' => [ + 'type' => 'object', + 'discriminator' => ['propertyName' => 'type'], + 'required' => ['type', 'id'], + 'properties' => [ + 'type' => ['type' => 'string'], + 'id' => ['type' => 'string'], + 'attributes' => ['type' => 'object'], + 'relationships' => ['type' => 'object'], + 'links' => ['type' => 'object', 'readOnly' => true], + ], + ], + ]; + + foreach ($api->collections as $collection) { + foreach ($collection->endpoints() as $endpoint) { + if ($endpoint instanceof OpenApiPathsProvider) { + $paths = array_merge_recursive($paths, $endpoint->getOpenApiPaths($collection)); + } + } + } + + foreach ($api->resources as $resource) { + $schema = ['attributes' => [], 'relationships' => []]; + + foreach ($resource->fields() as $field) { + $schema[location($field)]['properties'][$field->name] = $field->getSchema($api); + + if ($field->required) { + $schema[location($field)]['required'][] = $field->name; + } + } + + $schemas["{$resource->type()}Create"] = [ + 'type' => 'object', + 'required' => ['type'], + 'properties' => [ + 'type' => ['type' => 'string', 'const' => $resource->type()], + 'id' => ['type' => 'string'], + 'attributes' => ['type' => 'object'] + $schema['attributes'], + 'relationships' => ['type' => 'object'] + $schema['relationships'], + ], + ]; + + $schemas[$resource->type()] = [ + 'type' => 'object', + 'required' => ['type', 'id'], + 'properties' => [ + 'type' => ['type' => 'string', 'const' => $resource->type()], + 'id' => ['type' => 'string', 'readOnly' => true], + 'attributes' => ['type' => 'object'] + $schema['attributes'], + 'relationships' => ['type' => 'object'] + $schema['relationships'], + ], + ]; + } + + return array_filter([ + 'openapi' => '3.1.0', + 'servers' => $api->basePath ? [['url' => $api->basePath]] : null, + 'paths' => $paths, + 'components' => [ + 'schemas' => $schemas, + ], + 'externalDocs' => [ + 'description' => "JSON:API v$jsonApiVersion Specification", + 'url' => "https://jsonapi.org/format/$jsonApiVersion/", + ], + ]); + } +} diff --git a/src/OpenApi/OpenApiPathsProvider.php b/src/OpenApi/OpenApiPathsProvider.php new file mode 100644 index 0000000..b066adf --- /dev/null +++ b/src/OpenApi/OpenApiPathsProvider.php @@ -0,0 +1,10 @@ +type?->schema() ?: []); + } } diff --git a/src/Schema/Field/Field.php b/src/Schema/Field/Field.php index bb20aa9..3fd6460 100644 --- a/src/Schema/Field/Field.php +++ b/src/Schema/Field/Field.php @@ -2,6 +2,7 @@ namespace Tobyz\JsonApiServer\Schema\Field; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Schema\Concerns\GetsValue; use Tobyz\JsonApiServer\Schema\Concerns\HasProperty; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; @@ -48,8 +49,12 @@ public function schema(array $schema): static return $this; } - public function getSchema(): array + public function getSchema(JsonApi $api): array { - return $this->schema + ['description' => $this->description, 'nullable' => $this->nullable]; + return $this->schema + [ + 'description' => $this->description, + 'nullable' => $this->nullable, + 'readOnly' => !$this->writable, + ]; } } diff --git a/src/Schema/Field/Relationship.php b/src/Schema/Field/Relationship.php index 76627f6..bcf4cb2 100644 --- a/src/Schema/Field/Relationship.php +++ b/src/Schema/Field/Relationship.php @@ -5,6 +5,7 @@ use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; abstract class Relationship extends Field @@ -73,6 +74,18 @@ public function getValue(Context $context): mixed return parent::getValue($context); } + protected function getRelatedResources(JsonApi $api): array + { + return array_merge( + ...array_map( + fn($collection) => isset($api->collections[$collection]) + ? $api->getCollection($collection)->resources() + : [], + $this->collections, + ), + ); + } + protected function findResourceForIdentifier(array $identifier, Context $context): mixed { if (!isset($identifier['type'])) { @@ -83,12 +96,7 @@ protected function findResourceForIdentifier(array $identifier, Context $context throw new BadRequestException('id not specified'); } - $resources = array_merge( - ...array_map( - fn($collection) => $context->api->getCollection($collection)->resources(), - $this->collections, - ), - ); + $resources = $this->getRelatedResources($context->api); if (in_array($identifier['type'], $resources)) { return $this->findResource( @@ -101,4 +109,16 @@ protected function findResourceForIdentifier(array $identifier, Context $context 'pointer' => '/type', ]); } + + public function getSchema(JsonApi $api): array + { + return ['nullable' => false] + + parent::getSchema($api) + [ + 'type' => 'object', + 'properties' => ['data' => $this->getDataSchema($api)], + 'required' => $this->required ? ['data'] : [], + ]; + } + + abstract protected function getDataSchema(JsonApi $api): array; } diff --git a/src/Schema/Field/ToMany.php b/src/Schema/Field/ToMany.php index 5d9d585..3939bb1 100644 --- a/src/Schema/Field/ToMany.php +++ b/src/Schema/Field/ToMany.php @@ -6,6 +6,7 @@ use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\Sourceable; +use Tobyz\JsonApiServer\JsonApi; class ToMany extends Relationship { @@ -68,4 +69,24 @@ public function deserializeValue(mixed $value, Context $context): mixed return $models; } + + protected function getDataSchema(JsonApi $api): array + { + return [ + 'type' => 'array', + 'items' => [ + 'allOf' => [ + ['$ref' => '#/components/schemas/jsonApiResourceIdentifier'], + [ + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'enum' => $this->getRelatedResources($api), + ], + ], + ], + ], + ], + ]; + } } diff --git a/src/Schema/Field/ToOne.php b/src/Schema/Field/ToOne.php index ba6e318..e467db8 100644 --- a/src/Schema/Field/ToOne.php +++ b/src/Schema/Field/ToOne.php @@ -6,6 +6,7 @@ use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\Sourceable; +use Tobyz\JsonApiServer\JsonApi; class ToOne extends Relationship { @@ -63,4 +64,26 @@ public function deserializeValue(mixed $value, Context $context): mixed throw $e->prependSource(['pointer' => '/data']); } } + + protected function getDataSchema(JsonApi $api): array + { + return [ + 'oneOf' => [ + [ + 'allOf' => [ + ['$ref' => '#/components/schemas/jsonApiResourceIdentifier'], + [ + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'enum' => $this->getRelatedResources($api), + ], + ], + ], + ], + ], + ['type' => 'null'], + ], + ]; + } } diff --git a/src/Schema/Type/Number.php b/src/Schema/Type/Number.php index e52c6ab..3d60511 100644 --- a/src/Schema/Type/Number.php +++ b/src/Schema/Type/Number.php @@ -57,14 +57,29 @@ public function validate(mixed $value, callable $fail): void public function schema(): array { - return [ - 'type' => 'number', - 'minimum' => $this->minimum, - 'exclusiveMinimum' => $this->exclusiveMinimum, - 'maximum' => $this->maximum, - 'exclusiveMaximum' => $this->exclusiveMaximum, - 'multipleOf' => $this->multipleOf, - ]; + $schema = ['type' => 'number']; + + if ($this->minimum !== null) { + $schema['minimum'] = $this->minimum; + } + + if ($this->exclusiveMinimum) { + $schema['exclusiveMinimum'] = $this->exclusiveMinimum; + } + + if ($this->maximum !== null) { + $schema['maximum'] = $this->maximum; + } + + if ($this->exclusiveMaximum) { + $schema['exclusiveMaximum'] = $this->exclusiveMaximum; + } + + if ($this->multipleOf !== null) { + $schema['multipleOf'] = $this->multipleOf; + } + + return $schema; } public function minimum(?float $minimum, bool $exclusive = false): static diff --git a/src/Schema/Type/Str.php b/src/Schema/Type/Str.php index 85866b9..0a55869 100644 --- a/src/Schema/Type/Str.php +++ b/src/Schema/Type/Str.php @@ -55,14 +55,29 @@ public function validate(mixed $value, callable $fail): void public function schema(): array { - return [ - 'type' => 'string', - 'minLength' => $this->minLength, - 'maxLength' => $this->maxLength, - 'pattern' => $this->pattern, - 'format' => $this->format, - 'enum' => $this->enum, - ]; + $schema = ['type' => 'string']; + + if ($this->minLength) { + $schema['minLength'] = $this->minLength; + } + + if ($this->maxLength !== null) { + $schema['maxLength'] = $this->maxLength; + } + + if ($this->pattern !== null) { + $schema['pattern'] = $this->pattern; + } + + if ($this->format !== null) { + $schema['format'] = $this->format; + } + + if ($this->enum !== null) { + $schema['enum'] = $this->enum; + } + + return $schema; } public function minLength(int $characters): static diff --git a/tests/feature/OpenApiTest.php b/tests/feature/OpenApiTest.php new file mode 100644 index 0000000..94d0a53 --- /dev/null +++ b/tests/feature/OpenApiTest.php @@ -0,0 +1,32 @@ +resource( + new MockResource( + 'users', + endpoints: [Index::make()], + fields: [], + meta: [], + filters: [], + sorts: [], + ), + ); + + $generator = new OpenApiGenerator(); + + print_r($generator->generate($api)); + } +} From bad98dc5d2c06b8529746b5fa9f5197295c17f28 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 9 May 2024 17:07:06 +0930 Subject: [PATCH 2/4] WIP add resource action endpoint --- src/Endpoint/ResourceAction.php | 116 ++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/Endpoint/ResourceAction.php diff --git a/src/Endpoint/ResourceAction.php b/src/Endpoint/ResourceAction.php new file mode 100644 index 0000000..677462f --- /dev/null +++ b/src/Endpoint/ResourceAction.php @@ -0,0 +1,116 @@ +method = strtoupper($method); + + return $this; + } + + public function handle(Context $context): ?ResponseInterface + { + $segments = explode('/', $context->path()); + + if (count($segments) !== 3 || $segments[2] !== $this->name) { + return null; + } + + if ($context->request->getMethod() !== $this->method) { + throw new MethodNotAllowedException(); + } + + $model = $this->findResource($context, $segments[1]); + + $context = $context + ->withModel($model) + ->withResource($context->resource($context->collection->resource($model, $context))); + + if (!$this->isVisible($context)) { + throw new ForbiddenException(); + } + + ($this->handler)($model, $context); + + return json_api_response($this->showResource($context, $model)); + } + + public function getOpenApiPaths(Collection $collection): array + { + $resources = array_map( + fn($resource) => [ + '$ref' => "#/components/schemas/$resource", + ], + $collection->resources(), + ); + + return [ + "/{$collection->name()}/{id}/{$this->name}" => [ + strtolower($this->method) => [ + 'description' => $this->getDescription(), + 'tags' => [$collection->name()], + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], + ], + 'responses' => [ + '200' => [ + 'content' => [ + JsonApi::MEDIA_TYPE => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['data'], + 'properties' => [ + 'data' => + count($resources) === 1 + ? $resources[0] + : ['oneOf' => $resources], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + } +} From 3e782b881bf1f28f2a0a878865814290d9adda6f Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 13 Sep 2024 10:05:08 +0100 Subject: [PATCH 3/4] WIP add collection action endpoint --- src/Endpoint/CollectionAction.php | 74 +++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/Endpoint/CollectionAction.php diff --git a/src/Endpoint/CollectionAction.php b/src/Endpoint/CollectionAction.php new file mode 100644 index 0000000..0310976 --- /dev/null +++ b/src/Endpoint/CollectionAction.php @@ -0,0 +1,74 @@ +method = strtoupper($method); + + return $this; + } + + public function handle(Context $context): ?ResponseInterface + { + $segments = explode('/', $context->path()); + + if (count($segments) !== 2 || $segments[1] !== $this->name) { + return null; + } + + if ($context->request->getMethod() !== $this->method) { + throw new MethodNotAllowedException(); + } + + if (!$this->isVisible($context)) { + throw new ForbiddenException(); + } + + ($this->handler)($context); + + return new Response(204); + } + + public function getOpenApiPaths(Collection $collection): array + { + return [ + "/{$collection->name()}/$this->name" => [ + 'post' => [ + 'description' => $this->getDescription(), + 'tags' => [$collection->name()], + 'responses' => [ + '204' => [], + ], + ], + ], + ]; + } +} From 9b64b681abfda5260ecf47597ec1e13c1a92e5fa Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 26 Oct 2024 09:33:46 +1030 Subject: [PATCH 4/4] Skip meta fields already mapped --- src/Serializer.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Serializer.php b/src/Serializer.php index b7005ac..cfeda6a 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -88,7 +88,10 @@ private function addToMap(Resource $resource, mixed $model, array $include): arr // TODO: cache foreach ($resource->meta() as $field) { - if (!$field->isVisible($context)) { + if ( + array_key_exists($field->name, $this->map[$key]['meta'] ?? []) || + !$field->isVisible($context) + ) { continue; }