From 8dbfd65ab5086e0cfbe856135bb36ae46e34581e Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Fri, 3 Nov 2023 20:37:25 +0000 Subject: [PATCH 01/29] Initial rest api support --- src/Http/Controllers/RestApiController.php | 76 ++++++++++++++++++++++ src/Http/Resources/ApiResource.php | 22 +++++++ src/ServiceProvider.php | 28 ++++++++ 3 files changed, 126 insertions(+) create mode 100644 src/Http/Controllers/RestApiController.php create mode 100644 src/Http/Resources/ApiResource.php diff --git a/src/Http/Controllers/RestApiController.php b/src/Http/Controllers/RestApiController.php new file mode 100644 index 00000000..8416628b --- /dev/null +++ b/src/Http/Controllers/RestApiController.php @@ -0,0 +1,76 @@ +abortIfDisabled(); + + $this->resourceHandle = $resource; + + $resource = Runway::findResource($resource); + + if (! $resource) { + throw new NotFoundHttpException; + } + + $results = $this->filterSortAndPaginate($resource->model()->query()); + $results->setCollection($results->getCollection()->map(fn ($model) => $this->makeResourceFromModel($resource, $model))); + + return ApiResource::collection($results); + } + + public function show($resource, $id) + { + $this->abortIfDisabled(); + + $this->resourceHandle = $resource; + + $resource = Runway::findResource($resource); + + if (! $resource) { + throw new NotFoundHttpException; + } + + if (! $model = $resource->model()->find($id)) { + throw new NotFoundHttpException; + } + + return ApiResource::make($this->makeResourceFromModel($resource, $model)); + } + + protected function allowedFilters() + { + return FilterAuthorizer::allowedForSubResources('api', $this->resourceConfigKey, $this->resourceHandle); + } + + private function makeResourceFromModel($resource, $model) + { + if (! $this->config) { + $this->config = collect(config('runway.resources'))->get(get_class($model)); + } + + return new Resource( + handle: $this->resourceHandle, + model: $model, + name: $this->config['name'] ?? Str::title($this->resourceHandle), + blueprint: $resource->blueprint(), + config: $this->config ?? [], + ); + } +} diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php new file mode 100644 index 00000000..018bf8ac --- /dev/null +++ b/src/Http/Resources/ApiResource.php @@ -0,0 +1,22 @@ +resource + ->toAugmentedCollection() + ->withShallowNesting() + ->toArray(); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 769a5dcf..9f9a0aaf 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,16 +2,21 @@ namespace DoubleThreeDigital\Runway; +use DoubleThreeDigital\Runway\Http\Controllers\RestApiController; use DoubleThreeDigital\Runway\Policies\ResourcePolicy; use DoubleThreeDigital\Runway\Search\Provider as SearchProvider; use DoubleThreeDigital\Runway\Search\Searchable; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Traits\Conditionable; +use Statamic\API\Middleware\Cache; use Statamic\Facades\CP\Nav; use Statamic\Facades\GraphQL; use Statamic\Facades\Permission; use Statamic\Facades\Search; +use Statamic\Http\Middleware\API\SwapExceptionHandler as SwapAPIExceptionHandler; +use Statamic\Http\Middleware\RequireStatamicPro; use Statamic\Providers\AddonServiceProvider; use Statamic\Statamic; @@ -80,6 +85,7 @@ public function boot() $this->registerPolicies(); $this->registerNavigation(); $this->bootGraphQl(); + $this->bootRestApi(); SearchProvider::register(); $this->bootModelEventListeners(); @@ -151,6 +157,28 @@ protected function bootGraphQl() GraphQL::addQuery("runway_graphql_queries_{$resource->handle()}_show"); }); } + + protected function bootRestApi() + { + if (config('statamic.api.enabled')) { + Route::middleware([ + SwapApiExceptionHandler::class, + RequireStatamicPro::class, + Cache::class, + ])->group(function () { + Route::middleware(config('statamic.api.middleware')) + ->name('statamic.api.') + ->prefix(config('statamic.api.route')) + ->group(function () { + Runway::allResources() + ->each(function (Resource $resource) { + Route::name('runway.'.$resource->handle().'.index')->get('runway/{resource}', [RestApiController::class, 'index']); + Route::name('runway.'.$resource->handle().'.show')->get('runway/{resource}/{id}', [RestApiController::class, 'show']); + }); + }); + }); + } + } protected function bootModelEventListeners() { From bc891adec9ed6beb8ab91cd2b16b1f48a3709034 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Fri, 3 Nov 2023 20:45:28 +0000 Subject: [PATCH 02/29] Ensure primary key is returned always --- src/Http/Resources/ApiResource.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index 018bf8ac..e1876b40 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -14,9 +14,12 @@ class ApiResource extends JsonResource */ public function toArray($request) { - return $this->resource - ->toAugmentedCollection() - ->withShallowNesting() - ->toArray(); + return array_merge([ + $this->resource->primaryKey() => $this->resource->model()->getKey(), + ], $this->resource + ->toAugmentedCollection() + ->withShallowNesting() + ->toArray() + ); } } From 3e334a346dd9cb2b43414ef365ee173f7aa325d2 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Fri, 3 Nov 2023 20:51:05 +0000 Subject: [PATCH 03/29] :beer: --- src/Http/Controllers/RestApiController.php | 23 ++++++++++++---------- src/Http/Resources/ApiResource.php | 12 +++++------ src/ServiceProvider.php | 4 ++-- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/Http/Controllers/RestApiController.php b/src/Http/Controllers/RestApiController.php index 8416628b..25c6ea85 100644 --- a/src/Http/Controllers/RestApiController.php +++ b/src/Http/Controllers/RestApiController.php @@ -13,44 +13,47 @@ class RestApiController extends ApiController { private $config; + protected $resourceConfigKey = 'runway'; + protected $routeResourceKey = 'resource'; + protected $resourceHandle; public function index($resource) { $this->abortIfDisabled(); - + $this->resourceHandle = $resource; $resource = Runway::findResource($resource); - + if (! $resource) { throw new NotFoundHttpException; } - + $results = $this->filterSortAndPaginate($resource->model()->query()); $results->setCollection($results->getCollection()->map(fn ($model) => $this->makeResourceFromModel($resource, $model))); - + return ApiResource::collection($results); } public function show($resource, $id) { $this->abortIfDisabled(); - + $this->resourceHandle = $resource; - + $resource = Runway::findResource($resource); - + if (! $resource) { throw new NotFoundHttpException; } - + if (! $model = $resource->model()->find($id)) { throw new NotFoundHttpException; } - + return ApiResource::make($this->makeResourceFromModel($resource, $model)); } @@ -58,7 +61,7 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', $this->resourceConfigKey, $this->resourceHandle); } - + private function makeResourceFromModel($resource, $model) { if (! $this->config) { diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index e1876b40..99bfb4e4 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -15,11 +15,11 @@ class ApiResource extends JsonResource public function toArray($request) { return array_merge([ - $this->resource->primaryKey() => $this->resource->model()->getKey(), - ], $this->resource - ->toAugmentedCollection() - ->withShallowNesting() - ->toArray() - ); + $this->resource->primaryKey() => $this->resource->model()->getKey(), + ], $this->resource + ->toAugmentedCollection() + ->withShallowNesting() + ->toArray() + ); } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 9f9a0aaf..a1be08bb 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -157,7 +157,7 @@ protected function bootGraphQl() GraphQL::addQuery("runway_graphql_queries_{$resource->handle()}_show"); }); } - + protected function bootRestApi() { if (config('statamic.api.enabled')) { @@ -169,7 +169,7 @@ protected function bootRestApi() Route::middleware(config('statamic.api.middleware')) ->name('statamic.api.') ->prefix(config('statamic.api.route')) - ->group(function () { + ->group(function () { Runway::allResources() ->each(function (Resource $resource) { Route::name('runway.'.$resource->handle().'.index')->get('runway/{resource}', [RestApiController::class, 'index']); From 1426ec41c3c3ef176f8f1f19f0ad4985f0d3f773 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Sat, 4 Nov 2023 08:50:59 +0000 Subject: [PATCH 04/29] Pluralize ie (api/runway/users versus api/runway/user) --- src/Http/Controllers/RestApiController.php | 8 ++++---- src/ServiceProvider.php | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Http/Controllers/RestApiController.php b/src/Http/Controllers/RestApiController.php index 25c6ea85..6b78c335 100644 --- a/src/Http/Controllers/RestApiController.php +++ b/src/Http/Controllers/RestApiController.php @@ -24,9 +24,9 @@ public function index($resource) { $this->abortIfDisabled(); - $this->resourceHandle = $resource; + $this->resourceHandle = Str::singular($resource); - $resource = Runway::findResource($resource); + $resource = Runway::findResource($this->resourceHandle); if (! $resource) { throw new NotFoundHttpException; @@ -42,9 +42,9 @@ public function show($resource, $id) { $this->abortIfDisabled(); - $this->resourceHandle = $resource; + $this->resourceHandle = Str::singular($resource); - $resource = Runway::findResource($resource); + $resource = Runway::findResource($this->resourceHandle); if (! $resource) { throw new NotFoundHttpException; diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index a1be08bb..e9e79183 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Str; use Illuminate\Support\Traits\Conditionable; use Statamic\API\Middleware\Cache; use Statamic\Facades\CP\Nav; @@ -172,8 +173,9 @@ protected function bootRestApi() ->group(function () { Runway::allResources() ->each(function (Resource $resource) { - Route::name('runway.'.$resource->handle().'.index')->get('runway/{resource}', [RestApiController::class, 'index']); - Route::name('runway.'.$resource->handle().'.show')->get('runway/{resource}/{id}', [RestApiController::class, 'show']); + $handle = Str::plural($resource->handle()); + Route::name('runway.'.$handle.'.index')->get('runway/{resource}', [RestApiController::class, 'index']); + Route::name('runway.'.$handle.'.show')->get('runway/{resource}/{id}', [RestApiController::class, 'show']); }); }); }); From 5bcdf248d508b49135ebf081918c0db91604eea5 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Sat, 4 Nov 2023 08:58:16 +0000 Subject: [PATCH 05/29] Only define routes once as they are generic --- src/ServiceProvider.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index e9e79183..0da3dd8a 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -9,7 +9,6 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; -use Illuminate\Support\Str; use Illuminate\Support\Traits\Conditionable; use Statamic\API\Middleware\Cache; use Statamic\Facades\CP\Nav; @@ -171,12 +170,8 @@ protected function bootRestApi() ->name('statamic.api.') ->prefix(config('statamic.api.route')) ->group(function () { - Runway::allResources() - ->each(function (Resource $resource) { - $handle = Str::plural($resource->handle()); - Route::name('runway.'.$handle.'.index')->get('runway/{resource}', [RestApiController::class, 'index']); - Route::name('runway.'.$handle.'.show')->get('runway/{resource}/{id}', [RestApiController::class, 'show']); - }); + Route::name('runway.restapi.index')->get('runway/{resource}', [RestApiController::class, 'index']); + Route::name('runway.restapi.show')->get('runway/{resource}/{id}', [RestApiController::class, 'show']); }); }); } From 30835ca252908ddb9eb2841b2fc412aba0cea2dd Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Sat, 4 Nov 2023 08:58:59 +0000 Subject: [PATCH 06/29] Simplify route names --- src/ServiceProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 0da3dd8a..88dfc146 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -170,8 +170,8 @@ protected function bootRestApi() ->name('statamic.api.') ->prefix(config('statamic.api.route')) ->group(function () { - Route::name('runway.restapi.index')->get('runway/{resource}', [RestApiController::class, 'index']); - Route::name('runway.restapi.show')->get('runway/{resource}/{id}', [RestApiController::class, 'show']); + Route::name('runway.index')->get('runway/{resource}', [RestApiController::class, 'index']); + Route::name('runway.show')->get('runway/{resource}/{id}', [RestApiController::class, 'show']); }); }); } From e7d1b0ee411735f5f704e662bebb800851442905 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Sat, 4 Nov 2023 09:11:26 +0000 Subject: [PATCH 07/29] Ensure filters work --- src/Http/Controllers/RestApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Controllers/RestApiController.php b/src/Http/Controllers/RestApiController.php index 6b78c335..d628210a 100644 --- a/src/Http/Controllers/RestApiController.php +++ b/src/Http/Controllers/RestApiController.php @@ -59,7 +59,7 @@ public function show($resource, $id) protected function allowedFilters() { - return FilterAuthorizer::allowedForSubResources('api', $this->resourceConfigKey, $this->resourceHandle); + return FilterAuthorizer::allowedForSubResources('api', $this->resourceConfigKey, Str::plural($this->resourceHandle)); } private function makeResourceFromModel($resource, $model) From 3b75d51c36ed6c440663c11f3c57ca2508994b79 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Mon, 6 Nov 2023 19:02:37 +0000 Subject: [PATCH 08/29] Sync with v6 changes --- src/Http/Controllers/RestApiController.php | 10 +++++----- src/Http/Resources/ApiResource.php | 4 +++- src/ServiceProvider.php | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Http/Controllers/RestApiController.php b/src/Http/Controllers/RestApiController.php index d628210a..cc4a2574 100644 --- a/src/Http/Controllers/RestApiController.php +++ b/src/Http/Controllers/RestApiController.php @@ -20,11 +20,11 @@ class RestApiController extends ApiController protected $resourceHandle; - public function index($resource) + public function index($handle) { $this->abortIfDisabled(); - $this->resourceHandle = Str::singular($resource); + $this->resourceHandle = Str::singular($handle); $resource = Runway::findResource($this->resourceHandle); @@ -38,11 +38,11 @@ public function index($resource) return ApiResource::collection($results); } - public function show($resource, $id) + public function show($handle, $id) { $this->abortIfDisabled(); - $this->resourceHandle = Str::singular($resource); + $this->resourceHandle = Str::singular($handle); $resource = Runway::findResource($this->resourceHandle); @@ -73,7 +73,7 @@ private function makeResourceFromModel($resource, $model) model: $model, name: $this->config['name'] ?? Str::title($this->resourceHandle), blueprint: $resource->blueprint(), - config: $this->config ?? [], + config: collect($this->config ?? []), ); } } diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index 99bfb4e4..3d92ebc2 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -14,10 +14,12 @@ class ApiResource extends JsonResource */ public function toArray($request) { + $fields = $this->resource->blueprint()->fields()->all()->map->handle()->all(); + return array_merge([ $this->resource->primaryKey() => $this->resource->model()->getKey(), ], $this->resource - ->toAugmentedCollection() + ->toAugmentedCollection($fields) ->withShallowNesting() ->toArray() ); diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 3633e2c0..c57ac10f 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -178,8 +178,8 @@ protected function bootRestApi() ->name('statamic.api.') ->prefix(config('statamic.api.route')) ->group(function () { - Route::name('runway.index')->get('runway/{resource}', [RestApiController::class, 'index']); - Route::name('runway.show')->get('runway/{resource}/{id}', [RestApiController::class, 'show']); + Route::name('runway.index')->get('runway/{handle}', [RestApiController::class, 'index']); + Route::name('runway.show')->get('runway/{handle}/{id}', [RestApiController::class, 'show']); }); }); } From 61c9301359b2ea1bd24d8be31b2c624a7f125be8 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Mon, 6 Nov 2023 19:03:34 +0000 Subject: [PATCH 09/29] :beer: --- src/Http/Resources/ApiResource.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index 3d92ebc2..56bd4751 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -15,7 +15,7 @@ class ApiResource extends JsonResource public function toArray($request) { $fields = $this->resource->blueprint()->fields()->all()->map->handle()->all(); - + return array_merge([ $this->resource->primaryKey() => $this->resource->model()->getKey(), ], $this->resource From 4c58c25649c9067451d0d5127897b4b8f69af14c Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Mon, 6 Nov 2023 19:08:17 +0000 Subject: [PATCH 10/29] Use correct key --- src/Http/Controllers/RestApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Controllers/RestApiController.php b/src/Http/Controllers/RestApiController.php index cc4a2574..74c592ff 100644 --- a/src/Http/Controllers/RestApiController.php +++ b/src/Http/Controllers/RestApiController.php @@ -16,7 +16,7 @@ class RestApiController extends ApiController protected $resourceConfigKey = 'runway'; - protected $routeResourceKey = 'resource'; + protected $routeResourceKey = 'handle'; protected $resourceHandle; From 0bb3bf2b00fba52c8dd26f65aa31990877dd6f1e Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Mon, 6 Nov 2023 21:48:34 +0000 Subject: [PATCH 11/29] Change of approach --- src/Http/Controllers/RestApiController.php | 26 +++++++++------------- src/Http/Resources/ApiResource.php | 20 +++++++++++++---- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/Http/Controllers/RestApiController.php b/src/Http/Controllers/RestApiController.php index 74c592ff..d9f64918 100644 --- a/src/Http/Controllers/RestApiController.php +++ b/src/Http/Controllers/RestApiController.php @@ -33,9 +33,15 @@ public function index($handle) } $results = $this->filterSortAndPaginate($resource->model()->query()); - $results->setCollection($results->getCollection()->map(fn ($model) => $this->makeResourceFromModel($resource, $model))); - return ApiResource::collection($results); + $results = ApiResource::collection($results); + + $results->setCollection( + $results->getCollection() + ->transform(fn ($result) => $result->withBlueprintFields($this->getFieldsFromBlueprint($resource))) + ); + + return $results; } public function show($handle, $id) @@ -54,7 +60,7 @@ public function show($handle, $id) throw new NotFoundHttpException; } - return ApiResource::make($this->makeResourceFromModel($resource, $model)); + return ApiResource::make($model)->withBlueprintFields($this->getFieldsFromBlueprint($resource)); } protected function allowedFilters() @@ -62,18 +68,8 @@ protected function allowedFilters() return FilterAuthorizer::allowedForSubResources('api', $this->resourceConfigKey, Str::plural($this->resourceHandle)); } - private function makeResourceFromModel($resource, $model) + private function getFieldsFromBlueprint(Resource $resource): array { - if (! $this->config) { - $this->config = collect(config('runway.resources'))->get(get_class($model)); - } - - return new Resource( - handle: $this->resourceHandle, - model: $model, - name: $this->config['name'] ?? Str::title($this->resourceHandle), - blueprint: $resource->blueprint(), - config: collect($this->config ?? []), - ); + return $resource->blueprint()->fields()->all()->map->handle()->all(); } } diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index 56bd4751..b9170905 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -6,6 +6,8 @@ class ApiResource extends JsonResource { + public $blueprintFields = []; + /** * Transform the resource into an array. * @@ -14,14 +16,24 @@ class ApiResource extends JsonResource */ public function toArray($request) { - $fields = $this->resource->blueprint()->fields()->all()->map->handle()->all(); - return array_merge([ - $this->resource->primaryKey() => $this->resource->model()->getKey(), + $this->resource->getKeyName() => $this->resource->getKey(), ], $this->resource - ->toAugmentedCollection($fields) + ->toAugmentedCollection($this->blueprintFields ?? []) ->withShallowNesting() ->toArray() ); } + + /** + * Set the fields that should be returned by this resource + * + * @return self + */ + public function withBlueprintFields(array $fields) + { + $this->blueprintFields = $fields; + + return $this; + } } From 5a00f767e542592025a1ad4ec23e87313374a0c0 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Mon, 6 Nov 2023 22:05:59 +0000 Subject: [PATCH 12/29] Use toAugmentedArray --- src/Http/Resources/ApiResource.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index b9170905..9db73e16 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -19,9 +19,7 @@ public function toArray($request) return array_merge([ $this->resource->getKeyName() => $this->resource->getKey(), ], $this->resource - ->toAugmentedCollection($this->blueprintFields ?? []) - ->withShallowNesting() - ->toArray() + ->toAugmentedArray($this->blueprintFields) ); } From 340c7beb6f95e4f183b5cb56b4edecf7e5f7ac0b Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Mon, 6 Nov 2023 22:39:44 +0000 Subject: [PATCH 13/29] Work around wrapValue issues --- src/Http/Resources/ApiResource.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index 9db73e16..465b25cd 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -16,10 +16,17 @@ class ApiResource extends JsonResource */ public function toArray($request) { + // this gets around augmented model wrapValue() returning handles + // like meta->title instead of a nested array + // if that ever changes this could be removed + $augmentedArray = collect($this->resource->toAugmentedArray($this->blueprintFields)) + ->mapWithKeys(fn ($item, $key) => [str_replace('->', '.', $key) => $item]) + ->undot() + ->all(); + return array_merge([ $this->resource->getKeyName() => $this->resource->getKey(), - ], $this->resource - ->toAugmentedArray($this->blueprintFields) + ], $augmentedArray ); } From f2a34ecb888114ef610aba4bac03baacbbfabbda Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Mon, 6 Nov 2023 22:39:49 +0000 Subject: [PATCH 14/29] :beer: --- src/Http/Resources/ApiResource.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index 465b25cd..67dffbfb 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -16,14 +16,14 @@ class ApiResource extends JsonResource */ public function toArray($request) { - // this gets around augmented model wrapValue() returning handles + // this gets around augmented model wrapValue() returning handles // like meta->title instead of a nested array // if that ever changes this could be removed $augmentedArray = collect($this->resource->toAugmentedArray($this->blueprintFields)) ->mapWithKeys(fn ($item, $key) => [str_replace('->', '.', $key) => $item]) ->undot() ->all(); - + return array_merge([ $this->resource->getKeyName() => $this->resource->getKey(), ], $augmentedArray From c37259fe904b496a6ffe18b69a65b55a10162941 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 7 Nov 2023 08:02:57 +0000 Subject: [PATCH 15/29] Add test coverage --- tests/Http/Controllers/ApiControllerTest.php | 168 +++++++++++++++++++ tests/TestCase.php | 3 + 2 files changed, 171 insertions(+) create mode 100644 tests/Http/Controllers/ApiControllerTest.php diff --git a/tests/Http/Controllers/ApiControllerTest.php b/tests/Http/Controllers/ApiControllerTest.php new file mode 100644 index 00000000..bb05afd5 --- /dev/null +++ b/tests/Http/Controllers/ApiControllerTest.php @@ -0,0 +1,168 @@ + ['allowed_filters' => ['title']], + ]); + } + + /** @test */ + public function gets_a_resource_that_exists() + { + Post::factory()->count(2)->create(); + + $response = $this + ->get(route('statamic.api.runway.index', ['handle' => 'posts'])) + ->assertOk() + ->assertSee([ + 'data', + 'meta', + ]); + + $ids = collect($response->json()['data'])->pluck('id')->values()->all(); + + $this->assertSame($ids, Post::all()->pluck('id')->values()->all()); + } + + /** @test */ + public function returns_not_found_on_a_resource_that_doesnt_exist() + { + Post::factory()->count(2)->create(); + + $response = $this + ->get(route('statamic.api.runway.index', ['handle' => 'posts2'])) + ->assertNotFound(); + } + + /** @test */ + public function gets_a_resource_model_that_exists() + { + Post::factory()->count(2)->create(); + + $response = $this + ->get(route('statamic.api.runway.show', ['handle' => 'posts', 'id' => 1])) + ->assertOk() + ->assertSee([ + 'data', + ]); + } + + /** @test */ + public function returns_not_found_on_a_model_that_doesnt_exist() + { + Post::factory()->count(2)->create(); + $user = User::make()->makeSuper()->save(); + + $response = $this + ->get(route('statamic.api.runway.show', ['handle' => 'posts', 'id' => 44])) + ->assertNotFound(); + } + + /** @test */ + public function paginates_a_resource_list() + { + Post::factory()->count(10)->create(); + + $response = $this + ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'limit' => 5])) + ->assertOk() + ->assertSee([ + 'data', + 'meta', + ]); + + $json = $response->json(); + + $this->assertSame($json['meta']['current_page'], 1); + $this->assertSame($json['meta']['last_page'], 2); + $this->assertSame($json['meta']['total'], 10); + + $response = $this + ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'limit' => 5, 'page' => 2])) + ->assertOk() + ->assertSee([ + 'data', + 'meta', + ]); + + $json = $response->json(); + + $this->assertSame($json['meta']['current_page'], 2); + $this->assertSame($json['meta']['last_page'], 2); + $this->assertSame($json['meta']['total'], 10); + } + + /** @test */ + public function filters_a_resource_list() + { + Post::factory()->count(3)->create(); + + Post::find(1)->fill(['title' => 'Test One'])->save(); + Post::find(2)->fill(['title' => 'Test Two'])->save(); + Post::find(3)->fill(['title' => 'Test Three'])->save(); + + $response = $this + ->get(route('statamic.api.runway.index', ['handle' => 'posts'])) + ->assertOk() + ->assertSee([ + 'data', + 'meta', + ]); + + $json = $response->json(); + + $this->assertSame($json['meta']['total'], 3); + + $response = $this + ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[title:contains]' => 'one'])) + ->assertOk() + ->assertSee([ + 'data', + 'meta', + ]); + + $json = $response->json(); + + $this->assertSame($json['meta']['total'], 1); + + $response = $this + ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[title:contains]' => 'test'])) + ->assertOk() + ->assertSee([ + 'data', + 'meta', + ]); + + $json = $response->json(); + + $this->assertSame($json['meta']['total'], 3); + } + + /** @test */ + public function wont_filter_a_resource_list_on_a_forbidden_filter() + { + Post::factory()->count(3)->create(); + + Post::find(1)->fill(['title' => 'Test One'])->save(); + Post::find(2)->fill(['title' => 'Test Two'])->save(); + Post::find(3)->fill(['title' => 'Test Three'])->save(); + + $this + ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[slug:contains]' => 'one'])) + ->assertStatus(422); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index deb525bb..cc02cfd1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -100,5 +100,8 @@ protected function resolveApplicationConfiguration($app) ]); $app['config']->set('runway', require(__DIR__.'/__fixtures__/config/runway.php')); + + $app['config']->set('statamic.api.enabled', true); + $app['config']->set('statamic.editions.pro', true); } } From fde70b80b3ef072612a7dd9e31ca36a3d4955438 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 7 Nov 2023 08:03:28 +0000 Subject: [PATCH 16/29] :beer: --- tests/Http/Controllers/ApiControllerTest.php | 52 ++++++++++---------- tests/TestCase.php | 2 +- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/tests/Http/Controllers/ApiControllerTest.php b/tests/Http/Controllers/ApiControllerTest.php index bb05afd5..9efc4953 100644 --- a/tests/Http/Controllers/ApiControllerTest.php +++ b/tests/Http/Controllers/ApiControllerTest.php @@ -2,8 +2,6 @@ namespace DoubleThreeDigital\Runway\Tests\Http\Controllers; -use DoubleThreeDigital\Runway\Runway; -use DoubleThreeDigital\Runway\Tests\Fixtures\Models\Author; use DoubleThreeDigital\Runway\Tests\Fixtures\Models\Post; use DoubleThreeDigital\Runway\Tests\TestCase; use Statamic\Facades\Config; @@ -19,7 +17,7 @@ public function setUp(): void 'posts' => ['allowed_filters' => ['title']], ]); } - + /** @test */ public function gets_a_resource_that_exists() { @@ -32,12 +30,12 @@ public function gets_a_resource_that_exists() 'data', 'meta', ]); - + $ids = collect($response->json()['data'])->pluck('id')->values()->all(); - + $this->assertSame($ids, Post::all()->pluck('id')->values()->all()); } - + /** @test */ public function returns_not_found_on_a_resource_that_doesnt_exist() { @@ -47,7 +45,7 @@ public function returns_not_found_on_a_resource_that_doesnt_exist() ->get(route('statamic.api.runway.index', ['handle' => 'posts2'])) ->assertNotFound(); } - + /** @test */ public function gets_a_resource_model_that_exists() { @@ -71,7 +69,7 @@ public function returns_not_found_on_a_model_that_doesnt_exist() ->get(route('statamic.api.runway.show', ['handle' => 'posts', 'id' => 44])) ->assertNotFound(); } - + /** @test */ public function paginates_a_resource_list() { @@ -84,13 +82,13 @@ public function paginates_a_resource_list() 'data', 'meta', ]); - + $json = $response->json(); - + $this->assertSame($json['meta']['current_page'], 1); $this->assertSame($json['meta']['last_page'], 2); $this->assertSame($json['meta']['total'], 10); - + $response = $this ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'limit' => 5, 'page' => 2])) ->assertOk() @@ -98,19 +96,19 @@ public function paginates_a_resource_list() 'data', 'meta', ]); - + $json = $response->json(); - + $this->assertSame($json['meta']['current_page'], 2); $this->assertSame($json['meta']['last_page'], 2); $this->assertSame($json['meta']['total'], 10); } - + /** @test */ public function filters_a_resource_list() { - Post::factory()->count(3)->create(); - + Post::factory()->count(3)->create(); + Post::find(1)->fill(['title' => 'Test One'])->save(); Post::find(2)->fill(['title' => 'Test Two'])->save(); Post::find(3)->fill(['title' => 'Test Three'])->save(); @@ -122,11 +120,11 @@ public function filters_a_resource_list() 'data', 'meta', ]); - + $json = $response->json(); - + $this->assertSame($json['meta']['total'], 3); - + $response = $this ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[title:contains]' => 'one'])) ->assertOk() @@ -134,11 +132,11 @@ public function filters_a_resource_list() 'data', 'meta', ]); - + $json = $response->json(); - + $this->assertSame($json['meta']['total'], 1); - + $response = $this ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[title:contains]' => 'test'])) ->assertOk() @@ -146,17 +144,17 @@ public function filters_a_resource_list() 'data', 'meta', ]); - + $json = $response->json(); - + $this->assertSame($json['meta']['total'], 3); } - + /** @test */ public function wont_filter_a_resource_list_on_a_forbidden_filter() { - Post::factory()->count(3)->create(); - + Post::factory()->count(3)->create(); + Post::find(1)->fill(['title' => 'Test One'])->save(); Post::find(2)->fill(['title' => 'Test Two'])->save(); Post::find(3)->fill(['title' => 'Test Three'])->save(); diff --git a/tests/TestCase.php b/tests/TestCase.php index cc02cfd1..7f93dd7d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -100,7 +100,7 @@ protected function resolveApplicationConfiguration($app) ]); $app['config']->set('runway', require(__DIR__.'/__fixtures__/config/runway.php')); - + $app['config']->set('statamic.api.enabled', true); $app['config']->set('statamic.editions.pro', true); } From ce3ce00d9c4a68f3bdb374cb22094b1d5e3c5dce Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 18:07:44 +0000 Subject: [PATCH 17/29] nitpick --- tests/TestCase.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 7f93dd7d..a441bd2f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -92,6 +92,8 @@ protected function resolveApplicationConfiguration($app) ); } + $app['config']->set('statamic.api.enabled', true); + $app['config']->set('statamic.editions.pro', true); $app['config']->set('statamic.users.repository', 'file'); $app['config']->set('statamic.stache.stores.users', [ @@ -100,8 +102,5 @@ protected function resolveApplicationConfiguration($app) ]); $app['config']->set('runway', require(__DIR__.'/__fixtures__/config/runway.php')); - - $app['config']->set('statamic.api.enabled', true); - $app['config']->set('statamic.editions.pro', true); } } From e677354b1229b0210acb742a28dd54e30ad00efa Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 18:14:29 +0000 Subject: [PATCH 18/29] rename controller --- .../{RestApiController.php => ApiController.php} | 4 ++-- src/ServiceProvider.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/Http/Controllers/{RestApiController.php => ApiController.php} (94%) diff --git a/src/Http/Controllers/RestApiController.php b/src/Http/Controllers/ApiController.php similarity index 94% rename from src/Http/Controllers/RestApiController.php rename to src/Http/Controllers/ApiController.php index d9f64918..e88445de 100644 --- a/src/Http/Controllers/RestApiController.php +++ b/src/Http/Controllers/ApiController.php @@ -8,9 +8,9 @@ use Facades\Statamic\API\FilterAuthorizer; use Illuminate\Support\Str; use Statamic\Exceptions\NotFoundHttpException; -use Statamic\Http\Controllers\API\ApiController; +use Statamic\Http\Controllers\API\ApiController as StatamicApiController; -class RestApiController extends ApiController +class ApiController extends StatamicApiController { private $config; diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index c57ac10f..6e3e5b21 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,7 +2,7 @@ namespace DoubleThreeDigital\Runway; -use DoubleThreeDigital\Runway\Http\Controllers\RestApiController; +use DoubleThreeDigital\Runway\Http\Controllers\ApiController; use DoubleThreeDigital\Runway\Policies\ResourcePolicy; use DoubleThreeDigital\Runway\Search\Provider as SearchProvider; use DoubleThreeDigital\Runway\Search\Searchable; @@ -178,8 +178,8 @@ protected function bootRestApi() ->name('statamic.api.') ->prefix(config('statamic.api.route')) ->group(function () { - Route::name('runway.index')->get('runway/{handle}', [RestApiController::class, 'index']); - Route::name('runway.show')->get('runway/{handle}/{id}', [RestApiController::class, 'show']); + Route::name('runway.index')->get('runway/{handle}', [ApiController::class, 'index']); + Route::name('runway.show')->get('runway/{handle}/{id}', [ApiController::class, 'show']); }); }); } From 6311d3d8d848c8164ea94c36eecd2f55028d3aec Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 18:28:19 +0000 Subject: [PATCH 19/29] Refactor tests to use JSON assertions --- tests/Http/Controllers/ApiControllerTest.php | 121 +++++++------------ 1 file changed, 41 insertions(+), 80 deletions(-) diff --git a/tests/Http/Controllers/ApiControllerTest.php b/tests/Http/Controllers/ApiControllerTest.php index 9efc4953..5909828f 100644 --- a/tests/Http/Controllers/ApiControllerTest.php +++ b/tests/Http/Controllers/ApiControllerTest.php @@ -5,7 +5,6 @@ use DoubleThreeDigital\Runway\Tests\Fixtures\Models\Post; use DoubleThreeDigital\Runway\Tests\TestCase; use Statamic\Facades\Config; -use Statamic\Facades\User; class ApiControllerTest extends TestCase { @@ -21,19 +20,14 @@ public function setUp(): void /** @test */ public function gets_a_resource_that_exists() { - Post::factory()->count(2)->create(); + $posts = Post::factory()->count(2)->create(); - $response = $this + $this ->get(route('statamic.api.runway.index', ['handle' => 'posts'])) ->assertOk() - ->assertSee([ - 'data', - 'meta', - ]); - - $ids = collect($response->json()['data'])->pluck('id')->values()->all(); - - $this->assertSame($ids, Post::all()->pluck('id')->values()->all()); + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('data.0.id', $posts[0]->id) + ->assertJsonPath('data.1.id', $posts[1]->id); } /** @test */ @@ -41,7 +35,7 @@ public function returns_not_found_on_a_resource_that_doesnt_exist() { Post::factory()->count(2)->create(); - $response = $this + $this ->get(route('statamic.api.runway.index', ['handle' => 'posts2'])) ->assertNotFound(); } @@ -49,23 +43,20 @@ public function returns_not_found_on_a_resource_that_doesnt_exist() /** @test */ public function gets_a_resource_model_that_exists() { - Post::factory()->count(2)->create(); + $post = Post::factory()->create(); - $response = $this - ->get(route('statamic.api.runway.show', ['handle' => 'posts', 'id' => 1])) + $this + ->get(route('statamic.api.runway.show', ['handle' => 'posts', 'id' => $post->id])) ->assertOk() - ->assertSee([ - 'data', - ]); + ->assertSee(['data']) + ->assertJsonPath('data.id', $post->id) + ->assertJsonPath('data.title', $post->title); } /** @test */ - public function returns_not_found_on_a_model_that_doesnt_exist() + public function returns_not_found_on_a_model_that_does_not_exist() { - Post::factory()->count(2)->create(); - $user = User::make()->makeSuper()->save(); - - $response = $this + $this ->get(route('statamic.api.runway.show', ['handle' => 'posts', 'id' => 44])) ->assertNotFound(); } @@ -75,89 +66,59 @@ public function paginates_a_resource_list() { Post::factory()->count(10)->create(); - $response = $this + $this ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'limit' => 5])) ->assertOk() - ->assertSee([ - 'data', - 'meta', - ]); - - $json = $response->json(); - - $this->assertSame($json['meta']['current_page'], 1); - $this->assertSame($json['meta']['last_page'], 2); - $this->assertSame($json['meta']['total'], 10); + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.last_page', 2) + ->assertJsonPath('meta.total', 10); - $response = $this + $this ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'limit' => 5, 'page' => 2])) ->assertOk() - ->assertSee([ - 'data', - 'meta', - ]); - - $json = $response->json(); - - $this->assertSame($json['meta']['current_page'], 2); - $this->assertSame($json['meta']['last_page'], 2); - $this->assertSame($json['meta']['total'], 10); + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('meta.current_page', 2) + ->assertJsonPath('meta.last_page', 2) + ->assertJsonPath('meta.total', 10); } /** @test */ public function filters_a_resource_list() { - Post::factory()->count(3)->create(); + [$postA, $postB, $postC] = Post::factory()->count(3)->create(); - Post::find(1)->fill(['title' => 'Test One'])->save(); - Post::find(2)->fill(['title' => 'Test Two'])->save(); - Post::find(3)->fill(['title' => 'Test Three'])->save(); + $postA->update(['title' => 'Test One']); + $postB->update(['title' => 'Test Two']); + $postC->update(['title' => 'Test Three']); - $response = $this + $this ->get(route('statamic.api.runway.index', ['handle' => 'posts'])) ->assertOk() - ->assertSee([ - 'data', - 'meta', - ]); - - $json = $response->json(); - - $this->assertSame($json['meta']['total'], 3); + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('meta.total', 3); - $response = $this + $this ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[title:contains]' => 'one'])) ->assertOk() - ->assertSee([ - 'data', - 'meta', - ]); - - $json = $response->json(); - - $this->assertSame($json['meta']['total'], 1); + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('meta.total', 1); - $response = $this + $this ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[title:contains]' => 'test'])) ->assertOk() - ->assertSee([ - 'data', - 'meta', - ]); - - $json = $response->json(); - - $this->assertSame($json['meta']['total'], 3); + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('meta.total', 3); } /** @test */ public function wont_filter_a_resource_list_on_a_forbidden_filter() { - Post::factory()->count(3)->create(); + [$postA, $postB, $postC] = Post::factory()->count(3)->create(); - Post::find(1)->fill(['title' => 'Test One'])->save(); - Post::find(2)->fill(['title' => 'Test Two'])->save(); - Post::find(3)->fill(['title' => 'Test Three'])->save(); + $postA->update(['title' => 'Test One']); + $postB->update(['title' => 'Test Two']); + $postC->update(['title' => 'Test Three']); $this ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[slug:contains]' => 'one'])) From 42f2d55202eb5e4e45756b3f3c1a1f409d3714e3 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 18:29:14 +0000 Subject: [PATCH 20/29] Rename bootRestApi to bootApi --- src/ServiceProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 6e3e5b21..a84057c9 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -86,7 +86,7 @@ public function boot() $this->registerPolicies(); $this->registerNavigation(); $this->bootGraphQl(); - $this->bootRestApi(); + $this->bootApi(); SearchProvider::register(); $this->bootModelEventListeners(); @@ -166,7 +166,7 @@ protected function bootGraphQl() }); } - protected function bootRestApi() + protected function bootApi() { if (config('statamic.api.enabled')) { Route::middleware([ From 4617c6f833b83f713c70519723359a1d5a2a0b95 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 18:34:28 +0000 Subject: [PATCH 21/29] Rename route parameters to stay consistent with elsewhere in the codebase --- src/Http/Controllers/ApiController.php | 15 +++++++-------- src/ServiceProvider.php | 4 ++-- tests/Http/Controllers/ApiControllerTest.php | 20 ++++++++++---------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/Http/Controllers/ApiController.php b/src/Http/Controllers/ApiController.php index e88445de..84510d59 100644 --- a/src/Http/Controllers/ApiController.php +++ b/src/Http/Controllers/ApiController.php @@ -16,15 +16,15 @@ class ApiController extends StatamicApiController protected $resourceConfigKey = 'runway'; - protected $routeResourceKey = 'handle'; + protected $routeResourceKey = 'resourceHandle'; protected $resourceHandle; - public function index($handle) + public function index($resourceHandle) { $this->abortIfDisabled(); - $this->resourceHandle = Str::singular($handle); + $this->resourceHandle = Str::singular($resourceHandle); $resource = Runway::findResource($this->resourceHandle); @@ -37,18 +37,17 @@ public function index($handle) $results = ApiResource::collection($results); $results->setCollection( - $results->getCollection() - ->transform(fn ($result) => $result->withBlueprintFields($this->getFieldsFromBlueprint($resource))) + $results->getCollection()->transform(fn ($result) => $result->withBlueprintFields($this->getFieldsFromBlueprint($resource))) ); return $results; } - public function show($handle, $id) + public function show($resourceHandle, $record) { $this->abortIfDisabled(); - $this->resourceHandle = Str::singular($handle); + $this->resourceHandle = Str::singular($resourceHandle); $resource = Runway::findResource($this->resourceHandle); @@ -56,7 +55,7 @@ public function show($handle, $id) throw new NotFoundHttpException; } - if (! $model = $resource->model()->find($id)) { + if (! $model = $resource->model()->find($record)) { throw new NotFoundHttpException; } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index a84057c9..bb5342fb 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -178,8 +178,8 @@ protected function bootApi() ->name('statamic.api.') ->prefix(config('statamic.api.route')) ->group(function () { - Route::name('runway.index')->get('runway/{handle}', [ApiController::class, 'index']); - Route::name('runway.show')->get('runway/{handle}/{id}', [ApiController::class, 'show']); + Route::name('runway.index')->get('runway/{resourceHandle}', [ApiController::class, 'index']); + Route::name('runway.show')->get('runway/{resourceHandle}/{record}', [ApiController::class, 'show']); }); }); } diff --git a/tests/Http/Controllers/ApiControllerTest.php b/tests/Http/Controllers/ApiControllerTest.php index 5909828f..9c6bd5c1 100644 --- a/tests/Http/Controllers/ApiControllerTest.php +++ b/tests/Http/Controllers/ApiControllerTest.php @@ -23,7 +23,7 @@ public function gets_a_resource_that_exists() $posts = Post::factory()->count(2)->create(); $this - ->get(route('statamic.api.runway.index', ['handle' => 'posts'])) + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts'])) ->assertOk() ->assertJsonStructure(['data', 'meta']) ->assertJsonPath('data.0.id', $posts[0]->id) @@ -36,7 +36,7 @@ public function returns_not_found_on_a_resource_that_doesnt_exist() Post::factory()->count(2)->create(); $this - ->get(route('statamic.api.runway.index', ['handle' => 'posts2'])) + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts2'])) ->assertNotFound(); } @@ -46,7 +46,7 @@ public function gets_a_resource_model_that_exists() $post = Post::factory()->create(); $this - ->get(route('statamic.api.runway.show', ['handle' => 'posts', 'id' => $post->id])) + ->get(route('statamic.api.runway.show', ['resourceHandle' => 'posts', 'record' => $post->id])) ->assertOk() ->assertSee(['data']) ->assertJsonPath('data.id', $post->id) @@ -57,7 +57,7 @@ public function gets_a_resource_model_that_exists() public function returns_not_found_on_a_model_that_does_not_exist() { $this - ->get(route('statamic.api.runway.show', ['handle' => 'posts', 'id' => 44])) + ->get(route('statamic.api.runway.show', ['resourceHandle' => 'posts', 'record' => 44])) ->assertNotFound(); } @@ -67,7 +67,7 @@ public function paginates_a_resource_list() Post::factory()->count(10)->create(); $this - ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'limit' => 5])) + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts', 'limit' => 5])) ->assertOk() ->assertJsonStructure(['data', 'meta']) ->assertJsonPath('meta.current_page', 1) @@ -75,7 +75,7 @@ public function paginates_a_resource_list() ->assertJsonPath('meta.total', 10); $this - ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'limit' => 5, 'page' => 2])) + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts', 'limit' => 5, 'page' => 2])) ->assertOk() ->assertJsonStructure(['data', 'meta']) ->assertJsonPath('meta.current_page', 2) @@ -93,19 +93,19 @@ public function filters_a_resource_list() $postC->update(['title' => 'Test Three']); $this - ->get(route('statamic.api.runway.index', ['handle' => 'posts'])) + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts'])) ->assertOk() ->assertJsonStructure(['data', 'meta']) ->assertJsonPath('meta.total', 3); $this - ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[title:contains]' => 'one'])) + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts', 'filter[title:contains]' => 'one'])) ->assertOk() ->assertJsonStructure(['data', 'meta']) ->assertJsonPath('meta.total', 1); $this - ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[title:contains]' => 'test'])) + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts', 'filter[title:contains]' => 'test'])) ->assertOk() ->assertJsonStructure(['data', 'meta']) ->assertJsonPath('meta.total', 3); @@ -121,7 +121,7 @@ public function wont_filter_a_resource_list_on_a_forbidden_filter() $postC->update(['title' => 'Test Three']); $this - ->get(route('statamic.api.runway.index', ['handle' => 'posts', 'filter[slug:contains]' => 'one'])) + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts', 'filter[slug:contains]' => 'one'])) ->assertStatus(422); } } From fdc48784a4fdcd3f8b62256de7ab0f0cc0be6357 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 18:35:59 +0000 Subject: [PATCH 22/29] nitpick --- src/Http/Resources/ApiResource.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index 67dffbfb..da0053c5 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -24,10 +24,9 @@ public function toArray($request) ->undot() ->all(); - return array_merge([ + return array_merge($augmentedArray, [ $this->resource->getKeyName() => $this->resource->getKey(), - ], $augmentedArray - ); + ]); } /** From ce2f392b8ec450ccba5cdc6af6ad652354d40fbe Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 18:39:22 +0000 Subject: [PATCH 23/29] add test to cover nested fields --- tests/Http/Controllers/ApiControllerTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Http/Controllers/ApiControllerTest.php b/tests/Http/Controllers/ApiControllerTest.php index 9c6bd5c1..6a871a81 100644 --- a/tests/Http/Controllers/ApiControllerTest.php +++ b/tests/Http/Controllers/ApiControllerTest.php @@ -53,6 +53,26 @@ public function gets_a_resource_model_that_exists() ->assertJsonPath('data.title', $post->title); } + /** @test */ + public function gets_a_resource_model_with_nested_fields() + { + $post = Post::factory()->create([ + 'values' => [ + 'alt_title' => 'Alternative Title...', + 'alt_body' => 'This is a **great** post! You should *read* it.', + ], + ]); + + $this + ->get(route('statamic.api.runway.show', ['resourceHandle' => 'posts', 'record' => $post->id])) + ->assertOk() + ->assertSee(['data']) + ->assertJsonPath('data.id', $post->id) + ->assertJsonPath('data.values.alt_title', 'Alternative Title...') + ->assertJsonPath('data.values.alt_body', '

This is a great post! You should read it.

+'); + } + /** @test */ public function returns_not_found_on_a_model_that_does_not_exist() { From 5fef5999cc275d57cca6be9478e4b12f3fe39df1 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 18:41:21 +0000 Subject: [PATCH 24/29] Add test to cover relationship field --- tests/Http/Controllers/ApiControllerTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Http/Controllers/ApiControllerTest.php b/tests/Http/Controllers/ApiControllerTest.php index 6a871a81..abd8802e 100644 --- a/tests/Http/Controllers/ApiControllerTest.php +++ b/tests/Http/Controllers/ApiControllerTest.php @@ -73,6 +73,20 @@ public function gets_a_resource_model_with_nested_fields() '); } + /** @test */ + public function gets_a_resource_model_with_belongs_to_relationship() + { + $post = Post::factory()->create(); + + $this + ->get(route('statamic.api.runway.show', ['resourceHandle' => 'posts', 'record' => $post->id])) + ->assertOk() + ->assertSee(['data']) + ->assertJsonPath('data.id', $post->id) + ->assertJsonPath('data.author_id.id', $post->author->id) + ->assertJsonPath('data.author_id.name', $post->author->name); + } + /** @test */ public function returns_not_found_on_a_model_that_does_not_exist() { From 490f07b8a7c6ffeaa0c76c68d594b7b01a4e7413 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 19:51:28 +0000 Subject: [PATCH 25/29] Shallowly augment relationship fields This commit reverts the changes made in 5a00f76 to make nested fields work. --- src/Data/AugmentedModel.php | 10 ++++++++++ src/Http/Resources/ApiResource.php | 19 ++++++++++++------- src/Traits/HasRunwayResource.php | 5 +++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/Data/AugmentedModel.php b/src/Data/AugmentedModel.php index df9dc26f..69b4f21f 100644 --- a/src/Data/AugmentedModel.php +++ b/src/Data/AugmentedModel.php @@ -8,6 +8,7 @@ use Statamic\Data\AbstractAugmented; use Statamic\Fields\Field; use Statamic\Fields\Value; +use Statamic\Statamic; class AugmentedModel extends AbstractAugmented { @@ -60,6 +61,15 @@ public function url(): ?string : null; } + public function apiUrl() + { + if (! $id = $this->data->{$this->resource->primaryKey()}) { + return null; + } + + return Statamic::apiRoute('runway.show', [$this->resource->handle(), $id]); + } + protected function modelAttributes(): Collection { return collect($this->data->getAttributes()); diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index da0053c5..fed130b3 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -2,6 +2,7 @@ namespace DoubleThreeDigital\Runway\Http\Resources; +use DoubleThreeDigital\Runway\Runway; use Illuminate\Http\Resources\Json\JsonResource; class ApiResource extends JsonResource @@ -16,13 +17,17 @@ class ApiResource extends JsonResource */ public function toArray($request) { - // this gets around augmented model wrapValue() returning handles - // like meta->title instead of a nested array - // if that ever changes this could be removed - $augmentedArray = collect($this->resource->toAugmentedArray($this->blueprintFields)) - ->mapWithKeys(fn ($item, $key) => [str_replace('->', '.', $key) => $item]) - ->undot() - ->all(); + $resource = Runway::findResourceByModel($this->resource); + + $with = $resource->blueprint() + ->fields()->all() + ->filter->isRelationship()->keys()->all(); + + $augmentedArray = $this->resource + ->toAugmentedCollection($this->blueprintFields ?? []) + ->withRelations($with) + ->withShallowNesting() + ->toArray(); return array_merge($augmentedArray, [ $this->resource->getKeyName() => $this->resource->getKey(), diff --git a/src/Traits/HasRunwayResource.php b/src/Traits/HasRunwayResource.php index 191d117b..6da06b7d 100644 --- a/src/Traits/HasRunwayResource.php +++ b/src/Traits/HasRunwayResource.php @@ -27,6 +27,11 @@ public function newAugmentedInstance(): Augmented return new AugmentedModel($this); } + public function shallowAugmentedArrayKeys() + { + return [$this->runwayResource()->primaryKey(), $this->runwayResource()->titleField(), 'api_url']; + } + public function runwayResource(): Resource { return Runway::findResourceByModel($this); From 35f978a0186104cdf2aa5c9d54ff222dc7a82d86 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 22:29:45 +0000 Subject: [PATCH 26/29] wip --- src/Http/Resources/ApiResource.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index fed130b3..61b085d9 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -2,7 +2,6 @@ namespace DoubleThreeDigital\Runway\Http\Resources; -use DoubleThreeDigital\Runway\Runway; use Illuminate\Http\Resources\Json\JsonResource; class ApiResource extends JsonResource @@ -17,9 +16,7 @@ class ApiResource extends JsonResource */ public function toArray($request) { - $resource = Runway::findResourceByModel($this->resource); - - $with = $resource->blueprint() + $with = $this->resource->runwayResource()->blueprint() ->fields()->all() ->filter->isRelationship()->keys()->all(); From 514581d2cc23db9e0f674e1676a255e42e39059c Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 22:34:20 +0000 Subject: [PATCH 27/29] Make nested fields work in the API --- src/Http/Resources/ApiResource.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index 61b085d9..7a9bcfbf 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -3,6 +3,7 @@ namespace DoubleThreeDigital\Runway\Http\Resources; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Str; class ApiResource extends JsonResource { @@ -26,6 +27,13 @@ public function toArray($request) ->withShallowNesting() ->toArray(); + collect($augmentedArray) + ->filter(fn ($value, $key) => Str::contains($key, '->')) + ->each(function ($value, $key) use (&$augmentedArray) { + $augmentedArray[Str::before($key, '->')][Str::after($key, '->')] = $value; + unset($augmentedArray[$key]); + }); + return array_merge($augmentedArray, [ $this->resource->getKeyName() => $this->resource->getKey(), ]); From f5252b7dc86f77876d2a51828b2efdee0a506bb3 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 22:39:08 +0000 Subject: [PATCH 28/29] Pass the blueprint fields as a Collection & re-use them for relationship fields check --- src/Http/Controllers/ApiController.php | 5 +++-- src/Http/Resources/ApiResource.php | 13 +++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Http/Controllers/ApiController.php b/src/Http/Controllers/ApiController.php index 84510d59..c5e12d54 100644 --- a/src/Http/Controllers/ApiController.php +++ b/src/Http/Controllers/ApiController.php @@ -6,6 +6,7 @@ use DoubleThreeDigital\Runway\Resource; use DoubleThreeDigital\Runway\Runway; use Facades\Statamic\API\FilterAuthorizer; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Http\Controllers\API\ApiController as StatamicApiController; @@ -67,8 +68,8 @@ protected function allowedFilters() return FilterAuthorizer::allowedForSubResources('api', $this->resourceConfigKey, Str::plural($this->resourceHandle)); } - private function getFieldsFromBlueprint(Resource $resource): array + private function getFieldsFromBlueprint(Resource $resource): Collection { - return $resource->blueprint()->fields()->all()->map->handle()->all(); + return $resource->blueprint()->fields()->all(); } } diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php index 7a9bcfbf..76cd638a 100644 --- a/src/Http/Resources/ApiResource.php +++ b/src/Http/Resources/ApiResource.php @@ -3,11 +3,12 @@ namespace DoubleThreeDigital\Runway\Http\Resources; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; use Illuminate\Support\Str; class ApiResource extends JsonResource { - public $blueprintFields = []; + public $blueprintFields; /** * Transform the resource into an array. @@ -17,13 +18,9 @@ class ApiResource extends JsonResource */ public function toArray($request) { - $with = $this->resource->runwayResource()->blueprint() - ->fields()->all() - ->filter->isRelationship()->keys()->all(); - $augmentedArray = $this->resource - ->toAugmentedCollection($this->blueprintFields ?? []) - ->withRelations($with) + ->toAugmentedCollection($this->blueprintFields->map->handle()->all() ?? []) + ->withRelations($this->blueprintFields->filter->isRelationship()->keys()->all()) ->withShallowNesting() ->toArray(); @@ -44,7 +41,7 @@ public function toArray($request) * * @return self */ - public function withBlueprintFields(array $fields) + public function withBlueprintFields(Collection $fields) { $this->blueprintFields = $fields; From 134206c3fe6684b68c245806cc8b0d19b1bd7d68 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 11 Nov 2023 22:56:54 +0000 Subject: [PATCH 29/29] Add documentation --- docs/rest-api.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/rest-api.md diff --git a/docs/rest-api.md b/docs/rest-api.md new file mode 100644 index 00000000..41db0a54 --- /dev/null +++ b/docs/rest-api.md @@ -0,0 +1,58 @@ +--- +title: REST API +--- + +Statamic comes with a read-only API that allows you to deliver content from Statamic to your frontend, external apps, SPA, or any other desired location. Content is delivered RESTfully as JSON data. + +Runway includes support for Statamic's Content API, which enables you to access your Eloquent models. + +If you prefer, Runway also supports [GraphQL](/graphql). + +## Enabling for resources + +If you haven't already done so, you'll need to enable Statamic's REST API. You can do this by adding the following line to your `.env` file: + +``` +STATAMIC_API_ENABLED=true +``` + +Alternatively, you can enable it for all environments in `config/statamic/api.php`: + +```php +'enabled' => true, +``` + +Next, you'll need to enable the resources you want to make available. To do this, simply add a `runway` key to your `resources` array in `config/statamic/api.php`, and provide the handles of the resources for which you wish to enable the API: + +```php +'resources' => [ + 'collections' => true, + // ... + 'runway' => [ + 'product' => true, + ], +], +``` + +## Endpoints + +Each resource will have two endpoints: + +* `/api/runway/{resourceHandle}` for retrieving models associated with a resource. +* `/api/runway/{resourceHandle}/{id}` for retrieving a specific model. + +## Filtering + +To enable filtering for your resources, you'll need to opt in by defining a list of `allowed_filters` for each resource in your `config/statamic/api.php` configuration file: + +```php +'runway' => [ + 'product' => [ + 'allowed_filters' => ['name', 'slug'], + ], +], +``` + +## More Information + +For more information on Statamic's REST API functionality, please refer to the [Statamic Documentation](https://statamic.dev/rest-api#entries).