From ad15ae4892418cdeda7bbcf350e61d314b6d01db Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Mon, 23 Jun 2025 10:17:46 +0100 Subject: [PATCH 01/31] Added the optional included block --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 65cf5a1..c75f5be 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -6,7 +6,7 @@ trait BuildsOpenApiPaths { - private function buildOpenApiContent(array $resources, bool $multiple = false): array + private function buildOpenApiContent(array $resources, bool $multiple = false, bool $included = true): array { $item = count($resources) === 1 ? $resources[0] : ['oneOf' => $resources]; @@ -17,6 +17,7 @@ private function buildOpenApiContent(array $resources, bool $multiple = false): 'required' => ['data'], 'properties' => [ 'data' => $multiple ? ['type' => 'array', 'items' => $item] : $item, + 'included' => $included ? ['type' => 'array'] : [], ], ], ], From 6f27fd6234c5edd9c25c936e70f05f520327b1f1 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Mon, 23 Jun 2025 17:13:23 +0100 Subject: [PATCH 02/31] Add the include path parameter to every endpoint which has relations --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 31 ++++++++++++++++++++ src/Endpoint/Create.php | 1 + src/Endpoint/Index.php | 1 + src/Endpoint/Show.php | 19 +++++++----- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index c75f5be..2811c9d 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -3,6 +3,9 @@ namespace Tobyz\JsonApiServer\Endpoint\Concerns; use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Resource\Resource; +use Tobyz\JsonApiServer\Schema\Field\Field; +use Tobyz\JsonApiServer\Schema\Field\Relationship; trait BuildsOpenApiPaths { @@ -23,4 +26,32 @@ private function buildOpenApiContent(array $resources, bool $multiple = false, b ], ]; } + + private function buildOpenApiParameters(Resource $resource): array + { + $relationshipNames = array_map( + fn(Relationship $relationship) => $relationship->name, + array_filter( + $resource->fields(), + fn(Field $field) => $field instanceof Relationship && $field->includable, + ), + ); + + if (empty($relationshipNames)) { + return []; + } + + $includes = implode(', ', $relationshipNames); + + return [ + [ + 'name' => 'include', + 'in' => 'path', + 'description' => "Available include parameters: {$includes}.", + 'schema' => [ + 'type' => 'string', + ], + ], + ]; + } } diff --git a/src/Endpoint/Create.php b/src/Endpoint/Create.php index 0ba8a1c..f6a2de7 100644 --- a/src/Endpoint/Create.php +++ b/src/Endpoint/Create.php @@ -111,6 +111,7 @@ public function getOpenApiPaths(Collection $collection): array 'post' => [ 'description' => $this->getDescription(), 'tags' => [$collection->name()], + 'parameters' => $this->buildOpenApiParameters($collection), 'requestBody' => [ 'required' => true, 'content' => $this->buildOpenApiContent( diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 32523ef..261a83c 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -180,6 +180,7 @@ public function getOpenApiPaths(Collection $collection): array 'get' => [ 'description' => $this->getDescription(), 'tags' => [$collection->name()], + 'parameters' => $this->buildOpenApiParameters($collection), 'responses' => [ '200' => [ 'content' => $this->buildOpenApiContent( diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index 6a9aca6..656ce1b 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -52,19 +52,22 @@ public function handle(Context $context): ?ResponseInterface public function getOpenApiPaths(Collection $collection): array { + $parameters = [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], + ...$this->buildOpenApiParameters($collection), + ]; + return [ "/{$collection->name()}/{id}" => [ 'get' => [ 'description' => $this->getDescription(), 'tags' => [$collection->name()], - 'parameters' => [ - [ - 'name' => 'id', - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], - ], - ], + 'parameters' => $parameters, 'responses' => [ '200' => [ 'content' => $this->buildOpenApiContent( From ef1747bd6a1d2a0cf533e31e5e2d912e0cc512cd Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Mon, 23 Jun 2025 17:22:51 +0100 Subject: [PATCH 03/31] Typo within the parameter location --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 2811c9d..a67c037 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -46,7 +46,7 @@ private function buildOpenApiParameters(Resource $resource): array return [ [ 'name' => 'include', - 'in' => 'path', + 'in' => 'query', 'description' => "Available include parameters: {$includes}.", 'schema' => [ 'type' => 'string', From 635686ef95abdcbe28f3fbc8b680ad42313ca205 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Mon, 23 Jun 2025 17:45:04 +0100 Subject: [PATCH 04/31] Added a GeneratorInterface to allow for decorator patten usage --- src/OpenApi/Decorator.php | 10 ++++++++++ src/OpenApi/GeneratorInterface.php | 10 ++++++++++ src/OpenApi/OpenApiGenerator.php | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/OpenApi/Decorator.php create mode 100644 src/OpenApi/GeneratorInterface.php diff --git a/src/OpenApi/Decorator.php b/src/OpenApi/Decorator.php new file mode 100644 index 0000000..119ed18 --- /dev/null +++ b/src/OpenApi/Decorator.php @@ -0,0 +1,10 @@ + Date: Wed, 25 Jun 2025 13:24:44 +0100 Subject: [PATCH 05/31] Add the pagination parameters to all endpoints --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index a67c037..8a00792 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -28,6 +28,14 @@ private function buildOpenApiContent(array $resources, bool $multiple = false, b } private function buildOpenApiParameters(Resource $resource): array + { + return [ + ...$this->buildIncludeParameter($resource), + ...$this->buildPaginationParameters($resource), + ]; + } + + private function buildIncludeParameter(Resource $resource): array { $relationshipNames = array_map( fn(Relationship $relationship) => $relationship->name, @@ -54,4 +62,26 @@ private function buildOpenApiParameters(Resource $resource): array ], ]; } + + private function buildPaginationParameters(Resource $resource): array + { + return [ + [ + 'name' => 'page[limit]', + 'in' => 'query', + 'description' => "The limit pagination field.", + 'schema' => [ + 'type' => 'number', + ], + ], + [ + 'name' => 'page[offset]', + 'in' => 'query', + 'description' => "The offset pagination field.", + 'schema' => [ + 'type' => 'number', + ], + ], + ]; + } } From faaa315f06f441aeadc85570d13f68eaf4fbebbd Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 26 Jun 2025 12:41:19 +0100 Subject: [PATCH 06/31] Added the .idea to the .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 12723e9..f8b6f7b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ node_modules .phpunit.result.cache docs/.vitepress/dist docs/.vitepress/cache +.idea/* From 6d301e8053252d6fdb637b801f58f3ec043f1303 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 26 Jun 2025 12:50:46 +0100 Subject: [PATCH 07/31] Add the ability to add a summary to the Open API doc --- src/Endpoint/CollectionAction.php | 3 +++ src/Endpoint/Create.php | 3 +++ src/Endpoint/Delete.php | 3 +++ src/Endpoint/Index.php | 3 +++ src/Endpoint/ResourceAction.php | 3 +++ src/Endpoint/Show.php | 3 +++ src/Endpoint/Update.php | 3 +++ src/Schema/Concerns/HasSummary.php | 23 +++++++++++++++++++++++ 8 files changed, 44 insertions(+) create mode 100644 src/Schema/Concerns/HasSummary.php diff --git a/src/Endpoint/CollectionAction.php b/src/Endpoint/CollectionAction.php index 0310976..b972653 100644 --- a/src/Endpoint/CollectionAction.php +++ b/src/Endpoint/CollectionAction.php @@ -11,11 +11,13 @@ use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider; use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; +use Tobyz\JsonApiServer\Schema\Concerns\HasSummary; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; class CollectionAction implements Endpoint, OpenApiPathsProvider { use HasVisibility; + use HasSummary; use HasDescription; public string $method = 'POST'; @@ -62,6 +64,7 @@ public function getOpenApiPaths(Collection $collection): array return [ "/{$collection->name()}/$this->name" => [ 'post' => [ + 'summary' => $this->getSummary(), 'description' => $this->getDescription(), 'tags' => [$collection->name()], 'responses' => [ diff --git a/src/Endpoint/Create.php b/src/Endpoint/Create.php index f6a2de7..d708079 100644 --- a/src/Endpoint/Create.php +++ b/src/Endpoint/Create.php @@ -14,6 +14,7 @@ use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Creatable; use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; +use Tobyz\JsonApiServer\Schema\Concerns\HasSummary; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use function Tobyz\JsonApiServer\has_value; @@ -25,6 +26,7 @@ class Create implements Endpoint, OpenApiPathsProvider use HasVisibility; use SavesData; use ShowsResources; + use HasSummary; use HasDescription; use BuildsOpenApiPaths; @@ -109,6 +111,7 @@ public function getOpenApiPaths(Collection $collection): array return [ "/{$collection->name()}" => [ 'post' => [ + 'summary' => $this->getSummary(), 'description' => $this->getDescription(), 'tags' => [$collection->name()], 'parameters' => $this->buildOpenApiParameters($collection), diff --git a/src/Endpoint/Delete.php b/src/Endpoint/Delete.php index f8b20c5..f2c1ba1 100644 --- a/src/Endpoint/Delete.php +++ b/src/Endpoint/Delete.php @@ -14,6 +14,7 @@ use Tobyz\JsonApiServer\Resource\Deletable; use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; +use Tobyz\JsonApiServer\Schema\Concerns\HasSummary; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use function Tobyz\JsonApiServer\json_api_response; @@ -23,6 +24,7 @@ class Delete implements Endpoint, OpenApiPathsProvider use HasMeta; use HasVisibility; use FindsResources; + use HasSummary; use HasDescription; public static function make(): static @@ -72,6 +74,7 @@ public function getOpenApiPaths(Collection $collection): array return [ "/{$collection->name()}/{id}" => [ 'delete' => [ + 'summary' => $this->getSummary(), 'description' => $this->getDescription(), 'tags' => [$collection->name()], 'parameters' => [ diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 261a83c..b6684e2 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -19,6 +19,7 @@ use Tobyz\JsonApiServer\Resource\Listable; use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; +use Tobyz\JsonApiServer\Schema\Concerns\HasSummary; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use Tobyz\JsonApiServer\Serializer; @@ -31,6 +32,7 @@ class Index implements Endpoint, OpenApiPathsProvider use HasMeta; use HasVisibility; use IncludesData; + use HasSummary; use HasDescription; use BuildsOpenApiPaths; @@ -178,6 +180,7 @@ public function getOpenApiPaths(Collection $collection): array return [ "/{$collection->name()}" => [ 'get' => [ + 'summary' => $this->getSummary(), 'description' => $this->getDescription(), 'tags' => [$collection->name()], 'parameters' => $this->buildOpenApiParameters($collection), diff --git a/src/Endpoint/ResourceAction.php b/src/Endpoint/ResourceAction.php index df02cff..cb75eb9 100644 --- a/src/Endpoint/ResourceAction.php +++ b/src/Endpoint/ResourceAction.php @@ -13,6 +13,7 @@ use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider; use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; +use Tobyz\JsonApiServer\Schema\Concerns\HasSummary; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use function Tobyz\JsonApiServer\json_api_response; @@ -20,6 +21,7 @@ class ResourceAction implements Endpoint, OpenApiPathsProvider { use HasVisibility; + use HasSummary; use HasDescription; use FindsResources; use ShowsResources; @@ -75,6 +77,7 @@ public function getOpenApiPaths(Collection $collection): array return [ "/{$collection->name()}/{id}/{$this->name}" => [ strtolower($this->method) => [ + 'summary' => $this->getSummary(), 'description' => $this->getDescription(), 'tags' => [$collection->name()], 'parameters' => [ diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index 656ce1b..9b5f5ee 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -12,6 +12,7 @@ use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider; use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; +use Tobyz\JsonApiServer\Schema\Concerns\HasSummary; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use function Tobyz\JsonApiServer\json_api_response; @@ -21,6 +22,7 @@ class Show implements Endpoint, OpenApiPathsProvider use HasVisibility; use FindsResources; use ShowsResources; + use HasSummary; use HasDescription; use BuildsOpenApiPaths; @@ -65,6 +67,7 @@ public function getOpenApiPaths(Collection $collection): array return [ "/{$collection->name()}/{id}" => [ 'get' => [ + 'summary' => $this->getSummary(), 'description' => $this->getDescription(), 'tags' => [$collection->name()], 'parameters' => $parameters, diff --git a/src/Endpoint/Update.php b/src/Endpoint/Update.php index 838fd8f..fa9baa9 100644 --- a/src/Endpoint/Update.php +++ b/src/Endpoint/Update.php @@ -15,6 +15,7 @@ use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Updatable; use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; +use Tobyz\JsonApiServer\Schema\Concerns\HasSummary; use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; use function Tobyz\JsonApiServer\json_api_response; @@ -25,6 +26,7 @@ class Update implements Endpoint, OpenApiPathsProvider use FindsResources; use SavesData; use ShowsResources; + use HasSummary; use HasDescription; use BuildsOpenApiPaths; @@ -82,6 +84,7 @@ public function getOpenApiPaths(Collection $collection): array return [ "/{$collection->name()}/{id}" => [ 'patch' => [ + 'summary' => $this->getSummary(), 'description' => $this->getDescription(), 'tags' => [$collection->name()], 'parameters' => [ diff --git a/src/Schema/Concerns/HasSummary.php b/src/Schema/Concerns/HasSummary.php new file mode 100644 index 0000000..480af0a --- /dev/null +++ b/src/Schema/Concerns/HasSummary.php @@ -0,0 +1,23 @@ +summary = $summary; + + return $this; + } + + public function getSummary(): ?string + { + return $this->summary; + } +} From 51fb507a2b2bff6ffa58c409f34e7973898d3d64 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 26 Jun 2025 13:42:21 +0100 Subject: [PATCH 08/31] Only add the pagination query params when the endpoint can be paginated --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 25 ++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 8a00792..67e5e1b 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -29,10 +29,13 @@ private function buildOpenApiContent(array $resources, bool $multiple = false, b private function buildOpenApiParameters(Resource $resource): array { - return [ - ...$this->buildIncludeParameter($resource), - ...$this->buildPaginationParameters($resource), - ]; + $parameters = [$this->buildIncludeParameter($resource)]; + + if (property_exists($this, 'paginationResolver')) { + $parameters = array_merge_recursive($parameters, $this->buildPaginatableParameters()); + } + + return $parameters; } private function buildIncludeParameter(Resource $resource): array @@ -52,18 +55,16 @@ private function buildIncludeParameter(Resource $resource): array $includes = implode(', ', $relationshipNames); return [ - [ - 'name' => 'include', - 'in' => 'query', - 'description' => "Available include parameters: {$includes}.", - 'schema' => [ - 'type' => 'string', - ], + 'name' => 'include', + 'in' => 'query', + 'description' => "Available include parameters: {$includes}.", + 'schema' => [ + 'type' => 'string', ], ]; } - private function buildPaginationParameters(Resource $resource): array + private function buildPaginatableParameters(): array { return [ [ From 8da719ec5a7845f42ee3d690d52208e945dafc4c Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 26 Jun 2025 13:57:00 +0100 Subject: [PATCH 09/31] Added descriptions to the response blocks to form a valid doc --- src/Endpoint/CollectionAction.php | 5 ++++- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 8 ++++++-- src/Endpoint/Create.php | 1 + src/Endpoint/Delete.php | 5 ++++- src/Endpoint/Index.php | 1 + src/Endpoint/ResourceAction.php | 1 + src/Endpoint/Show.php | 1 + src/Endpoint/Update.php | 1 + src/OpenApi/OpenApiPathsProvider.php | 1 + 9 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Endpoint/CollectionAction.php b/src/Endpoint/CollectionAction.php index b972653..d7c205f 100644 --- a/src/Endpoint/CollectionAction.php +++ b/src/Endpoint/CollectionAction.php @@ -68,7 +68,10 @@ public function getOpenApiPaths(Collection $collection): array 'description' => $this->getDescription(), 'tags' => [$collection->name()], 'responses' => [ - '204' => [], + '204' => [ + 'description' => 'No Content', + 'content' => [], + ], ], ], ], diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 67e5e1b..06d7444 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -3,6 +3,7 @@ namespace Tobyz\JsonApiServer\Endpoint\Concerns; use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Resource; use Tobyz\JsonApiServer\Schema\Field\Field; use Tobyz\JsonApiServer\Schema\Field\Relationship; @@ -27,9 +28,12 @@ private function buildOpenApiContent(array $resources, bool $multiple = false, b ]; } - private function buildOpenApiParameters(Resource $resource): array + private function buildOpenApiParameters(Collection $collection): array { - $parameters = [$this->buildIncludeParameter($resource)]; + // @todo: fix this + assert($collection instanceof Resource); + + $parameters = [$this->buildIncludeParameter($collection)]; if (property_exists($this, 'paginationResolver')) { $parameters = array_merge_recursive($parameters, $this->buildPaginatableParameters()); diff --git a/src/Endpoint/Create.php b/src/Endpoint/Create.php index d708079..1143041 100644 --- a/src/Endpoint/Create.php +++ b/src/Endpoint/Create.php @@ -128,6 +128,7 @@ public function getOpenApiPaths(Collection $collection): array ], 'responses' => [ '200' => [ + 'description' => 'Resource created successfully.', 'content' => $this->buildOpenApiContent( array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], diff --git a/src/Endpoint/Delete.php b/src/Endpoint/Delete.php index f2c1ba1..834cd68 100644 --- a/src/Endpoint/Delete.php +++ b/src/Endpoint/Delete.php @@ -86,7 +86,10 @@ public function getOpenApiPaths(Collection $collection): array ], ], 'responses' => [ - '204' => [], + '204' => [ + 'description' => 'No Content', + 'content' => [], + ], ], ], ], diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index b6684e2..1482676 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -186,6 +186,7 @@ public function getOpenApiPaths(Collection $collection): array 'parameters' => $this->buildOpenApiParameters($collection), 'responses' => [ '200' => [ + 'description' => 'Successful list all response.', 'content' => $this->buildOpenApiContent( array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], diff --git a/src/Endpoint/ResourceAction.php b/src/Endpoint/ResourceAction.php index cb75eb9..10f6337 100644 --- a/src/Endpoint/ResourceAction.php +++ b/src/Endpoint/ResourceAction.php @@ -90,6 +90,7 @@ public function getOpenApiPaths(Collection $collection): array ], 'responses' => [ '200' => [ + 'description' => 'Successful custom action response.', 'content' => $this->buildOpenApiContent( array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index 9b5f5ee..5f61e00 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -73,6 +73,7 @@ public function getOpenApiPaths(Collection $collection): array 'parameters' => $parameters, 'responses' => [ '200' => [ + 'description' => 'Successful show response.', 'content' => $this->buildOpenApiContent( array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], diff --git a/src/Endpoint/Update.php b/src/Endpoint/Update.php index fa9baa9..a0efebe 100644 --- a/src/Endpoint/Update.php +++ b/src/Endpoint/Update.php @@ -108,6 +108,7 @@ public function getOpenApiPaths(Collection $collection): array ], 'responses' => [ '200' => [ + 'description' => 'Successful update response.', 'content' => $this->buildOpenApiContent( array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], diff --git a/src/OpenApi/OpenApiPathsProvider.php b/src/OpenApi/OpenApiPathsProvider.php index b066adf..023ec47 100644 --- a/src/OpenApi/OpenApiPathsProvider.php +++ b/src/OpenApi/OpenApiPathsProvider.php @@ -3,6 +3,7 @@ namespace Tobyz\JsonApiServer\OpenApi; use Tobyz\JsonApiServer\Resource\Collection; +use Tobyz\JsonApiServer\Resource\Resource; interface OpenApiPathsProvider { From 5eaafc5154d8ca8100571446787d4b1f229ce3d3 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 26 Jun 2025 14:27:23 +0100 Subject: [PATCH 10/31] Added an array filter to strip out empty parameters --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 06d7444..a7d85be 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -30,7 +30,7 @@ private function buildOpenApiContent(array $resources, bool $multiple = false, b private function buildOpenApiParameters(Collection $collection): array { - // @todo: fix this + // @todo: fix this assert($collection instanceof Resource); $parameters = [$this->buildIncludeParameter($collection)]; @@ -39,7 +39,7 @@ private function buildOpenApiParameters(Collection $collection): array $parameters = array_merge_recursive($parameters, $this->buildPaginatableParameters()); } - return $parameters; + return array_filter($parameters); } private function buildIncludeParameter(Resource $resource): array From 68c7e9ce04dd2e147285372f2afbb3d7b964b9f7 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 26 Jun 2025 15:22:12 +0100 Subject: [PATCH 11/31] Remove the content if 204 no content --- src/Endpoint/CollectionAction.php | 1 - src/Endpoint/Delete.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Endpoint/CollectionAction.php b/src/Endpoint/CollectionAction.php index d7c205f..3b2a429 100644 --- a/src/Endpoint/CollectionAction.php +++ b/src/Endpoint/CollectionAction.php @@ -70,7 +70,6 @@ public function getOpenApiPaths(Collection $collection): array 'responses' => [ '204' => [ 'description' => 'No Content', - 'content' => [], ], ], ], diff --git a/src/Endpoint/Delete.php b/src/Endpoint/Delete.php index 834cd68..a68d0db 100644 --- a/src/Endpoint/Delete.php +++ b/src/Endpoint/Delete.php @@ -88,7 +88,6 @@ public function getOpenApiPaths(Collection $collection): array 'responses' => [ '204' => [ 'description' => 'No Content', - 'content' => [], ], ], ], From 4e0399d146bbb3eade644d91ca12072882e6aeec Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 26 Jun 2025 15:28:32 +0100 Subject: [PATCH 12/31] Only add the pagination parameters if a valid pagination resolver has been passed (e.g. ->paginate()) --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index a7d85be..dd7f24e 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -2,6 +2,8 @@ namespace Tobyz\JsonApiServer\Endpoint\Concerns; +use ReflectionException; +use ReflectionFunction; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Resource; @@ -28,6 +30,9 @@ private function buildOpenApiContent(array $resources, bool $multiple = false, b ]; } + /** + * @throws ReflectionException + */ private function buildOpenApiParameters(Collection $collection): array { // @todo: fix this @@ -36,7 +41,12 @@ private function buildOpenApiParameters(Collection $collection): array $parameters = [$this->buildIncludeParameter($collection)]; if (property_exists($this, 'paginationResolver')) { - $parameters = array_merge_recursive($parameters, $this->buildPaginatableParameters()); + $resolver = $this->paginationResolver; + $reflection = new ReflectionFunction($resolver); + + if ($reflection->getNumberOfRequiredParameters() > 0) { + $parameters = array_merge_recursive($parameters, $this->buildPaginatableParameters()); + } } return array_filter($parameters); From 589122b2324adb472c3dcb796b880b994280d317 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 26 Jun 2025 16:10:03 +0100 Subject: [PATCH 13/31] Updated the Show endpoint Open API docs --- src/Endpoint/Show.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index 5f61e00..7617fdd 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -54,15 +54,17 @@ public function handle(Context $context): ?ResponseInterface public function getOpenApiPaths(Collection $collection): array { - $parameters = [ + $parameters = array_merge( [ - 'name' => 'id', - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], ], - ...$this->buildOpenApiParameters($collection), - ]; + $this->buildOpenApiParameters($collection) + ); return [ "/{$collection->name()}/{id}" => [ From a827d6311e4065b299df8a6ed347e1ea5e15d05b Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 26 Jun 2025 16:13:47 +0100 Subject: [PATCH 14/31] Added an array values to reset the keys so that an array is created rather than keyed object --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index dd7f24e..7906f3b 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -49,7 +49,7 @@ private function buildOpenApiParameters(Collection $collection): array } } - return array_filter($parameters); + return array_values(array_filter($parameters)); } private function buildIncludeParameter(Resource $resource): array From c1bea6c4b6d9f611c3be0df4a2fe7c1604e3e7dc Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Fri, 27 Jun 2025 09:29:16 +0100 Subject: [PATCH 15/31] Added some error response methods --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 103 +++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 7906f3b..f2801b1 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -99,4 +99,107 @@ private function buildPaginatableParameters(): array ], ]; } + + public function buildBadRequestErrorResponse(): array + { + return $this->buildErrorResponse( + 'A bad request.', + 400, + 'Bad Request', + 'Please try again with a valid request.', + ); + } + + public function buildUnauthorizedErrorResponse(): array + { + return $this->buildErrorResponse( + 'An unauthorised error.', + 401, + 'Unauthorized', + 'Please login and try again.', + ); + } + + public function buildForbiddenErrorResponse(): array + { + return $this->buildErrorResponse( + 'A forbidden error.', + 403, + 'Forbidden', + ); + } + + public function buildNotFoundErrorResponse(): array + { + return $this->buildErrorResponse( + 'A bad request.', + 404, + 'Not Found', + 'The requested resource could not be found.', + ); + } + + public function buildInternalServerErrorResponse(): array + { + return $this->buildErrorResponse( + 'A bad request.', + 500, + 'Internal Server Error', + 'Please try again later.', + ); + } + + public function buildErrorResponse(string $description, int $status, string $title, ?string $detail = null): array + { + return [ + 'description' => $description, + 'content' => [ + JsonApi::MEDIA_TYPE => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'errors' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'required' => [ + 'status', + 'title', + ], + 'properties' => array_filter([ + 'status' => [ + 'type' => 'string', + 'example' => (string)$status, + ], + 'title' => [ + 'type' => 'string', + 'example' => $title, + ], + 'detail' => [ + 'type' => 'string', + 'example' => $detail, + ], + 'source' => [ + 'type' => 'object', + 'properties' => [ + 'pointer' => [ + 'type' => 'string', + ], + 'parameter' => [ + 'type' => 'string', + ], + 'header' => [ + 'type' => 'string', + ], + ], + ], + ]), + ], + ], + ], + ], + ], + ], + ]; + } } From 4655da009a6d5b53d5418da021a75c68cd0e86ae Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Fri, 27 Jun 2025 09:32:56 +0100 Subject: [PATCH 16/31] Use the new error methods within the endpoints --- src/Endpoint/CollectionAction.php | 6 ++++++ src/Endpoint/Create.php | 5 +++++ src/Endpoint/Delete.php | 7 +++++++ src/Endpoint/Index.php | 4 ++++ src/Endpoint/ResourceAction.php | 5 +++++ src/Endpoint/Show.php | 5 +++++ src/Endpoint/Update.php | 5 +++++ 7 files changed, 37 insertions(+) diff --git a/src/Endpoint/CollectionAction.php b/src/Endpoint/CollectionAction.php index 3b2a429..87c2fdb 100644 --- a/src/Endpoint/CollectionAction.php +++ b/src/Endpoint/CollectionAction.php @@ -6,6 +6,7 @@ use Nyholm\Psr7\Response; use Psr\Http\Message\ResponseInterface; use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsOpenApiPaths; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider; @@ -19,6 +20,7 @@ class CollectionAction implements Endpoint, OpenApiPathsProvider use HasVisibility; use HasSummary; use HasDescription; + use BuildsOpenApiPaths; public string $method = 'POST'; @@ -71,6 +73,10 @@ public function getOpenApiPaths(Collection $collection): array '204' => [ 'description' => 'No Content', ], + '400' => $this->buildBadRequestErrorResponse(), + '401' => $this->buildUnauthorizedErrorResponse(), + '403' => $this->buildForbiddenErrorResponse(), + '500' => $this->buildInternalServerErrorResponse(), ], ], ], diff --git a/src/Endpoint/Create.php b/src/Endpoint/Create.php index 1143041..715af1c 100644 --- a/src/Endpoint/Create.php +++ b/src/Endpoint/Create.php @@ -136,6 +136,11 @@ public function getOpenApiPaths(Collection $collection): array ), ), ], + '400' => $this->buildBadRequestErrorResponse(), + '401' => $this->buildUnauthorizedErrorResponse(), + '403' => $this->buildForbiddenErrorResponse(), + '404' => $this->buildNotFoundErrorResponse(), + '500' => $this->buildInternalServerErrorResponse(), ], ], ], diff --git a/src/Endpoint/Delete.php b/src/Endpoint/Delete.php index a68d0db..a55eb16 100644 --- a/src/Endpoint/Delete.php +++ b/src/Endpoint/Delete.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ResponseInterface; use RuntimeException; use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsOpenApiPaths; use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; @@ -26,6 +27,7 @@ class Delete implements Endpoint, OpenApiPathsProvider use FindsResources; use HasSummary; use HasDescription; + use BuildsOpenApiPaths; public static function make(): static { @@ -89,6 +91,11 @@ public function getOpenApiPaths(Collection $collection): array '204' => [ 'description' => 'No Content', ], + '400' => $this->buildBadRequestErrorResponse(), + '401' => $this->buildUnauthorizedErrorResponse(), + '403' => $this->buildForbiddenErrorResponse(), + '404' => $this->buildNotFoundErrorResponse(), + '500' => $this->buildInternalServerErrorResponse(), ], ], ], diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 1482676..90b31bb 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -195,6 +195,10 @@ public function getOpenApiPaths(Collection $collection): array multiple: true, ), ], + '400' => $this->buildBadRequestErrorResponse(), + '401' => $this->buildUnauthorizedErrorResponse(), + '403' => $this->buildForbiddenErrorResponse(), + '500' => $this->buildInternalServerErrorResponse(), ], ], ], diff --git a/src/Endpoint/ResourceAction.php b/src/Endpoint/ResourceAction.php index 10f6337..a4823f3 100644 --- a/src/Endpoint/ResourceAction.php +++ b/src/Endpoint/ResourceAction.php @@ -98,6 +98,11 @@ public function getOpenApiPaths(Collection $collection): array ), ), ], + '400' => $this->buildBadRequestErrorResponse(), + '401' => $this->buildUnauthorizedErrorResponse(), + '403' => $this->buildForbiddenErrorResponse(), + '404' => $this->buildNotFoundErrorResponse(), + '500' => $this->buildInternalServerErrorResponse(), ], ], ], diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index 7617fdd..69eaf84 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -83,6 +83,11 @@ public function getOpenApiPaths(Collection $collection): array ), ), ], + '400' => $this->buildBadRequestErrorResponse(), + '401' => $this->buildUnauthorizedErrorResponse(), + '403' => $this->buildForbiddenErrorResponse(), + '404' => $this->buildNotFoundErrorResponse(), + '500' => $this->buildInternalServerErrorResponse(), ], ], ], diff --git a/src/Endpoint/Update.php b/src/Endpoint/Update.php index a0efebe..782c6a6 100644 --- a/src/Endpoint/Update.php +++ b/src/Endpoint/Update.php @@ -116,6 +116,11 @@ public function getOpenApiPaths(Collection $collection): array ), ), ], + '400' => $this->buildBadRequestErrorResponse(), + '401' => $this->buildUnauthorizedErrorResponse(), + '403' => $this->buildForbiddenErrorResponse(), + '404' => $this->buildNotFoundErrorResponse(), + '500' => $this->buildInternalServerErrorResponse(), ], ], ], From 39d27677dc8df19e72bf4049f34fcad5be526722 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Fri, 27 Jun 2025 13:30:20 +0100 Subject: [PATCH 17/31] Added in links to the open api generation --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 71 ++++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index f2801b1..d102ba4 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -12,8 +12,12 @@ trait BuildsOpenApiPaths { - private function buildOpenApiContent(array $resources, bool $multiple = false, bool $included = true): array - { + private function buildOpenApiContent( + array $resources, + bool $multiple = false, + bool $included = true, + bool $links = false, + ): array { $item = count($resources) === 1 ? $resources[0] : ['oneOf' => $resources]; return [ @@ -21,15 +25,74 @@ private function buildOpenApiContent(array $resources, bool $multiple = false, b 'schema' => [ 'type' => 'object', 'required' => ['data'], - 'properties' => [ + 'properties' => array_filter([ + 'links' => $links ? $this->buildLinksObject($item) : [], 'data' => $multiple ? ['type' => 'array', 'items' => $item] : $item, 'included' => $included ? ['type' => 'array'] : [], - ], + ]), ], ], ]; } + private function buildLinksObject(array $item): array + { + // @todo: maybe pull in the API or Context to return a server name? + $baseUri = sprintf('https://{server}/%s', $this->findResourceFromItem($item)); + $defaultQuery = ['page[limit]' => 10]; + + $links = [ + 'self' => ['page[offset]' => 2], + 'first' => ['page[offset]' => 1], + 'prev' => ['page[offset]' => 1], + 'next' => ['page[offset]' => 3], + 'last' => ['page[offset]' => 10], + ]; + + foreach ($links as $key => $params) { + $params = $params + $defaultQuery; + + $query = implode( + '&', + array_map( + fn($k, $v) => $k . '=' . urlencode($v), + array_keys($params), + $params, + ) + ); + + $links[$key] = sprintf('%s/%s', $baseUri, $query); + } + + return [ + 'type' => 'object', + 'properties' => [ + 'self' => [ + 'type' => 'string', + 'example' => array_map(function (string $uri) { + return [ + 'type' => 'string', + 'example' => $uri, + ]; + }, $links), + ], + ], + ]; + } + + private function findResourceFromItem(array $item) + { + $value = $item['$ref'] ?? null; + + if (empty($value)) { + dd($value); + } + + $parts = explode('/', $value); + + return end($parts); + } + /** * @throws ReflectionException */ From 75a56d7bd7d28bc6f74d4c205853aa3f879925a7 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Fri, 27 Jun 2025 13:31:24 +0100 Subject: [PATCH 18/31] Added a return type plus exception instead of Laravel's dd() --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index d102ba4..59cdc49 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -4,6 +4,7 @@ use ReflectionException; use ReflectionFunction; +use RuntimeException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Resource; @@ -80,12 +81,12 @@ private function buildLinksObject(array $item): array ]; } - private function findResourceFromItem(array $item) + private function findResourceFromItem(array $item): string { $value = $item['$ref'] ?? null; if (empty($value)) { - dd($value); + throw new RuntimeException('Unhandled.'); } $parts = explode('/', $value); From ff88b051a392987a6f77a39fee982c5cd6d1a2b8 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Fri, 27 Jun 2025 13:36:21 +0100 Subject: [PATCH 19/31] Add links = true to allow for pagination links to be included --- src/Endpoint/Index.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 90b31bb..2898b13 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -4,6 +4,7 @@ use Closure; use Psr\Http\Message\ResponseInterface as Response; +use ReflectionException; use RuntimeException; use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsOpenApiPaths; @@ -175,6 +176,9 @@ private function applyFilters($query, Context $context): void } } + /** + * @throws ReflectionException + */ public function getOpenApiPaths(Collection $collection): array { return [ @@ -193,6 +197,7 @@ public function getOpenApiPaths(Collection $collection): array $collection->resources(), ), multiple: true, + links: true, ), ], '400' => $this->buildBadRequestErrorResponse(), From 59f541a7daa45456505aeee323b1795e4577e8f3 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Fri, 27 Jun 2025 14:05:27 +0100 Subject: [PATCH 20/31] Fixed a bug which prevented the links from being generated properly --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 59cdc49..51929c2 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -67,17 +67,12 @@ private function buildLinksObject(array $item): array return [ 'type' => 'object', - 'properties' => [ - 'self' => [ + 'properties' => array_map(function (string $uri) { + return [ 'type' => 'string', - 'example' => array_map(function (string $uri) { - return [ - 'type' => 'string', - 'example' => $uri, - ]; - }, $links), - ], - ], + 'example' => $uri, + ]; + }, $links), ]; } From 5b215f08ea363cd3a5c8fbe30347ce88fcbd0cbe Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Mon, 30 Jun 2025 13:11:10 +0100 Subject: [PATCH 21/31] Added the ability to pass the collection name to create collection docs properly --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 7 ++++--- src/Endpoint/Create.php | 2 ++ src/Endpoint/Index.php | 1 + src/Endpoint/ResourceAction.php | 1 + src/Endpoint/Show.php | 1 + src/Endpoint/Update.php | 2 ++ 6 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 51929c2..1a3f12a 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -14,6 +14,7 @@ trait BuildsOpenApiPaths { private function buildOpenApiContent( + string $name, array $resources, bool $multiple = false, bool $included = true, @@ -27,7 +28,7 @@ private function buildOpenApiContent( 'type' => 'object', 'required' => ['data'], 'properties' => array_filter([ - 'links' => $links ? $this->buildLinksObject($item) : [], + 'links' => $links ? $this->buildLinksObject($name) : [], 'data' => $multiple ? ['type' => 'array', 'items' => $item] : $item, 'included' => $included ? ['type' => 'array'] : [], ]), @@ -36,10 +37,10 @@ private function buildOpenApiContent( ]; } - private function buildLinksObject(array $item): array + private function buildLinksObject(string $name): array { // @todo: maybe pull in the API or Context to return a server name? - $baseUri = sprintf('https://{server}/%s', $this->findResourceFromItem($item)); + $baseUri = sprintf('https://{server}/%s', $name); $defaultQuery = ['page[limit]' => 10]; $links = [ diff --git a/src/Endpoint/Create.php b/src/Endpoint/Create.php index 715af1c..8d6aaea 100644 --- a/src/Endpoint/Create.php +++ b/src/Endpoint/Create.php @@ -118,6 +118,7 @@ public function getOpenApiPaths(Collection $collection): array 'requestBody' => [ 'required' => true, 'content' => $this->buildOpenApiContent( + $collection->name(), array_map( fn($resource) => [ '$ref' => "#/components/schemas/{$resource}Create", @@ -130,6 +131,7 @@ public function getOpenApiPaths(Collection $collection): array '200' => [ 'description' => 'Resource created successfully.', 'content' => $this->buildOpenApiContent( + $collection->name(), array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], $collection->resources(), diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 2898b13..9824d5a 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -192,6 +192,7 @@ public function getOpenApiPaths(Collection $collection): array '200' => [ 'description' => 'Successful list all response.', 'content' => $this->buildOpenApiContent( + $collection->name(), array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], $collection->resources(), diff --git a/src/Endpoint/ResourceAction.php b/src/Endpoint/ResourceAction.php index a4823f3..e5e9468 100644 --- a/src/Endpoint/ResourceAction.php +++ b/src/Endpoint/ResourceAction.php @@ -92,6 +92,7 @@ public function getOpenApiPaths(Collection $collection): array '200' => [ 'description' => 'Successful custom action response.', 'content' => $this->buildOpenApiContent( + $collection->name(), array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], $collection->resources(), diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index 69eaf84..d6afb4c 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -77,6 +77,7 @@ public function getOpenApiPaths(Collection $collection): array '200' => [ 'description' => 'Successful show response.', 'content' => $this->buildOpenApiContent( + $collection->name(), array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], $collection->resources(), diff --git a/src/Endpoint/Update.php b/src/Endpoint/Update.php index 782c6a6..3680edb 100644 --- a/src/Endpoint/Update.php +++ b/src/Endpoint/Update.php @@ -98,6 +98,7 @@ public function getOpenApiPaths(Collection $collection): array 'requestBody' => [ 'required' => true, 'content' => $this->buildOpenApiContent( + $collection->name(), array_map( fn($resource) => [ '$ref' => "#/components/schemas/{$resource}Update", @@ -110,6 +111,7 @@ public function getOpenApiPaths(Collection $collection): array '200' => [ 'description' => 'Successful update response.', 'content' => $this->buildOpenApiContent( + $collection->name(), array_map( fn($resource) => ['$ref' => "#/components/schemas/$resource"], $collection->resources(), From 5645a852d0796b7f356d22a6dd99736e634a2abb Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Mon, 30 Jun 2025 14:35:01 +0100 Subject: [PATCH 22/31] Return the result of the handler else return 204 --- src/Endpoint/CollectionAction.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Endpoint/CollectionAction.php b/src/Endpoint/CollectionAction.php index 87c2fdb..5deec0f 100644 --- a/src/Endpoint/CollectionAction.php +++ b/src/Endpoint/CollectionAction.php @@ -56,9 +56,7 @@ public function handle(Context $context): ?ResponseInterface throw new ForbiddenException(); } - ($this->handler)($context); - - return new Response(204); + return ($this->handler)($context) ?? new Response(204); } public function getOpenApiPaths(Collection $collection): array From 3d24d488bdcea9b1ca24f3570bcc72a9bb4e7541 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 3 Jul 2025 19:16:31 +0100 Subject: [PATCH 23/31] Modified required to be a closure to allow for logic behind the required field --- src/Endpoint/Concerns/SavesData.php | 4 ++-- src/OpenApi/OpenApiGenerator.php | 4 ++-- src/Schema/Concerns/SetsValue.php | 14 +++++++++++--- src/Schema/Field/Relationship.php | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Endpoint/Concerns/SavesData.php b/src/Endpoint/Concerns/SavesData.php index 6dbf534..719f74c 100644 --- a/src/Endpoint/Concerns/SavesData.php +++ b/src/Endpoint/Concerns/SavesData.php @@ -176,7 +176,7 @@ private function assertDataValid(Context $context, array $data, bool $validateAl foreach ($context->fields($context->resource) as $field) { $empty = !has_value($data, $field); - if ($empty && (!$field->required || !$validateAll)) { + if ($empty && (!$field->isRequired($context) || !$validateAll)) { continue; } @@ -187,7 +187,7 @@ private function assertDataValid(Context $context, array $data, bool $validateAl ]; }; - if ($empty && $field->required) { + if ($empty && $field->isRequired($context)) { $fail('field is required'); } else { $field->validateValue(get_value($data, $field), $fail, $context->withField($field)); diff --git a/src/OpenApi/OpenApiGenerator.php b/src/OpenApi/OpenApiGenerator.php index 7945160..6c1818d 100644 --- a/src/OpenApi/OpenApiGenerator.php +++ b/src/OpenApi/OpenApiGenerator.php @@ -46,14 +46,14 @@ public function generate(JsonApi $api): array if ($field->writable) { $updateSchema[$location]['properties'][$field->name] = $fieldSchema; - if ($field->required) { + if ($field->isRequired()) { $updateSchema[$location]['required'][] = $field->name; } } if ($field->writableOnCreate) { $createSchema[$location]['properties'][$field->name] = $fieldSchema; - if ($field->required) { + if ($field->isRequired()) { $createSchema[$location]['required'][] = $field->name; } } diff --git a/src/Schema/Concerns/SetsValue.php b/src/Schema/Concerns/SetsValue.php index c6181e5..c334511 100644 --- a/src/Schema/Concerns/SetsValue.php +++ b/src/Schema/Concerns/SetsValue.php @@ -11,7 +11,7 @@ trait SetsValue { public ?Closure $writable = null; public ?Closure $writableOnCreate = null; - public bool $required = false; + public ?Closure $required = null; public ?Closure $default = null; public ?Closure $deserializer = null; public ?Closure $setter = null; @@ -41,9 +41,9 @@ public function writableOnCreate(?Closure $condition = null): static /** * Mark this field as required. */ - public function required(bool $required = true): static + public function required(?Closure $condition = null): static { - $this->required = $required; + $this->required = $condition ?: fn() => true; return $this; } @@ -121,6 +121,14 @@ public function isWritableOnCreate(Context $context): bool ($this->writableOnCreate && ($this->writableOnCreate)($context->model, $context)); } + /** + * Check if this field is required. + */ + public function isRequired(): bool + { + return $this->required && ($this->required)(); + } + /** * Deserialize a JSON value to an internal representation. */ diff --git a/src/Schema/Field/Relationship.php b/src/Schema/Field/Relationship.php index a5e229e..e6b1529 100644 --- a/src/Schema/Field/Relationship.php +++ b/src/Schema/Field/Relationship.php @@ -134,7 +134,7 @@ public function getSchema(JsonApi $api): array parent::getSchema($api) + [ 'type' => 'object', 'properties' => ['data' => $this->getDataSchema($api)], - 'required' => $this->required ? ['data'] : [], + 'required' => $this->isRequired() ? ['data'] : [], ]; } From 20271bc064ee5ae9849dae6b697ce4945821746d Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 3 Jul 2025 19:26:32 +0100 Subject: [PATCH 24/31] Allow writable fields to show up in the Open API 'create' schemas --- src/OpenApi/OpenApiGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/OpenApiGenerator.php b/src/OpenApi/OpenApiGenerator.php index 6c1818d..ab32c27 100644 --- a/src/OpenApi/OpenApiGenerator.php +++ b/src/OpenApi/OpenApiGenerator.php @@ -51,7 +51,7 @@ public function generate(JsonApi $api): array } } - if ($field->writableOnCreate) { + if ($field->writable || $field->writableOnCreate) { $createSchema[$location]['properties'][$field->name] = $fieldSchema; if ($field->isRequired()) { $createSchema[$location]['required'][] = $field->name; From 7f13b892edb70b8955298fa62bd2458562b80560 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Mon, 7 Jul 2025 10:46:49 +0100 Subject: [PATCH 25/31] Fixed an issue where id was required even in create requests --- src/OpenApi/OpenApiGenerator.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/OpenApi/OpenApiGenerator.php b/src/OpenApi/OpenApiGenerator.php index ab32c27..c042946 100644 --- a/src/OpenApi/OpenApiGenerator.php +++ b/src/OpenApi/OpenApiGenerator.php @@ -65,11 +65,12 @@ public function generate(JsonApi $api): array 'properties' => ['id' => ['type' => 'string', 'readOnly' => true]], ]); - $schemas["{$type}Create"] = $this->buildSchema($resource, $createSchema, [ - 'required' => ['type'], - ]); + $schemas["{$type}Create"] = $this->buildSchema($resource, $createSchema); - $schemas["{$type}Update"] = $this->buildSchema($resource, $updateSchema); + $schemas["{$type}Update"] = $this->buildSchema($resource, $updateSchema, [ + 'required' => ['type', 'id'], + 'properties' => ['id' => ['type' => 'string']], + ]); } return array_filter([ @@ -91,10 +92,9 @@ private function buildSchema(Resource $resource, array $schema, array $overrides return array_replace_recursive( [ 'type' => 'object', - 'required' => ['type', 'id'], + 'required' => ['type'], 'properties' => [ 'type' => ['type' => 'string', 'const' => $resource->type()], - 'id' => ['type' => 'string'], 'attributes' => ['type' => 'object'] + ($schema['attributes'] ?? []), 'relationships' => ['type' => 'object'] + ($schema['relationships'] ?? []), ], From a0075093af45969ef9962017754e856675533d4a Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Mon, 7 Jul 2025 17:24:10 +0100 Subject: [PATCH 26/31] Add the filters to the Open API document --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 94 +++++++++++--------- 1 file changed, 54 insertions(+), 40 deletions(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 1a3f12a..149b166 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -4,12 +4,13 @@ use ReflectionException; use ReflectionFunction; -use RuntimeException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Resource\Collection; +use Tobyz\JsonApiServer\Resource\Listable; use Tobyz\JsonApiServer\Resource\Resource; use Tobyz\JsonApiServer\Schema\Field\Field; use Tobyz\JsonApiServer\Schema\Field\Relationship; +use Tobyz\JsonApiServer\Schema\Filter; trait BuildsOpenApiPaths { @@ -77,19 +78,6 @@ private function buildLinksObject(string $name): array ]; } - private function findResourceFromItem(array $item): string - { - $value = $item['$ref'] ?? null; - - if (empty($value)) { - throw new RuntimeException('Unhandled.'); - } - - $parts = explode('/', $value); - - return end($parts); - } - /** * @throws ReflectionException */ @@ -98,16 +86,11 @@ private function buildOpenApiParameters(Collection $collection): array // @todo: fix this assert($collection instanceof Resource); - $parameters = [$this->buildIncludeParameter($collection)]; - - if (property_exists($this, 'paginationResolver')) { - $resolver = $this->paginationResolver; - $reflection = new ReflectionFunction($resolver); - - if ($reflection->getNumberOfRequiredParameters() > 0) { - $parameters = array_merge_recursive($parameters, $this->buildPaginatableParameters()); - } - } + $parameters = [ + $this->buildIncludeParameter($collection), + ...$this->buildFilterParameters($collection), + ...$this->buildPaginatableParameters(), + ]; return array_values(array_filter($parameters)); } @@ -138,26 +121,57 @@ private function buildIncludeParameter(Resource $resource): array ]; } - private function buildPaginatableParameters(): array + + private function buildFilterParameters(Resource $resource): array { - return [ - [ - 'name' => 'page[limit]', - 'in' => 'query', - 'description' => "The limit pagination field.", - 'schema' => [ - 'type' => 'number', - ], - ], - [ - 'name' => 'page[offset]', + if (!$this instanceof Listable) { + return []; + } + + return array_map(function (Filter $filter) { + return [ + 'name' => "filter[{$filter->name}]", 'in' => 'query', - 'description' => "The offset pagination field.", + 'description' => $filter->getDescription(), 'schema' => [ - 'type' => 'number', + 'type' => 'string', ], - ], - ]; + ]; + }, $resource->filters()); + } + + /** + * @throws ReflectionException + */ + private function buildPaginatableParameters(): array + { + if (property_exists($this, 'paginationResolver')) { + $resolver = $this->paginationResolver; + $reflection = new ReflectionFunction($resolver); + + if ($reflection->getNumberOfRequiredParameters() > 0) { + return [ + [ + 'name' => 'page[limit]', + 'in' => 'query', + 'description' => "The limit pagination field.", + 'schema' => [ + 'type' => 'number', + ], + ], + [ + 'name' => 'page[offset]', + 'in' => 'query', + 'description' => "The offset pagination field.", + 'schema' => [ + 'type' => 'number', + ], + ], + ]; + } + } + + return []; } public function buildBadRequestErrorResponse(): array From 3be10f0eda7310e374d5e36ef4f24abab18e99db Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Wed, 9 Jul 2025 14:01:40 +0100 Subject: [PATCH 27/31] Small bug fixing the filter parameters --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 149b166..3acf7f2 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -4,6 +4,7 @@ use ReflectionException; use ReflectionFunction; +use Tobyz\JsonApiServer\Endpoint\Index; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Listable; @@ -124,7 +125,7 @@ private function buildIncludeParameter(Resource $resource): array private function buildFilterParameters(Resource $resource): array { - if (!$this instanceof Listable) { + if (!$this instanceof Index) { return []; } From 2bb4a2cd535317f1872f6b4194b6858d8b001363 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Wed, 9 Jul 2025 14:56:17 +0100 Subject: [PATCH 28/31] Added countable and paginatable (works for now but should be re-visited) --- src/Endpoint/Concerns/BuildsOpenApiPaths.php | 25 +++++++++++++++++++- src/Endpoint/Index.php | 2 ++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Endpoint/Concerns/BuildsOpenApiPaths.php b/src/Endpoint/Concerns/BuildsOpenApiPaths.php index 3acf7f2..ae28af6 100644 --- a/src/Endpoint/Concerns/BuildsOpenApiPaths.php +++ b/src/Endpoint/Concerns/BuildsOpenApiPaths.php @@ -7,7 +7,6 @@ use Tobyz\JsonApiServer\Endpoint\Index; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Resource\Collection; -use Tobyz\JsonApiServer\Resource\Listable; use Tobyz\JsonApiServer\Resource\Resource; use Tobyz\JsonApiServer\Schema\Field\Field; use Tobyz\JsonApiServer\Schema\Field\Relationship; @@ -21,6 +20,8 @@ private function buildOpenApiContent( bool $multiple = false, bool $included = true, bool $links = false, + bool $countable = false, + bool $paginatable = false, ): array { $item = count($resources) === 1 ? $resources[0] : ['oneOf' => $resources]; @@ -33,6 +34,7 @@ private function buildOpenApiContent( 'links' => $links ? $this->buildLinksObject($name) : [], 'data' => $multiple ? ['type' => 'array', 'items' => $item] : $item, 'included' => $included ? ['type' => 'array'] : [], + 'meta' => $this->buildMetaObject($countable, $paginatable), ]), ], ], @@ -79,6 +81,27 @@ private function buildLinksObject(string $name): array ]; } + private function buildMetaObject(bool $countable, bool $paginatable): array + { + if (!($countable || $paginatable)) { + return []; + } + + return [ + 'type' => 'object', + 'properties' => [ + 'page' => [ + 'type' => 'object', + 'properties' => array_filter([ + 'total' => $countable ? ['type' => 'integer'] : [], + 'limit' => $paginatable ? ['type' => 'integer'] : [], + 'offset' => $paginatable ? ['type' => 'integer'] : [], + ]), + ], + ], + ]; + } + /** * @throws ReflectionException */ diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 9824d5a..5616895 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -199,6 +199,8 @@ public function getOpenApiPaths(Collection $collection): array ), multiple: true, links: true, + countable: true, + paginatable: true, ), ], '400' => $this->buildBadRequestErrorResponse(), From d22c6574be6994d5a48ea4775b17bc32fcbdf9de Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Thu, 10 Jul 2025 16:59:19 +0100 Subject: [PATCH 29/31] Fixed some required flags and ID to be required and readonly --- src/OpenApi/OpenApiGenerator.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/OpenApi/OpenApiGenerator.php b/src/OpenApi/OpenApiGenerator.php index c042946..730c4e9 100644 --- a/src/OpenApi/OpenApiGenerator.php +++ b/src/OpenApi/OpenApiGenerator.php @@ -62,6 +62,7 @@ public function generate(JsonApi $api): array $type = $resource->type(); $schemas[$type] = $this->buildSchema($resource, $schema, [ + 'required' => ['id'], 'properties' => ['id' => ['type' => 'string', 'readOnly' => true]], ]); @@ -89,10 +90,17 @@ public function generate(JsonApi $api): array private function buildSchema(Resource $resource, array $schema, array $overrides = []): array { + $hasAttributes = !empty($schema['attributes']); + $hasRelationships = !empty($schema['relationships']); + return array_replace_recursive( [ 'type' => 'object', - 'required' => ['type'], + 'required' => array_filter([ + 'type', + $hasAttributes ? 'attributes' : null, + $hasRelationships ? 'relationships' : null, + ]), 'properties' => [ 'type' => ['type' => 'string', 'const' => $resource->type()], 'attributes' => ['type' => 'object'] + ($schema['attributes'] ?? []), From 2bd5d0f300a412ab8c30338ae440b9559e90fc63 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Fri, 18 Jul 2025 09:28:34 +0100 Subject: [PATCH 30/31] Small bug fix to ensure required is an array and not an object --- src/OpenApi/OpenApiGenerator.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/OpenApi/OpenApiGenerator.php b/src/OpenApi/OpenApiGenerator.php index 730c4e9..edc19c6 100644 --- a/src/OpenApi/OpenApiGenerator.php +++ b/src/OpenApi/OpenApiGenerator.php @@ -96,11 +96,13 @@ private function buildSchema(Resource $resource, array $schema, array $overrides return array_replace_recursive( [ 'type' => 'object', - 'required' => array_filter([ - 'type', - $hasAttributes ? 'attributes' : null, - $hasRelationships ? 'relationships' : null, - ]), + 'required' => array_values( + array_filter([ + 'type', + $hasAttributes ? 'attributes' : null, + $hasRelationships ? 'relationships' : null, + ]) + ), 'properties' => [ 'type' => ['type' => 'string', 'const' => $resource->type()], 'attributes' => ['type' => 'object'] + ($schema['attributes'] ?? []), From 83910dd694adbeb97b223baf6d42b7945ec01488 Mon Sep 17 00:00:00 2001 From: Josh Murray Date: Fri, 25 Jul 2025 10:56:43 +0100 Subject: [PATCH 31/31] Use the Pagination interface rather than the concrete class --- src/Resource/Paginatable.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Resource/Paginatable.php b/src/Resource/Paginatable.php index 6da9d08..8021f21 100644 --- a/src/Resource/Paginatable.php +++ b/src/Resource/Paginatable.php @@ -2,12 +2,12 @@ namespace Tobyz\JsonApiServer\Resource; -use Tobyz\JsonApiServer\Pagination\OffsetPagination; +use Tobyz\JsonApiServer\Pagination\Pagination; interface Paginatable { /** * Paginate the given query. */ - public function paginate(object $query, OffsetPagination $pagination): void; + public function paginate(object $query, Pagination $pagination): void; }