Skip to content

Commit 4e4062e

Browse files
committed
Merge 4.0
2 parents 82dcfda + f01db26 commit 4e4062e

File tree

11 files changed

+295
-6
lines changed

11 files changed

+295
-6
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;
@@ -85,6 +87,8 @@
8587
use ApiPlatform\Laravel\Eloquent\Filter\DateFilter;
8688
use ApiPlatform\Laravel\Eloquent\Filter\EqualsFilter;
8789
use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface as EloquentFilterInterface;
90+
use ApiPlatform\Laravel\Eloquent\Filter\JsonApi\SortFilter;
91+
use ApiPlatform\Laravel\Eloquent\Filter\JsonApi\SortFilterParameterProvider;
8892
use ApiPlatform\Laravel\Eloquent\Filter\OrderFilter;
8993
use ApiPlatform\Laravel\Eloquent\Filter\PartialSearchFilter;
9094
use ApiPlatform\Laravel\Eloquent\Filter\RangeFilter;
@@ -107,6 +111,7 @@
107111
use ApiPlatform\Laravel\Exception\ErrorHandler;
108112
use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController;
109113
use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController;
114+
use ApiPlatform\Laravel\JsonApi\State\JsonApiProvider;
110115
use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory;
111116
use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory;
112117
use ApiPlatform\Laravel\Metadata\CacheResourceCollectionMetadataFactory;
@@ -423,7 +428,15 @@ public function register(): void
423428

424429
$this->app->bind(OperationMetadataFactoryInterface::class, OperationMetadataFactory::class);
425430

426-
$this->app->tag([BooleanFilter::class, EqualsFilter::class, PartialSearchFilter::class, DateFilter::class, OrderFilter::class, RangeFilter::class], EloquentFilterInterface::class);
431+
$this->app->tag([
432+
EqualsFilter::class,
433+
PartialSearchFilter::class,
434+
DateFilter::class,
435+
OrderFilter::class,
436+
RangeFilter::class,
437+
SortFilter::class,
438+
SparseFieldset::class,
439+
], EloquentFilterInterface::class);
427440

428441
$this->app->bind(FilterQueryExtension::class, function (Application $app) {
429442
$tagged = iterator_to_array($app->tagged(EloquentFilterInterface::class));
@@ -470,6 +483,12 @@ public function register(): void
470483
return new DeserializeProvider($app->make(ValidateProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class));
471484
});
472485

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

475494
$this->app->singleton(SerializerFilterParameterProvider::class, function (Application $app) {
@@ -479,7 +498,10 @@ public function register(): void
479498
});
480499
$this->app->alias(SerializerFilterParameterProvider::class, 'api_platform.serializer.filter_parameter_provider');
481500

482-
$this->app->tag([SerializerFilterParameterProvider::class], ParameterProviderInterface::class);
501+
$this->app->singleton(SortFilterParameterProvider::class, function (Application $app) {
502+
return new SortFilterParameterProvider();
503+
});
504+
$this->app->tag([SerializerFilterParameterProvider::class, SortFilterParameterProvider::class, SparseFieldsetParameterProvider::class], ParameterProviderInterface::class);
483505

484506
$this->app->singleton('filters', function (Application $app) {
485507
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
}

Eloquent/Paginator.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Laravel\Eloquent;
1515

16+
use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface;
1617
use ApiPlatform\State\Pagination\PaginatorInterface;
1718
use Illuminate\Pagination\LengthAwarePaginator;
1819
use IteratorAggregate;
@@ -21,7 +22,7 @@
2122
* @implements IteratorAggregate<mixed,object>
2223
* @implements PaginatorInterface<object>
2324
*/
24-
final class Paginator implements PaginatorInterface, \IteratorAggregate
25+
final class Paginator implements PaginatorInterface, HasNextPagePaginatorInterface, \IteratorAggregate
2526
{
2627
/**
2728
* @param LengthAwarePaginator<object> $paginator
@@ -60,4 +61,9 @@ public function getIterator(): \Traversable
6061
{
6162
return $this->paginator->getIterator();
6263
}
64+
65+
public function hasNextPage(): bool
66+
{
67+
return $this->paginator->hasMorePages();
68+
}
6369
}

Eloquent/State/LinksHandler.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,10 @@ private function buildQuery(Builder $builder, Link $link, mixed $identifier): Bu
109109
$relation_query = $relation->{$from}();
110110

111111
return $builder->getModel()->join(
112-
$relation_query->getTable(), $relation->{$from}()->getQualifiedForeignPivotKeyName(), $builder->getModel()->getQualifiedKeyName())
112+
$relation_query->getTable(), $relation->{$from}()->getQualifiedRelatedPivotKeyName(), $builder->getModel()->getQualifiedKeyName())
113113
->where($relation->{$from}()->getQualifiedForeignPivotKeyName(),
114-
$identifier);
114+
$identifier)
115+
->select($builder->getModel()->getTable().'.*');
115116
}
116117

117118
return $builder->getModel()->where($relation->{$from}()->getQualifiedForeignKeyName(), $identifier);

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/GraphQlTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,35 @@ public function testGetBooks(): void
4848
$this->assertArrayNotHasKey('errors', $data);
4949
}
5050

51+
public function testGetBooksWithSimplePagination(): void
52+
{
53+
BookFactory::new()->has(AuthorFactory::new())->count(9)->create();
54+
$response = $this->postJson('/api/graphql', ['query' => '{
55+
simplePaginationBooks(page: 1) {
56+
collection {
57+
id
58+
},
59+
paginationInfo {
60+
itemsPerPage,
61+
currentPage,
62+
lastPage,
63+
totalCount,
64+
hasNextPage
65+
}
66+
}
67+
}'], ['accept' => ['application/json']]);
68+
$response->assertStatus(200);
69+
$data = $response->json();
70+
$this->assertArrayHasKey('data', $data);
71+
$this->assertCount(3, $data['data']['simplePaginationBooks']['collection']);
72+
$this->assertEquals(3, $data['data']['simplePaginationBooks']['paginationInfo']['itemsPerPage']);
73+
$this->assertEquals(1, $data['data']['simplePaginationBooks']['paginationInfo']['currentPage']);
74+
$this->assertEquals(3, $data['data']['simplePaginationBooks']['paginationInfo']['lastPage']);
75+
$this->assertEquals(9, $data['data']['simplePaginationBooks']['paginationInfo']['totalCount']);
76+
$this->assertTrue($data['data']['simplePaginationBooks']['paginationInfo']['hasNextPage']);
77+
$this->assertArrayNotHasKey('errors', $data);
78+
}
79+
5180
public function testGetBooksWithPaginationAndOrder(): void
5281
{
5382
BookFactory::new()->has(AuthorFactory::new())->count(10)->create();

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
}

0 commit comments

Comments
 (0)