Skip to content

Commit c8db7ae

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

File tree

13 files changed

+412
-6
lines changed

13 files changed

+412
-6
lines changed

src/JsonApi/Filter/SparseFieldset.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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\JsonApi\Filter;
15+
16+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
17+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
18+
use ApiPlatform\Metadata\Parameter as MetadataParameter;
19+
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
20+
use ApiPlatform\Metadata\PropertiesAwareInterface;
21+
use ApiPlatform\Metadata\QueryParameter;
22+
use ApiPlatform\OpenApi\Model\Parameter;
23+
24+
final class SparseFieldset implements OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ParameterProviderFilterInterface, PropertiesAwareInterface
25+
{
26+
public function getSchema(MetadataParameter $parameter): array
27+
{
28+
return [
29+
'type' => 'array',
30+
'items' => [
31+
'type' => 'string',
32+
],
33+
];
34+
}
35+
36+
public function getOpenApiParameters(MetadataParameter $parameter): Parameter|array|null
37+
{
38+
return new Parameter(
39+
name: ($k = $parameter->getKey()).'[]',
40+
in: $parameter instanceof QueryParameter ? 'query' : 'header',
41+
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(
42+
'%1$s[]={propertyName}&%1$s[]={anotherPropertyName}',
43+
$k
44+
)
45+
);
46+
}
47+
48+
public static function getParameterProvider(): string
49+
{
50+
return SparseFieldsetParameterProvider::class;
51+
}
52+
}
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\JsonApi\Filter;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\Metadata\Parameter;
18+
use ApiPlatform\State\ParameterProviderInterface;
19+
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
20+
21+
final readonly class SparseFieldsetParameterProvider implements ParameterProviderInterface
22+
{
23+
public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation
24+
{
25+
if (!($operation = $context['operation'] ?? null)) {
26+
return null;
27+
}
28+
29+
$allowedProperties = $parameter->getExtraProperties()['_properties'] ?? [];
30+
$value = $parameter->getValue();
31+
$normalizationContext = $operation->getNormalizationContext();
32+
33+
if (!\is_array($value)) {
34+
return null;
35+
}
36+
37+
$properties = [];
38+
$shortName = strtolower($operation->getShortName());
39+
foreach ($value as $resource => $fields) {
40+
if (strtolower($resource) === $shortName) {
41+
$p = &$properties;
42+
} else {
43+
$properties[$resource] = [];
44+
$p = &$properties[$resource];
45+
}
46+
47+
foreach (explode(',', $fields) as $f) {
48+
if (\array_key_exists($f, $allowedProperties)) {
49+
$p[] = $f;
50+
}
51+
}
52+
}
53+
54+
if (isset($normalizationContext[AbstractNormalizer::ATTRIBUTES])) {
55+
$properties = array_merge_recursive((array) $normalizationContext[AbstractNormalizer::ATTRIBUTES], $properties);
56+
}
57+
58+
$normalizationContext[AbstractNormalizer::ATTRIBUTES] = $properties;
59+
60+
return $operation->withNormalizationContext($normalizationContext);
61+
}
62+
}

src/Laravel/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+
}

src/Laravel/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
}
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+
}

0 commit comments

Comments
 (0)