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). 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/Controllers/ApiController.php b/src/Http/Controllers/ApiController.php new file mode 100644 index 00000000..c5e12d54 --- /dev/null +++ b/src/Http/Controllers/ApiController.php @@ -0,0 +1,75 @@ +abortIfDisabled(); + + $this->resourceHandle = Str::singular($resourceHandle); + + $resource = Runway::findResource($this->resourceHandle); + + if (! $resource) { + throw new NotFoundHttpException; + } + + $results = $this->filterSortAndPaginate($resource->model()->query()); + + $results = ApiResource::collection($results); + + $results->setCollection( + $results->getCollection()->transform(fn ($result) => $result->withBlueprintFields($this->getFieldsFromBlueprint($resource))) + ); + + return $results; + } + + public function show($resourceHandle, $record) + { + $this->abortIfDisabled(); + + $this->resourceHandle = Str::singular($resourceHandle); + + $resource = Runway::findResource($this->resourceHandle); + + if (! $resource) { + throw new NotFoundHttpException; + } + + if (! $model = $resource->model()->find($record)) { + throw new NotFoundHttpException; + } + + return ApiResource::make($model)->withBlueprintFields($this->getFieldsFromBlueprint($resource)); + } + + protected function allowedFilters() + { + return FilterAuthorizer::allowedForSubResources('api', $this->resourceConfigKey, Str::plural($this->resourceHandle)); + } + + private function getFieldsFromBlueprint(Resource $resource): Collection + { + return $resource->blueprint()->fields()->all(); + } +} diff --git a/src/Http/Resources/ApiResource.php b/src/Http/Resources/ApiResource.php new file mode 100644 index 00000000..76cd638a --- /dev/null +++ b/src/Http/Resources/ApiResource.php @@ -0,0 +1,50 @@ +resource + ->toAugmentedCollection($this->blueprintFields->map->handle()->all() ?? []) + ->withRelations($this->blueprintFields->filter->isRelationship()->keys()->all()) + ->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(), + ]); + } + + /** + * Set the fields that should be returned by this resource + * + * @return self + */ + public function withBlueprintFields(Collection $fields) + { + $this->blueprintFields = $fields; + + return $this; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 8b231c18..bb5342fb 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,6 +2,7 @@ namespace DoubleThreeDigital\Runway; +use DoubleThreeDigital\Runway\Http\Controllers\ApiController; use DoubleThreeDigital\Runway\Policies\ResourcePolicy; use DoubleThreeDigital\Runway\Search\Provider as SearchProvider; use DoubleThreeDigital\Runway\Search\Searchable; @@ -9,10 +10,13 @@ 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; @@ -82,6 +86,7 @@ public function boot() $this->registerPolicies(); $this->registerNavigation(); $this->bootGraphQl(); + $this->bootApi(); SearchProvider::register(); $this->bootModelEventListeners(); @@ -161,6 +166,25 @@ protected function bootGraphQl() }); } + protected function bootApi() + { + 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 () { + Route::name('runway.index')->get('runway/{resourceHandle}', [ApiController::class, 'index']); + Route::name('runway.show')->get('runway/{resourceHandle}/{record}', [ApiController::class, 'show']); + }); + }); + } + } + protected function bootModelEventListeners() { Runway::allResources() 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); diff --git a/tests/Http/Controllers/ApiControllerTest.php b/tests/Http/Controllers/ApiControllerTest.php new file mode 100644 index 00000000..abd8802e --- /dev/null +++ b/tests/Http/Controllers/ApiControllerTest.php @@ -0,0 +1,161 @@ + ['allowed_filters' => ['title']], + ]); + } + + /** @test */ + public function gets_a_resource_that_exists() + { + $posts = Post::factory()->count(2)->create(); + + $this + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts'])) + ->assertOk() + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('data.0.id', $posts[0]->id) + ->assertJsonPath('data.1.id', $posts[1]->id); + } + + /** @test */ + public function returns_not_found_on_a_resource_that_doesnt_exist() + { + Post::factory()->count(2)->create(); + + $this + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts2'])) + ->assertNotFound(); + } + + /** @test */ + public function gets_a_resource_model_that_exists() + { + $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.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 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() + { + $this + ->get(route('statamic.api.runway.show', ['resourceHandle' => 'posts', 'record' => 44])) + ->assertNotFound(); + } + + /** @test */ + public function paginates_a_resource_list() + { + Post::factory()->count(10)->create(); + + $this + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts', 'limit' => 5])) + ->assertOk() + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.last_page', 2) + ->assertJsonPath('meta.total', 10); + + $this + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts', 'limit' => 5, 'page' => 2])) + ->assertOk() + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('meta.current_page', 2) + ->assertJsonPath('meta.last_page', 2) + ->assertJsonPath('meta.total', 10); + } + + /** @test */ + public function filters_a_resource_list() + { + [$postA, $postB, $postC] = Post::factory()->count(3)->create(); + + $postA->update(['title' => 'Test One']); + $postB->update(['title' => 'Test Two']); + $postC->update(['title' => 'Test Three']); + + $this + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts'])) + ->assertOk() + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('meta.total', 3); + + $this + ->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', ['resourceHandle' => 'posts', 'filter[title:contains]' => 'test'])) + ->assertOk() + ->assertJsonStructure(['data', 'meta']) + ->assertJsonPath('meta.total', 3); + } + + /** @test */ + public function wont_filter_a_resource_list_on_a_forbidden_filter() + { + [$postA, $postB, $postC] = Post::factory()->count(3)->create(); + + $postA->update(['title' => 'Test One']); + $postB->update(['title' => 'Test Two']); + $postC->update(['title' => 'Test Three']); + + $this + ->get(route('statamic.api.runway.index', ['resourceHandle' => 'posts', 'filter[slug:contains]' => 'one'])) + ->assertStatus(422); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index deb525bb..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', [