From 566e9a5390b9ae18c1cba3e8389a7d09887b53fd Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Thu, 3 Oct 2024 07:40:07 +0100 Subject: [PATCH 1/3] Support query scopes in REST API --- src/API/FilterAuthorizer.php | 12 ++-- src/API/QueryScopeAuthorizer.php | 8 +++ src/Http/Controllers/API/ApiController.php | 62 +++++++++++++++++++ src/Http/Controllers/API/AssetsController.php | 6 ++ .../API/CollectionEntriesController.php | 6 ++ .../API/CollectionTreeController.php | 6 ++ .../API/TaxonomyTermEntriesController.php | 6 ++ .../API/TaxonomyTermsController.php | 6 ++ src/Http/Controllers/API/UsersController.php | 6 ++ tests/API/APITest.php | 28 +++++++++ 10 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 src/API/QueryScopeAuthorizer.php diff --git a/src/API/FilterAuthorizer.php b/src/API/FilterAuthorizer.php index 2a7f6e90078..c66f77dce6f 100644 --- a/src/API/FilterAuthorizer.php +++ b/src/API/FilterAuthorizer.php @@ -6,6 +6,8 @@ class FilterAuthorizer extends AbstractAuthorizer { + protected $configKey = 'allowed_filters'; + /** * Get allowed filters for resource. * @@ -17,7 +19,7 @@ class FilterAuthorizer extends AbstractAuthorizer */ public function allowedForResource($configFile, $queriedResource) { - $config = config("statamic.{$configFile}.resources.{$queriedResource}.allowed_filters"); + $config = config("statamic.{$configFile}.resources.{$queriedResource}.{$this->configKey}"); // Use explicitly configured `allowed_filters` array, otherwise no filters should be allowed. return is_array($config) @@ -54,7 +56,7 @@ public function allowedForSubResources($configFile, $queriedResource, $queriedHa // Determine if any of our queried resources have filters explicitly disabled. $disabled = $resources - ->filter(fn ($resource) => Arr::get($config, "{$resource}.allowed_filters") === false) + ->filter(fn ($resource) => Arr::get($config, "{$resource}.{$this->configKey}") === false) ->isNotEmpty(); // If any queried resource is explicitly disabled, then no filters should be allowed. @@ -65,10 +67,10 @@ public function allowedForSubResources($configFile, $queriedResource, $queriedHa // Determine `allowed_filters` by filtering out any that don't appear in all of them. // A resource named `*` will apply to all enabled resources at once. return $resources - ->map(fn ($resource) => $config[$resource]['allowed_filters'] ?? []) + ->map(fn ($resource) => $config[$resource][$this->configKey] ?? []) ->reduce(function ($carry, $allowedFilters) use ($config) { - return $carry->intersect($allowedFilters)->merge($config['*']['allowed_filters'] ?? []); - }, collect($config[$resources[0] ?? '']['allowed_filters'] ?? [])) + return $carry->intersect($allowedFilters)->merge($config['*'][$this->configKey] ?? []); + }, collect($config[$resources[0] ?? ''][$this->configKey] ?? [])) ->all(); } } diff --git a/src/API/QueryScopeAuthorizer.php b/src/API/QueryScopeAuthorizer.php new file mode 100644 index 00000000000..91b5c985046 --- /dev/null +++ b/src/API/QueryScopeAuthorizer.php @@ -0,0 +1,8 @@ +filterSortScopeAndPaginate($query); + } + + /** + * Filter, sort, scope, and paginate query for API resource output. + * + * @param \Statamic\Query\Builder $query + * @return \Statamic\Extensions\Pagination\LengthAwarePaginator + */ + protected function filterSortScopeAndPaginate($query) { return $this ->filter($query) ->sort($query) + ->scope($query) ->paginate($query); } @@ -171,6 +187,52 @@ protected function doesntHaveFilter($field) ->contains($field); } + /** + * Apply query scopes a query based on conditions in the query_scope parameter. + * + * /endpoint?query_scope[scope_handle]=foo&query_scope[another_scope]=bar + * + * @param \Statamic\Query\Builder $query + * @return $this + */ + protected function scope($query) + { + $this->getScopes() + ->each(function ($value, $handle) use ($query) { + Scope::find($handle)?->apply($query, Arr::wrap($value)); + }); + + return $this; + } + + /** + * Get scopes for querying. + * + * @return \Illuminate\Support\Collection + */ + protected function getScopes() + { + if (! method_exists($this, 'allowedQueryScopes')) { + return collect(); + } + + $scopes = collect(request()->query_scope ?? []); + + $allowedScopes = collect($this->allowedQueryScopes()); + + $forbidden = $scopes + ->keys() + ->filter(fn ($handle) => ! Scope::find($handle) || ! $allowedScopes->contains($handle)); + + if ($forbidden->isNotEmpty()) { + throw ApiValidationException::withMessages([ + 'query_scope' => Str::plural('Forbidden query scope', $forbidden).': '.$forbidden->join(', '), + ]); + } + + return $scopes; + } + /** * Sorts the query based on the sort parameter. * diff --git a/src/Http/Controllers/API/AssetsController.php b/src/Http/Controllers/API/AssetsController.php index 9d144b4834f..bd69eee02e1 100644 --- a/src/Http/Controllers/API/AssetsController.php +++ b/src/Http/Controllers/API/AssetsController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Http\Resources\API\AssetResource; class AssetsController extends ApiController @@ -37,4 +38,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'assets', $this->containerHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'assets', $this->containerHandle); + } } diff --git a/src/Http/Controllers/API/CollectionEntriesController.php b/src/Http/Controllers/API/CollectionEntriesController.php index 892d823c966..0302f5fdf48 100644 --- a/src/Http/Controllers/API/CollectionEntriesController.php +++ b/src/Http/Controllers/API/CollectionEntriesController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Entry; use Statamic\Http\Resources\API\EntryResource; @@ -81,4 +82,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); + } } diff --git a/src/Http/Controllers/API/CollectionTreeController.php b/src/Http/Controllers/API/CollectionTreeController.php index 9a58dbbe723..2a5cd9f0e9d 100644 --- a/src/Http/Controllers/API/CollectionTreeController.php +++ b/src/Http/Controllers/API/CollectionTreeController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Http\Resources\API\TreeResource; use Statamic\Query\ItemQueryBuilder; @@ -48,4 +49,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); + } } diff --git a/src/Http/Controllers/API/TaxonomyTermEntriesController.php b/src/Http/Controllers/API/TaxonomyTermEntriesController.php index 7b732c99205..2aa261947f4 100644 --- a/src/Http/Controllers/API/TaxonomyTermEntriesController.php +++ b/src/Http/Controllers/API/TaxonomyTermEntriesController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Facades\Statamic\API\ResourceAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Collection; @@ -72,4 +73,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->allowedCollections); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->allowedCollections); + } } diff --git a/src/Http/Controllers/API/TaxonomyTermsController.php b/src/Http/Controllers/API/TaxonomyTermsController.php index b97b50f96f5..4e71f1ebb7e 100644 --- a/src/Http/Controllers/API/TaxonomyTermsController.php +++ b/src/Http/Controllers/API/TaxonomyTermsController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Term; use Statamic\Http\Resources\API\TermResource; @@ -43,4 +44,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'taxonomies', $this->taxonomyHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'taxonomies', $this->taxonomyHandle); + } } diff --git a/src/Http/Controllers/API/UsersController.php b/src/Http/Controllers/API/UsersController.php index de0a0a2f899..fc4ab91376d 100644 --- a/src/Http/Controllers/API/UsersController.php +++ b/src/Http/Controllers/API/UsersController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\User; use Statamic\Http\Resources\API\UserResource; @@ -42,4 +43,9 @@ protected function allowedFilters() ->reject(fn ($field) => in_array($field, ['password', 'password_hash'])) ->all(); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForResource('api', 'users'); + } } diff --git a/tests/API/APITest.php b/tests/API/APITest.php index 4b195927024..9c15e2e5553 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -10,6 +10,7 @@ use Statamic\Facades\Blueprint; use Statamic\Facades\Token; use Statamic\Facades\User; +use Statamic\Query\Scopes\Scope; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -136,6 +137,25 @@ public function it_filters_out_past_entries_from_past_private_collection() $response->assertJsonPath('data.0.id', 'a'); } + #[Test] + public function it_can_use_a_query_scope_on_collection_entries_when_configuration_allows_for_it() + { + app('statamic.scopes')['test_scope'] = TestScope::class; + + Facades\Config::set('statamic.api.resources.collections.pages', [ + 'allowed_query_scopes' => ['test_scope'], + ]); + + Facades\Collection::make('pages')->save(); + + Facades\Entry::make()->collection('pages')->id('about')->slug('about')->published(true)->save(); + Facades\Entry::make()->collection('pages')->id('dance')->slug('dance')->published(true)->save(); + Facades\Entry::make()->collection('pages')->id('nectar')->slug('nectar')->published(true)->save(); + + $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][]=is&query_scope[test_scope][]=about', 1); + $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][]=isnt&query_scope[test_scope][]=about', 2); + } + #[Test] public function it_can_filter_collection_entries_when_configuration_allows_for_it() { @@ -592,3 +612,11 @@ public function handle(\Statamic\Contracts\Tokens\Token $token, \Illuminate\Http return $next($token); } } + +class TestScope extends Scope +{ + public function apply($query, $values) + { + $query->where('id', $values[0] == 'is' ? '=' : '!=', $values[1]); + } +} From 907595697e63467280a3f86f5e31efddf7cd0814 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Thu, 3 Oct 2024 07:44:52 +0100 Subject: [PATCH 2/3] Use handles rather than numeric keys --- tests/API/APITest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/API/APITest.php b/tests/API/APITest.php index 9c15e2e5553..68792463d85 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -152,8 +152,8 @@ public function it_can_use_a_query_scope_on_collection_entries_when_configuratio Facades\Entry::make()->collection('pages')->id('dance')->slug('dance')->published(true)->save(); Facades\Entry::make()->collection('pages')->id('nectar')->slug('nectar')->published(true)->save(); - $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][]=is&query_scope[test_scope][]=about', 1); - $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][]=isnt&query_scope[test_scope][]=about', 2); + $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][operator]=is&query_scope[test_scope][value]=about', 1); + $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][operator]=isnt&query_scope[test_scope][value]=about', 2); } #[Test] @@ -617,6 +617,6 @@ class TestScope extends Scope { public function apply($query, $values) { - $query->where('id', $values[0] == 'is' ? '=' : '!=', $values[1]); + $query->where('id', $values['operator'] == 'is' ? '=' : '!=', $values['value']); } } From a92768745659bc684dab79f5f4784c1561b1909c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 25 Mar 2025 14:48:41 -0400 Subject: [PATCH 3/3] use more generic method name --- src/Http/Controllers/API/ApiController.php | 4 ++-- src/Http/Controllers/API/AssetsController.php | 2 +- src/Http/Controllers/API/CollectionEntriesController.php | 2 +- src/Http/Controllers/API/TaxonomyTermEntriesController.php | 2 +- src/Http/Controllers/API/TaxonomyTermsController.php | 2 +- src/Http/Controllers/API/UsersController.php | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Http/Controllers/API/ApiController.php b/src/Http/Controllers/API/ApiController.php index 8ac5ad975f2..dfd61b05fab 100644 --- a/src/Http/Controllers/API/ApiController.php +++ b/src/Http/Controllers/API/ApiController.php @@ -87,7 +87,7 @@ protected function filterAllowedResources($items) */ protected function filterSortAndPaginate($query) { - return $this->filterSortScopeAndPaginate($query); + return $this->updateAndPaginate($query); } /** @@ -96,7 +96,7 @@ protected function filterSortAndPaginate($query) * @param \Statamic\Query\Builder $query * @return \Statamic\Extensions\Pagination\LengthAwarePaginator */ - protected function filterSortScopeAndPaginate($query) + protected function updateAndPaginate($query) { return $this ->filter($query) diff --git a/src/Http/Controllers/API/AssetsController.php b/src/Http/Controllers/API/AssetsController.php index bd69eee02e1..3fc9ebc61f1 100644 --- a/src/Http/Controllers/API/AssetsController.php +++ b/src/Http/Controllers/API/AssetsController.php @@ -23,7 +23,7 @@ public function index($assetContainer) ->filter->isRelationship()->keys()->all(); return app(AssetResource::class)::collection( - $this->filterSortAndPaginate($assetContainer->queryAssets()->with($with)) + $this->updateAndPaginate($assetContainer->queryAssets()->with($with)) ); } diff --git a/src/Http/Controllers/API/CollectionEntriesController.php b/src/Http/Controllers/API/CollectionEntriesController.php index 0302f5fdf48..13b3d7c73b8 100644 --- a/src/Http/Controllers/API/CollectionEntriesController.php +++ b/src/Http/Controllers/API/CollectionEntriesController.php @@ -30,7 +30,7 @@ public function index($collection) ->filter->isRelationship()->keys()->all(); return app(EntryResource::class)::collection( - $this->filterSortAndPaginate($collection->queryEntries()->with($with)) + $this->updateAndPaginate($collection->queryEntries()->with($with)) ); } diff --git a/src/Http/Controllers/API/TaxonomyTermEntriesController.php b/src/Http/Controllers/API/TaxonomyTermEntriesController.php index 2aa261947f4..cf92e8b3e2b 100644 --- a/src/Http/Controllers/API/TaxonomyTermEntriesController.php +++ b/src/Http/Controllers/API/TaxonomyTermEntriesController.php @@ -47,7 +47,7 @@ public function index($taxonomy, $term) $with = $this->getRelationshipFieldsFromCollections($taxonomy); return app(EntryResource::class)::collection( - $this->filterSortAndPaginate($query->with($with)) + $this->updateAndPaginate($query->with($with)) ); } diff --git a/src/Http/Controllers/API/TaxonomyTermsController.php b/src/Http/Controllers/API/TaxonomyTermsController.php index 4e71f1ebb7e..93d40fa860b 100644 --- a/src/Http/Controllers/API/TaxonomyTermsController.php +++ b/src/Http/Controllers/API/TaxonomyTermsController.php @@ -25,7 +25,7 @@ public function index($taxonomy) ->filter->isRelationship()->keys()->all(); return app(TermResource::class)::collection( - $this->filterSortAndPaginate($taxonomy->queryTerms()->with($with)) + $this->updateAndPaginate($taxonomy->queryTerms()->with($with)) ); } diff --git a/src/Http/Controllers/API/UsersController.php b/src/Http/Controllers/API/UsersController.php index fc4ab91376d..25d9ca367e9 100644 --- a/src/Http/Controllers/API/UsersController.php +++ b/src/Http/Controllers/API/UsersController.php @@ -17,7 +17,7 @@ public function index() $this->abortIfDisabled(); return app(UserResource::class)::collection( - $this->filterSortAndPaginate(User::query()) + $this->updateAndPaginate(User::query()) ); }