Skip to content

Commit bb5f03d

Browse files
authored
fix(laravel): jsonapi query parameters (page, sort, fields and include) (#6876)
1 parent 5818e80 commit bb5f03d

File tree

7 files changed

+249
-3
lines changed

7 files changed

+249
-3
lines changed

ApiPlatformProvider.php

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
use ApiPlatform\Hydra\Serializer\HydraPrefixNameConverter;
5858
use ApiPlatform\Hydra\Serializer\PartialCollectionViewNormalizer as HydraPartialCollectionViewNormalizer;
5959
use ApiPlatform\Hydra\State\HydraLinkProcessor;
60+
use ApiPlatform\JsonApi\Filter\SparseFieldset;
61+
use ApiPlatform\JsonApi\Filter\SparseFieldsetParameterProvider;
6062
use ApiPlatform\JsonApi\JsonSchema\SchemaFactory as JsonApiSchemaFactory;
6163
use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as JsonApiCollectionNormalizer;
6264
use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer as JsonApiEntrypointNormalizer;
@@ -84,6 +86,8 @@
8486
use ApiPlatform\Laravel\Eloquent\Filter\DateFilter;
8587
use ApiPlatform\Laravel\Eloquent\Filter\EqualsFilter;
8688
use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface as EloquentFilterInterface;
89+
use ApiPlatform\Laravel\Eloquent\Filter\JsonApi\SortFilter;
90+
use ApiPlatform\Laravel\Eloquent\Filter\JsonApi\SortFilterParameterProvider;
8791
use ApiPlatform\Laravel\Eloquent\Filter\OrderFilter;
8892
use ApiPlatform\Laravel\Eloquent\Filter\PartialSearchFilter;
8993
use ApiPlatform\Laravel\Eloquent\Filter\RangeFilter;
@@ -106,6 +110,7 @@
106110
use ApiPlatform\Laravel\Exception\ErrorHandler;
107111
use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController;
108112
use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController;
113+
use ApiPlatform\Laravel\JsonApi\State\JsonApiProvider;
109114
use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory;
110115
use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory;
111116
use ApiPlatform\Laravel\Metadata\CacheResourceCollectionMetadataFactory;
@@ -421,7 +426,15 @@ public function register(): void
421426

422427
$this->app->bind(OperationMetadataFactoryInterface::class, OperationMetadataFactory::class);
423428

424-
$this->app->tag([EqualsFilter::class, PartialSearchFilter::class, DateFilter::class, OrderFilter::class, RangeFilter::class], EloquentFilterInterface::class);
429+
$this->app->tag([
430+
EqualsFilter::class,
431+
PartialSearchFilter::class,
432+
DateFilter::class,
433+
OrderFilter::class,
434+
RangeFilter::class,
435+
SortFilter::class,
436+
SparseFieldset::class,
437+
], EloquentFilterInterface::class);
425438

426439
$this->app->bind(FilterQueryExtension::class, function (Application $app) {
427440
$tagged = iterator_to_array($app->tagged(EloquentFilterInterface::class));
@@ -468,6 +481,12 @@ public function register(): void
468481
return new DeserializeProvider($app->make(ValidateProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class));
469482
});
470483

484+
if (class_exists(JsonApiProvider::class)) {
485+
$this->app->extend(DeserializeProvider::class, function (ProviderInterface $inner, Application $app) {
486+
return new JsonApiProvider($inner);
487+
});
488+
}
489+
471490
$this->app->tag([PropertyFilter::class], SerializerFilterInterface::class);
472491

473492
$this->app->singleton(SerializerFilterParameterProvider::class, function (Application $app) {
@@ -477,7 +496,10 @@ public function register(): void
477496
});
478497
$this->app->alias(SerializerFilterParameterProvider::class, 'api_platform.serializer.filter_parameter_provider');
479498

480-
$this->app->tag([SerializerFilterParameterProvider::class], ParameterProviderInterface::class);
499+
$this->app->singleton(SortFilterParameterProvider::class, function (Application $app) {
500+
return new SortFilterParameterProvider();
501+
});
502+
$this->app->tag([SerializerFilterParameterProvider::class, SortFilterParameterProvider::class, SparseFieldsetParameterProvider::class], ParameterProviderInterface::class);
481503

482504
$this->app->singleton('filters', function (Application $app) {
483505
return new ServiceLocator(array_merge(
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Eloquent\Filter\JsonApi;
15+
16+
use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface;
17+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
18+
use ApiPlatform\Metadata\Parameter;
19+
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
20+
use ApiPlatform\Metadata\PropertiesAwareInterface;
21+
use Illuminate\Database\Eloquent\Builder;
22+
use Illuminate\Database\Eloquent\Model;
23+
24+
final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, ParameterProviderFilterInterface, PropertiesAwareInterface
25+
{
26+
public const ASC = 'asc';
27+
public const DESC = 'desc';
28+
29+
/**
30+
* @param Builder<Model> $builder
31+
* @param array<string, mixed> $context
32+
*/
33+
public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder
34+
{
35+
if (!\is_array($values)) {
36+
return $builder;
37+
}
38+
39+
foreach ($values as $order => $dir) {
40+
if (self::ASC !== $dir && self::DESC !== $dir) {
41+
continue;
42+
}
43+
44+
$builder->orderBy($order, $dir);
45+
}
46+
47+
return $builder;
48+
}
49+
50+
/**
51+
* @return array<string, mixed>
52+
*/
53+
public function getSchema(Parameter $parameter): array
54+
{
55+
return ['type' => 'string'];
56+
}
57+
58+
public static function getParameterProvider(): string
59+
{
60+
return SortFilterParameterProvider::class;
61+
}
62+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Eloquent\Filter\JsonApi;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\Metadata\Parameter;
18+
use ApiPlatform\State\ParameterProviderInterface;
19+
20+
final readonly class SortFilterParameterProvider implements ParameterProviderInterface
21+
{
22+
public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation
23+
{
24+
if (!($operation = $context['operation'] ?? null)) {
25+
return null;
26+
}
27+
28+
$parameters = $operation->getParameters();
29+
$properties = $parameter->getExtraProperties()['_properties'] ?? [];
30+
$value = $parameter->getValue();
31+
if (!\is_string($value)) {
32+
return $operation;
33+
}
34+
35+
$values = explode(',', $value);
36+
$orderBy = [];
37+
foreach ($values as $v) {
38+
$dir = SortFilter::ASC;
39+
if (str_starts_with($v, '-')) {
40+
$dir = SortFilter::DESC;
41+
$v = substr($v, 1);
42+
}
43+
44+
if (\array_key_exists($v, $properties)) {
45+
$orderBy[$properties[$v]] = $dir;
46+
}
47+
}
48+
49+
$parameters->add($parameter->getKey(), $parameter->withExtraProperties(
50+
['_api_values' => $orderBy] + $parameter->getExtraProperties()
51+
));
52+
53+
return $operation->withParameters($parameters);
54+
}
55+
}

Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public function create(string $resourceClass, array $options = []): PropertyName
7373
}
7474

7575
return new PropertyNameCollection(
76-
array_keys($properties) // @phpstan-ignore-line
76+
array_keys($properties)
7777
);
7878
}
7979
}

JsonApi/State/JsonApiProvider.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\JsonApi\State;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\State\ProviderInterface;
18+
19+
/**
20+
* 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.
21+
* At some point we want to merge both classes but for now we don't have the SortFilter inside Symfony.
22+
*
23+
* @internal
24+
*/
25+
final class JsonApiProvider implements ProviderInterface
26+
{
27+
public function __construct(private readonly ProviderInterface $decorated)
28+
{
29+
}
30+
31+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
32+
{
33+
$request = $context['request'] ?? null;
34+
35+
if (!$request || 'jsonapi' !== $request->getRequestFormat()) {
36+
return $this->decorated->provide($operation, $uriVariables, $context);
37+
}
38+
39+
$filters = $request->attributes->get('_api_filters', []);
40+
$queryParameters = $request->query->all();
41+
42+
$pageParameter = $queryParameters['page'] ?? null;
43+
if (
44+
\is_array($pageParameter)
45+
) {
46+
$filters = array_merge($pageParameter, $filters);
47+
}
48+
49+
if (isset($pageParameter['offset'])) {
50+
$filters['page'] = $pageParameter['offset'];
51+
unset($filters['offset']);
52+
}
53+
54+
$includeParameter = $queryParameters['include'] ?? null;
55+
56+
if ($includeParameter) {
57+
$request->attributes->set('_api_included', explode(',', $includeParameter));
58+
}
59+
60+
if ($filters) {
61+
$request->attributes->set('_api_filters', $filters);
62+
}
63+
64+
return $this->decorated->provide($operation, $uriVariables, $context);
65+
}
66+
}

Tests/JsonApiTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Illuminate\Contracts\Config\Repository;
1818
use Illuminate\Foundation\Application;
1919
use Illuminate\Foundation\Testing\RefreshDatabase;
20+
use Illuminate\Support\Facades\DB;
2021
use Orchestra\Testbench\Concerns\WithWorkbench;
2122
use Orchestra\Testbench\TestCase;
2223
use Workbench\App\Models\Author;
@@ -40,6 +41,8 @@ protected function defineEnvironment($app): void
4041
$config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]);
4142
$config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]);
4243
$config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]);
44+
$config->set('api-platform.pagination.items_per_page_parameter_name', 'limit');
45+
4346
$config->set('app.debug', true);
4447
});
4548
}
@@ -285,4 +288,37 @@ public function testNotFound(): void
285288
'detail' => 'Not Found',
286289
], $response->json()['errors'][0]);
287290
}
291+
292+
public function testSortParameter(): void
293+
{
294+
BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
295+
DB::enableQueryLog();
296+
$this->get('/api/books?sort=isbn,-name', headers: ['accept' => 'application/vnd.api+json']);
297+
['query' => $q] = DB::getQueryLog()[1];
298+
$this->assertStringContainsString('order by "isbn" asc, "name" desc', $q);
299+
}
300+
301+
public function testPageParameter(): void
302+
{
303+
BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
304+
DB::enableQueryLog();
305+
$this->get('/api/books?page[limit]=1&page[offset]=2', headers: ['accept' => 'application/vnd.api+json']);
306+
['query' => $q] = DB::getQueryLog()[1];
307+
$this->assertStringContainsString('select * from "books" limit 1 offset 1', $q);
308+
}
309+
310+
public function testSparseFieldset(): void
311+
{
312+
BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
313+
$r = $this->get('/api/books?fields[book]=name,isbn&fields[author]=name&include=author', headers: ['accept' => 'application/vnd.api+json']);
314+
$res = $r->json();
315+
$attributes = $res['data'][0]['attributes'];
316+
$this->assertArrayHasKey('name', $attributes);
317+
$this->assertArrayHasKey('isbn', $attributes);
318+
$this->assertArrayNotHasKey('isAvailable', $attributes);
319+
320+
$included = $res['included'][0]['attributes'];
321+
$this->assertArrayNotHasKey('createdAt', $included);
322+
$this->assertArrayHasKey('name', $included);
323+
}
288324
}

