diff --git a/src/JsonApi/Serializer/CollectionNormalizer.php b/src/JsonApi/Serializer/CollectionNormalizer.php index 1c1f362ce0..836113ad84 100644 --- a/src/JsonApi/Serializer/CollectionNormalizer.php +++ b/src/JsonApi/Serializer/CollectionNormalizer.php @@ -13,10 +13,12 @@ namespace ApiPlatform\JsonApi\Serializer; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\IriHelper; use ApiPlatform\Serializer\AbstractCollectionNormalizer; +use ApiPlatform\State\Util\RequestParser; use Symfony\Component\Serializer\Exception\UnexpectedValueException; /** @@ -41,29 +43,29 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve protected function getPaginationData(iterable $object, array $context = []): array { [$paginator, $paginated, $currentPage, $itemsPerPage, $lastPage, $pageTotalItems, $totalItems] = $this->getPaginationConfig($object, $context); - $parsed = IriHelper::parseIri($context['uri'] ?? '/', $this->pageParameterName); + $parsed = $this->parseUri($context['uri'] ?? '/'); $operation = $context['operation'] ?? $this->getOperation($context); $urlGenerationStrategy = $operation->getUrlGenerationStrategy(); $data = [ 'links' => [ - 'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy), + 'self' => $this->createPageIri($parsed, $paginated ? $currentPage : null, $urlGenerationStrategy), ], ]; if ($paginated) { if (null !== $lastPage) { - $data['links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); - $data['links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); + $data['links']['first'] = $this->createPageIri($parsed, 1., $urlGenerationStrategy); + $data['links']['last'] = $this->createPageIri($parsed, $lastPage, $urlGenerationStrategy); } if (1. !== $currentPage) { - $data['links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); + $data['links']['prev'] = $this->createPageIri($parsed, $currentPage - 1., $urlGenerationStrategy); } if (null !== $lastPage && $currentPage !== $lastPage || null === $lastPage && $pageTotalItems >= $itemsPerPage) { - $data['links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); + $data['links']['next'] = $this->createPageIri($parsed, $currentPage + 1., $urlGenerationStrategy); } } @@ -79,6 +81,39 @@ protected function getPaginationData(iterable $object, array $context = []): arr return $data; } + /** + * @return array{parts: array, parameters: array} + */ + private function parseUri(string $uri): array + { + $parts = parse_url($uri); + if (false === $parts) { + throw new InvalidArgumentException(\sprintf('The request URI "%s" is malformed.', $uri)); + } + + return ['parts' => $parts, 'parameters' => isset($parts['query']) ? RequestParser::parseRequestParams($parts['query']) : []]; + } + + /** + * @param array{parts: array, parameters: array} $parsed + */ + private function createPageIri(array $parsed, ?float $page, ?int $urlGenerationStrategy): string + { + $parameters = $parsed['parameters']; + $pageParameters = \is_array($parameters['page'] ?? null) ? $parameters['page'] : []; + unset($parameters['page'], $parameters[$this->pageParameterName], $pageParameters[$this->pageParameterName]); + + if (null !== $page) { + $pageParameters = [$this->pageParameterName => $page] + $pageParameters; + } + + if ([] !== $pageParameters) { + $parameters['page'] = $pageParameters; + } + + return IriHelper::createIri($parsed['parts'], $parameters, null, null, $urlGenerationStrategy); + } + /** * {@inheritdoc} * diff --git a/src/JsonApi/Tests/Serializer/CollectionNormalizerTest.php b/src/JsonApi/Tests/Serializer/CollectionNormalizerTest.php index 4a75ff5d52..70e296b270 100644 --- a/src/JsonApi/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/CollectionNormalizerTest.php @@ -80,8 +80,8 @@ public function testNormalizePaginator(): void $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ - 'request_uri' => '/foos?page=3', - 'uri' => 'http://example.com/foos?page=3', + 'request_uri' => '/foos?page[page]=3', + 'uri' => 'http://example.com/foos?page[page]=3', 'api_sub_level' => true, 'resource_class' => 'Foo', 'root_operation_name' => 'get', @@ -101,11 +101,11 @@ public function testNormalizePaginator(): void $expected = [ 'links' => [ - 'self' => '/foos?page=3', - 'first' => '/foos?page=1', - 'last' => '/foos?page=7', - 'prev' => '/foos?page=2', - 'next' => '/foos?page=4', + 'self' => '/foos?page%5Bpage%5D=3', + 'first' => '/foos?page%5Bpage%5D=1', + 'last' => '/foos?page%5Bpage%5D=7', + 'prev' => '/foos?page%5Bpage%5D=2', + 'next' => '/foos?page%5Bpage%5D=4', ], 'data' => [ [ @@ -125,9 +125,9 @@ public function testNormalizePaginator(): void ]; $this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [ - 'request_uri' => '/foos?page=3', + 'request_uri' => '/foos?page[page]=3', 'operation_name' => 'get', - 'uri' => 'http://example.com/foos?page=3', + 'uri' => 'http://example.com/foos?page[page]=3', 'resource_class' => 'Foo', ])); } @@ -157,8 +157,8 @@ public function testNormalizePartialPaginator(): void $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ - 'request_uri' => '/foos?page=3', - 'uri' => 'http://example.com/foos?page=3', + 'request_uri' => '/foos?page[page]=3', + 'uri' => 'http://example.com/foos?page[page]=3', 'api_sub_level' => true, 'resource_class' => 'Foo', 'root_operation_name' => 'get', @@ -178,9 +178,9 @@ public function testNormalizePartialPaginator(): void $expected = [ 'links' => [ - 'self' => '/foos?page=3', - 'prev' => '/foos?page=2', - 'next' => '/foos?page=4', + 'self' => '/foos?page%5Bpage%5D=3', + 'prev' => '/foos?page%5Bpage%5D=2', + 'next' => '/foos?page%5Bpage%5D=4', ], 'data' => [ [ @@ -199,9 +199,9 @@ public function testNormalizePartialPaginator(): void ]; $this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [ - 'request_uri' => '/foos?page=3', + 'request_uri' => '/foos?page[page]=3', 'operation_name' => 'get', - 'uri' => 'http://example.com/foos?page=3', + 'uri' => 'http://example.com/foos?page[page]=3', 'resource_class' => 'Foo', ])); } diff --git a/src/Laravel/Tests/JsonApiTest.php b/src/Laravel/Tests/JsonApiTest.php index 1cbcd950b8..b4867c2c2f 100644 --- a/src/Laravel/Tests/JsonApiTest.php +++ b/src/Laravel/Tests/JsonApiTest.php @@ -83,10 +83,10 @@ public function testGetCollection(): void $response->assertHeader('content-type', 'application/vnd.api+json'); $response->assertJsonFragment([ 'links' => [ - 'self' => '/api/books?page=1', - 'first' => '/api/books?page=1', - 'last' => '/api/books?page=2', - 'next' => '/api/books?page=2', + 'self' => '/api/books?page%5Bpage%5D=1', + 'first' => '/api/books?page%5Bpage%5D=1', + 'last' => '/api/books?page%5Bpage%5D=2', + 'next' => '/api/books?page%5Bpage%5D=2', ], 'meta' => ['totalItems' => 10, 'itemsPerPage' => 5, 'currentPage' => 1], ]); diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/PageLinksDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PageLinksDummy.php new file mode 100644 index 0000000000..4fc8a5c3ef --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PageLinksDummy.php @@ -0,0 +1,66 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'JsonApiPageLinksDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + paginationItemsPerPage: 3, + paginationClientItemsPerPage: true, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_page_links_dummies', + provider: [self::class, 'provideTenItems'], + ), + new GetCollection( + uriTemplate: '/jsonapi_page_links_dummies_single_page', + provider: [self::class, 'provideTwoItems'], + ), + ], +)] +class PageLinksDummy +{ + #[ApiProperty(identifier: true)] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } + + public static function provideTenItems(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + return self::paginate(10, $context['filters'] ?? []); + } + + public static function provideTwoItems(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + return self::paginate(2, $context['filters'] ?? []); + } + + private static function paginate(int $count, array $filters): ArrayPaginator + { + $page = max(1, (int) ($filters['number'] ?? $filters['page'] ?? 1)); + $size = max(1, (int) ($filters['size'] ?? $filters['itemsPerPage'] ?? 3)); + $items = array_map(static fn (int $i): self => new self($i), range(1, $count)); + + return new ArrayPaginator($items, ($page - 1) * $size, $size); + } +} diff --git a/tests/Functional/JsonApi/PageNumberPaginationLinksTest.php b/tests/Functional/JsonApi/PageNumberPaginationLinksTest.php new file mode 100644 index 0000000000..9994dfdfb0 --- /dev/null +++ b/tests/Functional/JsonApi/PageNumberPaginationLinksTest.php @@ -0,0 +1,92 @@ + + * + * 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\Tests\Functional\JsonApi; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\PageLinksDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class PageNumberPaginationAppKernel extends \AppKernel +{ + public function getCacheDir(): string + { + return parent::getCacheDir().'/jsonapi_page_number'; + } + + public function getLogDir(): string + { + return parent::getLogDir().'/jsonapi_page_number'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('api_platform', [ + 'collection' => [ + 'pagination' => [ + 'page_parameter_name' => 'number', + 'items_per_page_parameter_name' => 'size', + ], + ], + ]); + }); + } +} + +final class PageNumberPaginationLinksTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = true; + + public static function getResources(): array + { + return [PageLinksDummy::class]; + } + + protected static function getKernelClass(): string + { + return PageNumberPaginationAppKernel::class; + } + + public function testLinksDoNotDuplicateTheHoistedPageParameter(): void + { + $response = self::createClient()->request('GET', '/jsonapi_page_links_dummies?page[number]=2&page[size]=3', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame(2, $body['meta']['currentPage']); + $links = $body['links']; + $this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=2&page%5Bsize%5D=3', $links['self']); + $this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=1&page%5Bsize%5D=3', $links['first']); + $this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=4&page%5Bsize%5D=3', $links['last']); + $this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=1&page%5Bsize%5D=3', $links['prev']); + $this->assertSame('/jsonapi_page_links_dummies?page%5Bnumber%5D=3&page%5Bsize%5D=3', $links['next']); + } + + public function testFollowingTheNextLinkMovesToTheNextPage(): void + { + $client = self::createClient(); + $headers = ['headers' => ['Accept' => 'application/vnd.api+json']]; + $next = $client->request('GET', '/jsonapi_page_links_dummies?page[number]=2&page[size]=3', $headers)->toArray()['links']['next']; + + $this->assertSame(3, $client->request('GET', $next, $headers)->toArray()['meta']['currentPage']); + } +} diff --git a/tests/Functional/JsonApi/PaginationLinksTest.php b/tests/Functional/JsonApi/PaginationLinksTest.php new file mode 100644 index 0000000000..77b73f3c2f --- /dev/null +++ b/tests/Functional/JsonApi/PaginationLinksTest.php @@ -0,0 +1,45 @@ + + * + * 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\Tests\Functional\JsonApi; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\PageLinksDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PaginationLinksTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [PageLinksDummy::class]; + } + + public function testLinksKeepTheBracketPageParameter(): void + { + $response = self::createClient()->request('GET', '/jsonapi_page_links_dummies?page[page]=2', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $links = $response->toArray()['links']; + $this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=2', $links['self']); + $this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=1', $links['first']); + $this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=4', $links['last']); + $this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=1', $links['prev']); + $this->assertSame('/jsonapi_page_links_dummies?page%5Bpage%5D=3', $links['next']); + } +}