Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions src/JsonApi/Serializer/CollectionNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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);
}
}

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

/**
* @return array{parts: array<string, mixed>, parameters: array<string, mixed>}
*/
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<string, mixed>, parameters: array<string, mixed>} $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}
*
Expand Down
32 changes: 16 additions & 16 deletions src/JsonApi/Tests/Serializer/CollectionNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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' => [
[
Expand All @@ -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',
]));
}
Expand Down Expand Up @@ -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',
Expand All @@ -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' => [
[
Expand All @@ -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',
]));
}
Expand Down
8 changes: 4 additions & 4 deletions src/Laravel/Tests/JsonApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
]);
Expand Down
66 changes: 66 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/JsonApi/PageLinksDummy.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 <dunglas@gmail.com>
*
* 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);
}
}
92 changes: 92 additions & 0 deletions tests/Functional/JsonApi/PageNumberPaginationLinksTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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']);
}
}
45 changes: 45 additions & 0 deletions tests/Functional/JsonApi/PaginationLinksTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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']);
}
}
Loading