workbench/app/Models/Book.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313

1414
namespace Workbench\App\Models;
1515

16+
use ApiPlatform\JsonApi\Filter\SparseFieldset;
1617
use ApiPlatform\Laravel\Eloquent\Filter\DateFilter;
1718
use ApiPlatform\Laravel\Eloquent\Filter\EqualsFilter;
19+
use ApiPlatform\Laravel\Eloquent\Filter\JsonApi\SortFilter;
1820
use ApiPlatform\Laravel\Eloquent\Filter\OrderFilter;
1921
use ApiPlatform\Laravel\Eloquent\Filter\OrFilter;
2022
use ApiPlatform\Laravel\Eloquent\Filter\PartialSearchFilter;
@@ -40,6 +42,7 @@
4042
#[ApiResource(
4143
paginationEnabled: true,
4244
paginationItemsPerPage: 5,
45+
paginationClientItemsPerPage: true,
4346
rules: BookFormRequest::class,
4447
operations: [
4548
new Put(),
@@ -76,6 +79,8 @@
7679
property: 'name'
7780
)]
7881
#[QueryParameter(key: 'properties', filter: PropertyFilter::class)]
82+
#[QueryParameter(key: 'fields', filter: SparseFieldset::class)]
83+
#[QueryParameter(key: 'sort', filter: SortFilter::class)]
7984
class Book extends Model
8085
{
8186
use HasFactory;

0 commit comments

Comments
 (0)