Skip to content

Commit 7eadf14

Browse files
committed
fix(jsonapi): keep bracket page param in links
Fixes #8747
1 parent c810012 commit 7eadf14

5 files changed

Lines changed: 260 additions & 22 deletions

File tree

‎src/JsonApi/Serializer/CollectionNormalizer.php‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313

1414
namespace ApiPlatform\JsonApi\Serializer;
1515

16+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1617
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
1718
use ApiPlatform\Metadata\ResourceClassResolverInterface;
1819
use ApiPlatform\Metadata\Util\IriHelper;
1920
use ApiPlatform\Serializer\AbstractCollectionNormalizer;
21+
use ApiPlatform\State\Util\RequestParser;
2022
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
2123

2224
/**
@@ -41,29 +43,29 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve
4143
protected function getPaginationData(iterable $object, array $context = []): array
4244
{
4345
[$paginator, $paginated, $currentPage, $itemsPerPage, $lastPage, $pageTotalItems, $totalItems] = $this->getPaginationConfig($object, $context);
44-
$parsed = IriHelper::parseIri($context['uri'] ?? '/', $this->pageParameterName);
46+
$parsed = $this->parseUri($context['uri'] ?? '/');
4547

4648
$operation = $context['operation'] ?? $this->getOperation($context);
4749
$urlGenerationStrategy = $operation->getUrlGenerationStrategy();
4850

4951
$data = [
5052
'links' => [
51-
'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy),
53+
'self' => $this->createPageIri($parsed, $paginated ? $currentPage : null, $urlGenerationStrategy),
5254
],
5355
];
5456

5557
if ($paginated) {
5658
if (null !== $lastPage) {
57-
$data['links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy);
58-
$data['links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy);
59+
$data['links']['first'] = $this->createPageIri($parsed, 1., $urlGenerationStrategy);
60+
$data['links']['last'] = $this->createPageIri($parsed, $lastPage, $urlGenerationStrategy);
5961
}
6062

6163
if (1. !== $currentPage) {
62-
$data['links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy);
64+
$data['links']['prev'] = $this->createPageIri($parsed, $currentPage - 1., $urlGenerationStrategy);
6365
}
6466

6567
if (null !== $lastPage && $currentPage !== $lastPage || null === $lastPage && $pageTotalItems >= $itemsPerPage) {
66-
$data['links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy);
68+
$data['links']['next'] = $this->createPageIri($parsed, $currentPage + 1., $urlGenerationStrategy);
6769
}
6870
}
6971

@@ -79,6 +81,39 @@ protected function getPaginationData(iterable $object, array $context = []): arr
7981
return $data;
8082
}
8183

84+
/**
85+
* @return array{parts: array<string, mixed>, parameters: array<string, mixed>}
86+
*/
87+
private function parseUri(string $uri): array
88+
{
89+
$parts = parse_url($uri);
90+
if (false === $parts) {
91+
throw new InvalidArgumentException(\sprintf('The request URI "%s" is malformed.', $uri));
92+
}
93+
94+
return ['parts' => $parts, 'parameters' => isset($parts['query']) ? RequestParser::parseRequestParams($parts['query']) : []];
95+
}
96+
97+
/**
98+
* @param array{parts: array<string, mixed>, parameters: array<string, mixed>} $parsed
99+
*/
100+
private function createPageIri(array $parsed, ?float $page, ?int $urlGenerationStrategy): string
101+
{
102+
$parameters = $parsed['parameters'];
103+
$pageParameters = \is_array($parameters['page'] ?? null) ? $parameters['page'] : [];
104+
unset($parameters['page'], $parameters[$this->pageParameterName], $pageParameters[$this->pageParameterName]);
105+
106+
if (null !== $page) {
107+
$pageParameters = [$this->pageParameterName => $page] + $pageParameters;
108+
}
109+
110+
if ([] !== $pageParameters) {
111+
$parameters['page'] = $pageParameters;
112+
}
113+
114+
return IriHelper::createIri($parsed['parts'], $parameters, null, null, $urlGenerationStrategy);
115+
}
116+
82117
/**
83118
* {@inheritdoc}
84119
*

‎src/JsonApi/Tests/Serializer/CollectionNormalizerTest.php‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ public function testNormalizePaginator(): void
8080

8181
$itemNormalizer = $this->prophesize(NormalizerInterface::class);
8282
$itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [
83-
'request_uri' => '/foos?page=3',
84-
'uri' => 'http://example.com/foos?page=3',
83+
'request_uri' => '/foos?page[page]=3',
84+
'uri' => 'http://example.com/foos?page[page]=3',
8585
'api_sub_level' => true,
8686
'resource_class' => 'Foo',
8787
'root_operation_name' => 'get',
@@ -101,11 +101,11 @@ public function testNormalizePaginator(): void
101101

102102
$expected = [
103103
'links' => [
104-
'self' => '/foos?page=3',
105-
'first' => '/foos?page=1',
106-
'last' => '/foos?page=7',
107-
'prev' => '/foos?page=2',
108-
'next' => '/foos?page=4',
104+
'self' => '/foos?page%5Bpage%5D=3',
105+
'first' => '/foos?page%5Bpage%5D=1',
106+
'last' => '/foos?page%5Bpage%5D=7',
107+
'prev' => '/foos?page%5Bpage%5D=2',
108+
'next' => '/foos?page%5Bpage%5D=4',
109109
],
110110
'data' => [
111111
[
@@ -125,9 +125,9 @@ public function testNormalizePaginator(): void
125125
];
126126

127127
$this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [
128-
'request_uri' => '/foos?page=3',
128+
'request_uri' => '/foos?page[page]=3',
129129
'operation_name' => 'get',
130-
'uri' => 'http://example.com/foos?page=3',
130+
'uri' => 'http://example.com/foos?page[page]=3',
131131
'resource_class' => 'Foo',
132132
]));
133133
}
@@ -157,8 +157,8 @@ public function testNormalizePartialPaginator(): void
157157

158158
$itemNormalizer = $this->prophesize(NormalizerInterface::class);
159159
$itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [
160-
'request_uri' => '/foos?page=3',
161-
'uri' => 'http://example.com/foos?page=3',
160+
'request_uri' => '/foos?page[page]=3',
161+
'uri' => 'http://example.com/foos?page[page]=3',
162162
'api_sub_level' => true,
163163
'resource_class' => 'Foo',
164164
'root_operation_name' => 'get',
@@ -178,9 +178,9 @@ public function testNormalizePartialPaginator(): void
178178

179179
$expected = [
180180
'links' => [
181-
'self' => '/foos?page=3',
182-
'prev' => '/foos?page=2',
183-
'next' => '/foos?page=4',
181+
'self' => '/foos?page%5Bpage%5D=3',
182+
'prev' => '/foos?page%5Bpage%5D=2',
183+
'next' => '/foos?page%5Bpage%5D=4',
184184
],
185185
'data' => [
186186
[
@@ -199,9 +199,9 @@ public function testNormalizePartialPaginator(): void
199199
];
200200

201201
$this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [
202-
'request_uri' => '/foos?page=3',
202+
'request_uri' => '/foos?page[page]=3',
203203
'operation_name' => 'get',
204-
'uri' => 'http://example.com/foos?page=3',
204+
'uri' => 'http://example.com/foos?page[page]=3',
205205
'resource_class' => 'Foo',
206206
]));
207207
}
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 <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\ApiResource\JsonApi;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\GetCollection;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\State\Pagination\ArrayPaginator;
21+
22+
#[ApiResource(
23+
shortName: 'JsonApiPageLinksDummy',
24+
formats: ['jsonapi' => ['application/vnd.api+json']],
25+
paginationItemsPerPage: 3,
26+
paginationClientItemsPerPage: true,
27+
operations: [
28+
new GetCollection(
29+
uriTemplate: '/jsonapi_page_links_dummies',
30+
provider: [self::class, 'provideTenItems'],
31+
),
32+
new GetCollection(
33+
uriTemplate: '/jsonapi_page_links_dummies_single_page',
34+
provider: [self::class, 'provideTwoItems'],
35+
),
36+
],
37+
)]
38+
class PageLinksDummy
39+
{
40+
#[ApiProperty(identifier: true)]
41+
public int $id;
42+
43+
public function __construct(int $id)
44+
{
45+
$this->id = $id;
46+
}
47+
48+
public static function provideTenItems(Operation $operation, array $uriVariables = [], array $context = []): iterable
49+
{
50+
return self::paginate(10, $context['filters'] ?? []);
51+
}
52+
53+
public static function provideTwoItems(Operation $operation, array $uriVariables = [], array $context = []): iterable
54+
{
55+
return self::paginate(2, $context['filters'] ?? []);
56+
}
57+
58+
private static function paginate(int $count, array $filters): ArrayPaginator
59+
{
60+
$page = max(1, (int) ($filters['number'] ?? $filters['page'] ?? 1));
61+
$size = max(1, (int) ($filters['size'] ?? $filters['itemsPerPage'] ?? 3));
62+
$items = array_map(static fn (int $i): self => new self($i), range(1, $count));
63+
64+
return new ArrayPaginator($items, ($page - 1) * $size, $size);
65+
}
66+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Functional\JsonApi;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\PageLinksDummy;
18+
use ApiPlatform\Tests\SetupClassResourcesTrait;
19+
use Symfony\Component\Config\Loader\LoaderInterface;
20+
use Symfony\Component\DependencyInjection\ContainerBuilder;
21+
22+
class PageNumberPaginationAppKernel extends \AppKernel
23+
{
24+
public function getCacheDir(): string
25+
{
26+
return parent::getCacheDir().'/jsonapi_page_number';
27+
}
28+
29+
public function getLogDir(): string
30+
{
31+
return parent::getLogDir().'/jsonapi_page_number';
32+
}
33+
34+
protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void
35+
{
36+
parent::configureContainer($c, $loader);
37+
38+
$loader->load(static function (ContainerBuilder $container): void {
39+
$container->loadFromExtension('api_platform', [
40+
'collection' => [
41+
'pagination' => [
42+
'page_parameter_name' => 'number',
43+
'items_per_page_parameter_name' => 'size',
44+
],
45+
],
46+
]);
47+
});
48+
}
49+
}
50+
51+
final class PageNumberPaginationLinksTest extends ApiTestCase
52+
{
53+
use SetupClassResourcesTrait;
54+
55+
protected static ?bool $alwaysBootKernel = true;
56+
57+
public static function getResources(): array
58+
{
59+
return [PageLinksDummy::class];
60+
}
61+
62+
protected static function getKernelClass(): string
63+
{
64+
return PageNumberPaginationAppKernel::class;
65+
}
66+
67+
public function testLinksDoNotDuplicateTheHoistedPageParameter(): void
68+
{
69+
$response = self::createClient()->request('GET', '/jsonapi_page_links_dummies?page[number]=2&page[size]=3', [
70+
'headers' => ['Accept' => 'application/vnd.api+json'],
71+
]);
72+
73+
$this->assertResponseIsSuccessful();
74+
$body = $response->toArray();
75+
$this->assertSame(2, $body['meta']['currentPage']);
76+
$links = $body['links'];
77+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=2&page%5Bsize%5D=3', $links['self']);
78+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=1&page%5Bsize%5D=3', $links['first']);
79+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=4&page%5Bsize%5D=3', $links['last']);
80+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=1&page%5Bsize%5D=3', $links['prev']);
81+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=3&page%5Bsize%5D=3', $links['next']);
82+
}
83+
84+
public function testFollowingTheNextLinkMovesToTheNextPage(): void
85+
{
86+
$client = self::createClient();
87+
$headers = ['headers' => ['Accept' => 'application/vnd.api+json']];
88+
$next = $client->request('GET', '/jsonapi_page_links_dummies?page[number]=2&page[size]=3', $headers)->toArray()['links']['next'];
89+
90+
$this->assertSame(3, $client->request('GET', $next, $headers)->toArray()['meta']['currentPage']);
91+
}
92+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Functional\JsonApi;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\PageLinksDummy;
18+
use ApiPlatform\Tests\SetupClassResourcesTrait;
19+
20+
final class PaginationLinksTest extends ApiTestCase
21+
{
22+
use SetupClassResourcesTrait;
23+
24+
protected static ?bool $alwaysBootKernel = false;
25+
26+
public static function getResources(): array
27+
{
28+
return [PageLinksDummy::class];
29+
}
30+
31+
public function testLinksKeepTheBracketPageParameter(): void
32+
{
33+
$response = self::createClient()->request('GET', '/jsonapi_page_links_dummies?page[page]=2', [
34+
'headers' => ['Accept' => 'application/vnd.api+json'],
35+
]);
36+
37+
$this->assertResponseIsSuccessful();
38+
$links = $response->toArray()['links'];
39+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=2', $links['self']);
40+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=1', $links['first']);
41+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=4', $links['last']);
42+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=1', $links['prev']);
43+
$this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=3', $links['next']);
44+
}
45+
}

0 commit comments

Comments
 (0)