From c8db7aef05557675940c3e610c94c6a2184d90ba Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 20 Dec 2024 10:36:56 +0100 Subject: [PATCH] fix(laravel): jsonapi query parameters (page, sort, fields and include) (#6876) --- src/JsonApi/Filter/SparseFieldset.php | 52 +++++++++++++++ .../SparseFieldsetParameterProvider.php | 62 +++++++++++++++++ src/Laravel/ApiPlatformProvider.php | 26 +++++++- .../Eloquent/Filter/JsonApi/SortFilter.php | 62 +++++++++++++++++ .../JsonApi/SortFilterParameterProvider.php | 55 ++++++++++++++++ ...tPropertyNameCollectionMetadataFactory.php | 2 +- src/Laravel/JsonApi/State/JsonApiProvider.php | 66 +++++++++++++++++++ src/Laravel/Tests/JsonApiTest.php | 36 ++++++++++ src/Laravel/workbench/app/Models/Book.php | 5 ++ src/Metadata/Parameter.php | 2 - .../ParameterProviderFilterInterface.php | 19 ++++++ src/Metadata/PropertiesAwareInterface.php | 23 +++++++ ...meterResourceMetadataCollectionFactory.php | 8 ++- 13 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 src/JsonApi/Filter/SparseFieldset.php create mode 100644 src/JsonApi/Filter/SparseFieldsetParameterProvider.php create mode 100644 src/Laravel/Eloquent/Filter/JsonApi/SortFilter.php create mode 100644 src/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.php create mode 100644 src/Laravel/JsonApi/State/JsonApiProvider.php create mode 100644 src/Metadata/ParameterProviderFilterInterface.php create mode 100644 src/Metadata/PropertiesAwareInterface.php diff --git a/src/JsonApi/Filter/SparseFieldset.php b/src/JsonApi/Filter/SparseFieldset.php new file mode 100644 index 00000000000..0c30f9540be --- /dev/null +++ b/src/JsonApi/Filter/SparseFieldset.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Filter; + +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Parameter as MetadataParameter; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\Metadata\PropertiesAwareInterface; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter; + +final class SparseFieldset implements OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ParameterProviderFilterInterface, PropertiesAwareInterface +{ + public function getSchema(MetadataParameter $parameter): array + { + return [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + ]; + } + + public function getOpenApiParameters(MetadataParameter $parameter): Parameter|array|null + { + return new Parameter( + name: ($k = $parameter->getKey()).'[]', + in: $parameter instanceof QueryParameter ? 'query' : 'header', + description: 'Allows you to reduce the response to contain only the properties you need. If your desired property is nested, you can address it using nested arrays. Example: '.\sprintf( + '%1$s[]={propertyName}&%1$s[]={anotherPropertyName}', + $k + ) + ); + } + + public static function getParameterProvider(): string + { + return SparseFieldsetParameterProvider::class; + } +} diff --git a/src/JsonApi/Filter/SparseFieldsetParameterProvider.php b/src/JsonApi/Filter/SparseFieldsetParameterProvider.php new file mode 100644 index 00000000000..7ded8e9e765 --- /dev/null +++ b/src/JsonApi/Filter/SparseFieldsetParameterProvider.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Filter; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterProviderInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; + +final readonly class SparseFieldsetParameterProvider implements ParameterProviderInterface +{ + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + if (!($operation = $context['operation'] ?? null)) { + return null; + } + + $allowedProperties = $parameter->getExtraProperties()['_properties'] ?? []; + $value = $parameter->getValue(); + $normalizationContext = $operation->getNormalizationContext(); + + if (!\is_array($value)) { + return null; + } + + $properties = []; + $shortName = strtolower($operation->getShortName()); + foreach ($value as $resource => $fields) { + if (strtolower($resource) === $shortName) { + $p = &$properties; + } else { + $properties[$resource] = []; + $p = &$properties[$resource]; + } + + foreach (explode(',', $fields) as $f) { + if (\array_key_exists($f, $allowedProperties)) { + $p[] = $f; + } + } + } + + if (isset($normalizationContext[AbstractNormalizer::ATTRIBUTES])) { + $properties = array_merge_recursive((array) $normalizationContext[AbstractNormalizer::ATTRIBUTES], $properties); + } + + $normalizationContext[AbstractNormalizer::ATTRIBUTES] = $properties; + + return $operation->withNormalizationContext($normalizationContext); + } +} diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index d08a81c5ab4..d0f064f508b 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -57,6 +57,8 @@ use ApiPlatform\Hydra\Serializer\HydraPrefixNameConverter; use ApiPlatform\Hydra\Serializer\PartialCollectionViewNormalizer as HydraPartialCollectionViewNormalizer; use ApiPlatform\Hydra\State\HydraLinkProcessor; +use ApiPlatform\JsonApi\Filter\SparseFieldset; +use ApiPlatform\JsonApi\Filter\SparseFieldsetParameterProvider; use ApiPlatform\JsonApi\JsonSchema\SchemaFactory as JsonApiSchemaFactory; use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as JsonApiCollectionNormalizer; use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer as JsonApiEntrypointNormalizer; @@ -84,6 +86,8 @@ use ApiPlatform\Laravel\Eloquent\Filter\DateFilter; use ApiPlatform\Laravel\Eloquent\Filter\EqualsFilter; use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface as EloquentFilterInterface; +use ApiPlatform\Laravel\Eloquent\Filter\JsonApi\SortFilter; +use ApiPlatform\Laravel\Eloquent\Filter\JsonApi\SortFilterParameterProvider; use ApiPlatform\Laravel\Eloquent\Filter\OrderFilter; use ApiPlatform\Laravel\Eloquent\Filter\PartialSearchFilter; use ApiPlatform\Laravel\Eloquent\Filter\RangeFilter; @@ -106,6 +110,7 @@ use ApiPlatform\Laravel\Exception\ErrorHandler; use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController; use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController; +use ApiPlatform\Laravel\JsonApi\State\JsonApiProvider; use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory; use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory; use ApiPlatform\Laravel\Metadata\CacheResourceCollectionMetadataFactory; @@ -421,7 +426,15 @@ public function register(): void $this->app->bind(OperationMetadataFactoryInterface::class, OperationMetadataFactory::class); - $this->app->tag([EqualsFilter::class, PartialSearchFilter::class, DateFilter::class, OrderFilter::class, RangeFilter::class], EloquentFilterInterface::class); + $this->app->tag([ + EqualsFilter::class, + PartialSearchFilter::class, + DateFilter::class, + OrderFilter::class, + RangeFilter::class, + SortFilter::class, + SparseFieldset::class, + ], EloquentFilterInterface::class); $this->app->bind(FilterQueryExtension::class, function (Application $app) { $tagged = iterator_to_array($app->tagged(EloquentFilterInterface::class)); @@ -468,6 +481,12 @@ public function register(): void return new DeserializeProvider($app->make(ValidateProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class)); }); + if (class_exists(JsonApiProvider::class)) { + $this->app->extend(DeserializeProvider::class, function (ProviderInterface $inner, Application $app) { + return new JsonApiProvider($inner); + }); + } + $this->app->tag([PropertyFilter::class], SerializerFilterInterface::class); $this->app->singleton(SerializerFilterParameterProvider::class, function (Application $app) { @@ -477,7 +496,10 @@ public function register(): void }); $this->app->alias(SerializerFilterParameterProvider::class, 'api_platform.serializer.filter_parameter_provider'); - $this->app->tag([SerializerFilterParameterProvider::class], ParameterProviderInterface::class); + $this->app->singleton(SortFilterParameterProvider::class, function (Application $app) { + return new SortFilterParameterProvider(); + }); + $this->app->tag([SerializerFilterParameterProvider::class, SortFilterParameterProvider::class, SparseFieldsetParameterProvider::class], ParameterProviderInterface::class); $this->app->singleton('filters', function (Application $app) { return new ServiceLocator(array_merge( diff --git a/src/Laravel/Eloquent/Filter/JsonApi/SortFilter.php b/src/Laravel/Eloquent/Filter/JsonApi/SortFilter.php new file mode 100644 index 00000000000..46292a28c8a --- /dev/null +++ b/src/Laravel/Eloquent/Filter/JsonApi/SortFilter.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Eloquent\Filter\JsonApi; + +use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\Metadata\PropertiesAwareInterface; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; + +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, ParameterProviderFilterInterface, PropertiesAwareInterface +{ + public const ASC = 'asc'; + public const DESC = 'desc'; + + /** + * @param Builder $builder + * @param array $context + */ + public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder + { + if (!\is_array($values)) { + return $builder; + } + + foreach ($values as $order => $dir) { + if (self::ASC !== $dir && self::DESC !== $dir) { + continue; + } + + $builder->orderBy($order, $dir); + } + + return $builder; + } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string']; + } + + public static function getParameterProvider(): string + { + return SortFilterParameterProvider::class; + } +} diff --git a/src/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.php b/src/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.php new file mode 100644 index 00000000000..c6ffd33f922 --- /dev/null +++ b/src/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Eloquent\Filter\JsonApi; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterProviderInterface; + +final readonly class SortFilterParameterProvider implements ParameterProviderInterface +{ + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + if (!($operation = $context['operation'] ?? null)) { + return null; + } + + $parameters = $operation->getParameters(); + $properties = $parameter->getExtraProperties()['_properties'] ?? []; + $value = $parameter->getValue(); + if (!\is_string($value)) { + return $operation; + } + + $values = explode(',', $value); + $orderBy = []; + foreach ($values as $v) { + $dir = SortFilter::ASC; + if (str_starts_with($v, '-')) { + $dir = SortFilter::DESC; + $v = substr($v, 1); + } + + if (\array_key_exists($v, $properties)) { + $orderBy[$properties[$v]] = $dir; + } + } + + $parameters->add($parameter->getKey(), $parameter->withExtraProperties( + ['_api_values' => $orderBy] + $parameter->getExtraProperties() + )); + + return $operation->withParameters($parameters); + } +} diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php index 2db75b3972c..fbff507e905 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php @@ -73,7 +73,7 @@ public function create(string $resourceClass, array $options = []): PropertyName } return new PropertyNameCollection( - array_keys($properties) // @phpstan-ignore-line + array_keys($properties) ); } } diff --git a/src/Laravel/JsonApi/State/JsonApiProvider.php b/src/Laravel/JsonApi/State/JsonApiProvider.php new file mode 100644 index 00000000000..78d605f94a5 --- /dev/null +++ b/src/Laravel/JsonApi/State/JsonApiProvider.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\JsonApi\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; + +/** + * This is a copy of ApiPlatform\JsonApi\State\JsonApiProvider without the support of sort,filter and fields as these should be implemented using QueryParameters and specific Filters. + * At some point we want to merge both classes but for now we don't have the SortFilter inside Symfony. + * + * @internal + */ +final class JsonApiProvider implements ProviderInterface +{ + public function __construct(private readonly ProviderInterface $decorated) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $request = $context['request'] ?? null; + + if (!$request || 'jsonapi' !== $request->getRequestFormat()) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + $filters = $request->attributes->get('_api_filters', []); + $queryParameters = $request->query->all(); + + $pageParameter = $queryParameters['page'] ?? null; + if ( + \is_array($pageParameter) + ) { + $filters = array_merge($pageParameter, $filters); + } + + if (isset($pageParameter['offset'])) { + $filters['page'] = $pageParameter['offset']; + unset($filters['offset']); + } + + $includeParameter = $queryParameters['include'] ?? null; + + if ($includeParameter) { + $request->attributes->set('_api_included', explode(',', $includeParameter)); + } + + if ($filters) { + $request->attributes->set('_api_filters', $filters); + } + + return $this->decorated->provide($operation, $uriVariables, $context); + } +} diff --git a/src/Laravel/Tests/JsonApiTest.php b/src/Laravel/Tests/JsonApiTest.php index 95492a9b0c6..ad76814656c 100644 --- a/src/Laravel/Tests/JsonApiTest.php +++ b/src/Laravel/Tests/JsonApiTest.php @@ -17,6 +17,7 @@ use Illuminate\Contracts\Config\Repository; use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; use Workbench\App\Models\Author; @@ -40,6 +41,8 @@ protected function defineEnvironment($app): void $config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]); $config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]); $config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]); + $config->set('api-platform.pagination.items_per_page_parameter_name', 'limit'); + $config->set('app.debug', true); }); } @@ -285,4 +288,37 @@ public function testNotFound(): void 'detail' => 'Not Found', ], $response->json()['errors'][0]); } + + public function testSortParameter(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + DB::enableQueryLog(); + $this->get('/api/books?sort=isbn,-name', headers: ['accept' => 'application/vnd.api+json']); + ['query' => $q] = DB::getQueryLog()[1]; + $this->assertStringContainsString('order by "isbn" asc, "name" desc', $q); + } + + public function testPageParameter(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + DB::enableQueryLog(); + $this->get('/api/books?page[limit]=1&page[offset]=2', headers: ['accept' => 'application/vnd.api+json']); + ['query' => $q] = DB::getQueryLog()[1]; + $this->assertStringContainsString('select * from "books" limit 1 offset 1', $q); + } + + public function testSparseFieldset(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $r = $this->get('/api/books?fields[book]=name,isbn&fields[author]=name&include=author', headers: ['accept' => 'application/vnd.api+json']); + $res = $r->json(); + $attributes = $res['data'][0]['attributes']; + $this->assertArrayHasKey('name', $attributes); + $this->assertArrayHasKey('isbn', $attributes); + $this->assertArrayNotHasKey('isAvailable', $attributes); + + $included = $res['included'][0]['attributes']; + $this->assertArrayNotHasKey('createdAt', $included); + $this->assertArrayHasKey('name', $included); + } } diff --git a/src/Laravel/workbench/app/Models/Book.php b/src/Laravel/workbench/app/Models/Book.php index d832b6579da..06c638e38fd 100644 --- a/src/Laravel/workbench/app/Models/Book.php +++ b/src/Laravel/workbench/app/Models/Book.php @@ -13,8 +13,10 @@ namespace Workbench\App\Models; +use ApiPlatform\JsonApi\Filter\SparseFieldset; use ApiPlatform\Laravel\Eloquent\Filter\DateFilter; use ApiPlatform\Laravel\Eloquent\Filter\EqualsFilter; +use ApiPlatform\Laravel\Eloquent\Filter\JsonApi\SortFilter; use ApiPlatform\Laravel\Eloquent\Filter\OrderFilter; use ApiPlatform\Laravel\Eloquent\Filter\OrFilter; use ApiPlatform\Laravel\Eloquent\Filter\PartialSearchFilter; @@ -40,6 +42,7 @@ #[ApiResource( paginationEnabled: true, paginationItemsPerPage: 5, + paginationClientItemsPerPage: true, rules: BookFormRequest::class, operations: [ new Put(), @@ -76,6 +79,8 @@ property: 'name' )] #[QueryParameter(key: 'properties', filter: PropertyFilter::class)] +#[QueryParameter(key: 'fields', filter: SparseFieldset::class)] +#[QueryParameter(key: 'sort', filter: SortFilter::class)] class Book extends Model { use HasFactory; diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index a92ab4e0382..49effdca15e 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -121,8 +121,6 @@ public function getSecurityMessage(): ?string /** * The computed value of this parameter, located into extraProperties['_api_values']. - * - * @readonly */ public function getValue(mixed $default = new ParameterNotFound()): mixed { diff --git a/src/Metadata/ParameterProviderFilterInterface.php b/src/Metadata/ParameterProviderFilterInterface.php new file mode 100644 index 00000000000..e64548f53fd --- /dev/null +++ b/src/Metadata/ParameterProviderFilterInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +interface ParameterProviderFilterInterface +{ + public static function getParameterProvider(): string; +} diff --git a/src/Metadata/PropertiesAwareInterface.php b/src/Metadata/PropertiesAwareInterface.php new file mode 100644 index 00000000000..cc948a7c331 --- /dev/null +++ b/src/Metadata/PropertiesAwareInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +/** + * This interface makes a Parameter aware of the properties it can filter on. + * It can be set on a Filter or a Parameter, properties are available in + * extraProperties['_properties']. + */ +interface PropertiesAwareInterface +{ +} diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 22ea107bc18..6722bc1ae1c 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -21,7 +21,9 @@ use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\PropertiesAwareInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -126,7 +128,7 @@ private function getDefaultParameters(Operation $operation, string $resourceClas $key = $parameter->getKey() ?? $key; - if (str_contains($key, ':property')) { + if (str_contains($key, ':property') || (($f = $parameter->getFilter()) && is_a($f, PropertiesAwareInterface::class, true)) || $parameter instanceof PropertiesAwareInterface) { $p = []; foreach ($propertyNames as $prop) { $p[$this->nameConverter?->denormalize($prop) ?? $prop] = $prop; @@ -155,6 +157,10 @@ private function addFilterMetadata(Parameter $parameter): Parameter $filter = \is_object($filterId) ? $filterId : $this->filterLocator->get($filterId); + if ($filter instanceof ParameterProviderFilterInterface) { + $parameter = $parameter->withProvider($filter::getParameterProvider()); + } + if (!$filter) { return $parameter; }