Skip to content

Commit

Permalink
fix(laravel): jsonapi query parameters (page, sort, fields and include)
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Dec 17, 2024
1 parent f15a338 commit 2e37d06
Show file tree
Hide file tree
Showing 13 changed files with 413 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
]);

return (new PhpCsFixer\Config())
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
// ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
->setRiskyAllowed(true)
->setRules([
'@DoctrineAnnotation' => true,
Expand Down
54 changes: 54 additions & 0 deletions src/JsonApi/Filter/SparseFieldset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\PropertiesFilterInterface;
use ApiPlatform\Metadata\QueryParameter;
use ApiPlatform\OpenApi\Model\Parameter;

final class SparseFieldset implements OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ParameterProviderFilterInterface, PropertiesFilterInterface
{
public function getSchema(MetadataParameter $parameter): array
{
return [
'type' => 'array',
'items' => [
'type' => 'string',
],
];
}

public function getOpenApiParameters(MetadataParameter $parameter): Parameter|array|null
{
$example = \sprintf(
'%1$s[]={propertyName}&%1$s[]={anotherPropertyName}',
$parameter->getKey()
);

return new Parameter(
name: $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: '.$example
);
}

public static function getParameterProvider(): string
{
return SparseFieldsetParameterProvider::class;
}
}
62 changes: 62 additions & 0 deletions src/JsonApi/Filter/SparseFieldsetParameterProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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);
}
}
26 changes: 24 additions & 2 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand All @@ -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(
Expand Down
62 changes: 62 additions & 0 deletions src/Laravel/Eloquent/Filter/JsonApi/SortFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\PropertiesFilterInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, ParameterProviderFilterInterface, PropertiesFilterInterface
{
public const ASC = 'asc';
public const DESC = 'desc';

/**
* @param Builder<Model> $builder
* @param array<string, mixed> $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<string, mixed>
*/
public function getSchema(Parameter $parameter): array
{
return ['type' => 'string'];
}

public static function getParameterProvider(): string
{
return SortFilterParameterProvider::class;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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 $operation;
}

$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;
}
}

$parameter->setValue($orderBy);

return $operation;
}
}
66 changes: 66 additions & 0 deletions src/Laravel/JsonApi/State/JsonApiProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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);
}
}
Loading

0 comments on commit 2e37d06

Please sign in to comment.