Skip to content

Commit

Permalink
Support query scopes in REST API
Browse files Browse the repository at this point in the history
  • Loading branch information
ryanmitchell committed Oct 3, 2024
1 parent 346acb7 commit 566e9a5
Show file tree
Hide file tree
Showing 10 changed files with 141 additions and 5 deletions.
12 changes: 7 additions & 5 deletions src/API/FilterAuthorizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

class FilterAuthorizer extends AbstractAuthorizer
{
protected $configKey = 'allowed_filters';

/**
* Get allowed filters for resource.
*
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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();
}
}
8 changes: 8 additions & 0 deletions src/API/QueryScopeAuthorizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Statamic\API;

class QueryScopeAuthorizer extends FilterAuthorizer
{
protected $configKey = 'allowed_query_scopes';
}
62 changes: 62 additions & 0 deletions src/Http/Controllers/API/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
use Facades\Statamic\API\ResourceAuthorizer;
use Statamic\Exceptions\ApiValidationException;
use Statamic\Exceptions\NotFoundHttpException;
use Statamic\Facades\Scope;
use Statamic\Facades\Site;
use Statamic\Http\Controllers\Controller;
use Statamic\Support\Arr;
use Statamic\Support\Str;
use Statamic\Tags\Concerns\QueriesConditions;

Expand Down Expand Up @@ -80,12 +82,26 @@ protected function filterAllowedResources($items)
*
* @param \Statamic\Query\Builder $query
* @return \Statamic\Extensions\Pagination\LengthAwarePaginator
*
* @deprecated
*/
protected function filterSortAndPaginate($query)
{
return $this->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);
}

Expand Down Expand Up @@ -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.
*
Expand Down
6 changes: 6 additions & 0 deletions src/Http/Controllers/API/AssetsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,4 +38,9 @@ protected function allowedFilters()
{
return FilterAuthorizer::allowedForSubResources('api', 'assets', $this->containerHandle);
}

protected function allowedQueryScopes()
{
return QueryScopeAuthorizer::allowedForSubResources('api', 'assets', $this->containerHandle);
}
}
6 changes: 6 additions & 0 deletions src/Http/Controllers/API/CollectionEntriesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,4 +82,9 @@ protected function allowedFilters()
{
return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle);
}

protected function allowedQueryScopes()
{
return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle);
}
}
6 changes: 6 additions & 0 deletions src/Http/Controllers/API/CollectionTreeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,4 +49,9 @@ protected function allowedFilters()
{
return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle);
}

protected function allowedQueryScopes()
{
return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle);
}
}
6 changes: 6 additions & 0 deletions src/Http/Controllers/API/TaxonomyTermEntriesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,4 +73,9 @@ protected function allowedFilters()
{
return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->allowedCollections);
}

protected function allowedQueryScopes()
{
return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->allowedCollections);
}
}
6 changes: 6 additions & 0 deletions src/Http/Controllers/API/TaxonomyTermsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,4 +44,9 @@ protected function allowedFilters()
{
return FilterAuthorizer::allowedForSubResources('api', 'taxonomies', $this->taxonomyHandle);
}

protected function allowedQueryScopes()
{
return QueryScopeAuthorizer::allowedForSubResources('api', 'taxonomies', $this->taxonomyHandle);
}
}
6 changes: 6 additions & 0 deletions src/Http/Controllers/API/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
}
28 changes: 28 additions & 0 deletions tests/API/APITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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]);
}
}

0 comments on commit 566e9a5

Please sign in to comment.