diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php
index 58317cf0884..2e1b5e3518b 100644
--- a/src/Laravel/ApiPlatformProvider.php
+++ b/src/Laravel/ApiPlatformProvider.php
@@ -169,6 +169,7 @@
use ApiPlatform\State\Processor\AddLinkHeaderProcessor;
use ApiPlatform\State\Processor\ObjectMapperInputProcessor;
use ApiPlatform\State\Processor\ObjectMapperOutputProcessor;
+use ApiPlatform\State\Processor\PaginationLinkProcessor;
use ApiPlatform\State\Processor\RespondProcessor;
use ApiPlatform\State\Processor\SerializeProcessor;
use ApiPlatform\State\Processor\WriteProcessor;
@@ -552,7 +553,7 @@ public function register(): void
);
}
- return new AddLinkHeaderProcessor($decorated, new HttpHeaderSerializer());
+ return new AddLinkHeaderProcessor(new PaginationLinkProcessor($decorated, $app->make(Pagination::class)), new HttpHeaderSerializer());
});
$this->app->singleton(SerializeProcessor::class, static function (Application $app) {
diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php
index 5d136533d83..e2aac4472a4 100644
--- a/src/Metadata/ApiResource.php
+++ b/src/Metadata/ApiResource.php
@@ -982,6 +982,7 @@ public function __construct(
protected array $extraProperties = [],
?bool $map = null,
protected ?array $mcp = null,
+ protected ?bool $paginationLinkHeader = null,
) {
parent::__construct(
shortName: $shortName,
@@ -1273,6 +1274,19 @@ public function withAcceptPatch(string $acceptPatch): static
return $self;
}
+ public function getPaginationLinkHeader(): ?bool
+ {
+ return $this->paginationLinkHeader;
+ }
+
+ public function withPaginationLinkHeader(bool $paginationLinkHeader): static
+ {
+ $self = clone $this;
+ $self->paginationLinkHeader = $paginationLinkHeader;
+
+ return $self;
+ }
+
public function getStatus(): ?int
{
return $this->status;
diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php
index 1c04a62c5c7..35042bea60a 100644
--- a/src/Metadata/Extractor/XmlResourceExtractor.php
+++ b/src/Metadata/Extractor/XmlResourceExtractor.php
@@ -124,6 +124,7 @@ private function buildBase(\SimpleXMLElement $resource): array
'paginationClientItemsPerPage' => $this->phpize($resource, 'paginationClientItemsPerPage', 'bool'),
'paginationClientPartial' => $this->phpize($resource, 'paginationClientPartial', 'bool'),
'paginationEnabled' => $this->phpize($resource, 'paginationEnabled', 'bool'),
+ 'paginationLinkHeader' => $this->phpize($resource, 'paginationLinkHeader', 'bool'),
'paginationFetchJoinCollection' => $this->phpize($resource, 'paginationFetchJoinCollection', 'bool'),
'paginationUseOutputWalkers' => $this->phpize($resource, 'paginationUseOutputWalkers', 'bool'),
'paginationItemsPerPage' => $this->phpize($resource, 'paginationItemsPerPage', 'integer'),
@@ -429,6 +430,7 @@ private function buildOperations(\SimpleXMLElement $resource, array $root): ?arr
'queryParameterValidate' => $this->phpize($operation, 'queryParameterValidate', 'bool'),
'priority' => $this->phpize($operation, 'priority', 'integer'),
'routePriority' => $this->phpize($operation, 'routePriority', 'integer'),
+ 'paginationLinkHeader' => $this->phpize($operation, 'paginationLinkHeader', 'bool'),
'name' => $this->phpize($operation, 'name', 'string'),
'routeName' => $this->phpize($operation, 'routeName', 'string'),
]);
diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php
index f73d3a6290a..5b7d68ca726 100644
--- a/src/Metadata/Extractor/YamlResourceExtractor.php
+++ b/src/Metadata/Extractor/YamlResourceExtractor.php
@@ -147,6 +147,7 @@ private function buildBase(array $resource): array
'paginationClientItemsPerPage' => $this->phpize($resource, 'paginationClientItemsPerPage', 'bool'),
'paginationClientPartial' => $this->phpize($resource, 'paginationClientPartial', 'bool'),
'paginationEnabled' => $this->phpize($resource, 'paginationEnabled', 'bool'),
+ 'paginationLinkHeader' => $this->phpize($resource, 'paginationLinkHeader', 'bool'),
'paginationFetchJoinCollection' => $this->phpize($resource, 'paginationFetchJoinCollection', 'bool'),
'paginationUseOutputWalkers' => $this->phpize($resource, 'paginationUseOutputWalkers', 'bool'),
'paginationItemsPerPage' => $this->phpize($resource, 'paginationItemsPerPage', 'integer'),
@@ -366,6 +367,7 @@ private function buildOperations(array $resource, array $root): ?array
'hideHydraOperation' => $this->phpize($resource, 'hideHydraOperation', 'bool'),
'priority' => $this->phpize($operation, 'priority', 'integer'),
'routePriority' => $this->phpize($operation, 'routePriority', 'integer'),
+ 'paginationLinkHeader' => $this->phpize($operation, 'paginationLinkHeader', 'bool'),
'name' => $this->phpize($operation, 'name', 'string'),
'class' => (string) $class,
]);
diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd
index 295dd495993..c597ef42e6d 100644
--- a/src/Metadata/Extractor/schema/resources.xsd
+++ b/src/Metadata/Extractor/schema/resources.xsd
@@ -540,6 +540,7 @@
+
diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php
index 9810442359d..27e5d942f1d 100644
--- a/src/Metadata/GetCollection.php
+++ b/src/Metadata/GetCollection.php
@@ -107,6 +107,7 @@ public function __construct(
?bool $throwOnNotFound = null,
private ?string $itemUriTemplate = null,
?bool $map = null,
+ ?bool $paginationLinkHeader = null,
) {
parent::__construct(
uriTemplate: $uriTemplate,
@@ -192,7 +193,8 @@ class: $class,
strictQueryParameterValidation: $strictQueryParameterValidation,
hideHydraOperation: $hideHydraOperation,
stateOptions: $stateOptions,
- map: $map
+ map: $map,
+ paginationLinkHeader: $paginationLinkHeader
);
}
diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php
index 789546274cb..45ad562060e 100644
--- a/src/Metadata/HttpOperation.php
+++ b/src/Metadata/HttpOperation.php
@@ -227,6 +227,7 @@ public function __construct(
?bool $throwOnNotFound = null,
array $extraProperties = [],
?bool $map = null,
+ protected ?bool $paginationLinkHeader = null,
) {
$this->formats = (null === $formats || \is_array($formats)) ? $formats : [$formats];
$this->inputFormats = (null === $inputFormats || \is_array($inputFormats)) ? $inputFormats : [$inputFormats];
@@ -518,6 +519,19 @@ public function withAcceptPatch(string $acceptPatch): static
return $self;
}
+ public function getPaginationLinkHeader(): ?bool
+ {
+ return $this->paginationLinkHeader;
+ }
+
+ public function withPaginationLinkHeader(bool $paginationLinkHeader): static
+ {
+ $self = clone $this;
+ $self->paginationLinkHeader = $paginationLinkHeader;
+
+ return $self;
+ }
+
public function getStatus(): ?int
{
return $this->status;
diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php
index 4bebfa435e7..556bfb75ea1 100644
--- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php
+++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php
@@ -46,6 +46,7 @@ final class XmlResourceAdapter implements ResourceAdapterInterface
'paginationClientItemsPerPage',
'paginationClientPartial',
'paginationEnabled',
+ 'paginationLinkHeader',
'paginationFetchJoinCollection',
'paginationUseOutputWalkers',
'paginationItemsPerPage',
diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml
index 15883953196..4568e75842f 100644
--- a/src/Metadata/Tests/Extractor/Adapter/resources.xml
+++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml
@@ -1,3 +1,3 @@
-
+
diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml
index fe1595bf154..1bc78ae11e4 100644
--- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml
+++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml
@@ -90,6 +90,7 @@ resources:
paginationViaCursor:
userId: DESC
paginationEnabled: false
+ paginationLinkHeader: false
paginationFetchJoinCollection: false
paginationUseOutputWalkers: false
paginationItemsPerPage: 54
@@ -350,3 +351,4 @@ resources:
'Lorem ipsum': 'Dolor sit amet'
map: null
mcp: null
+ paginationLinkHeader: true
diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php
index acda234e4b1..9b9aada44ce 100644
--- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php
+++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php
@@ -84,6 +84,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase
'paginationClientItemsPerPage' => true,
'paginationClientPartial' => true,
'paginationEnabled' => true,
+ 'paginationLinkHeader' => true,
'paginationFetchJoinCollection' => true,
'paginationUseOutputWalkers' => true,
'paginationItemsPerPage' => 42,
@@ -389,6 +390,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase
'userId' => 'DESC',
],
'paginationEnabled' => false,
+ 'paginationLinkHeader' => false,
'paginationFetchJoinCollection' => false,
'paginationUseOutputWalkers' => false,
'paginationItemsPerPage' => 54,
@@ -513,6 +515,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase
'jsonldContext',
'openapi',
'paginationViaCursor',
+ 'paginationLinkHeader',
'stateOptions',
'links',
'rules',
diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php
index 14e94d18a08..7f2471b9f8c 100644
--- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php
+++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php
@@ -56,6 +56,7 @@ public function testValidXML(): void
'paginationClientItemsPerPage' => null,
'paginationClientPartial' => null,
'paginationEnabled' => null,
+ 'paginationLinkHeader' => null,
'paginationFetchJoinCollection' => null,
'paginationUseOutputWalkers' => null,
'paginationItemsPerPage' => null,
@@ -132,6 +133,7 @@ public function testValidXML(): void
'paginationClientItemsPerPage' => null,
'paginationClientPartial' => null,
'paginationEnabled' => null,
+ 'paginationLinkHeader' => null,
'paginationFetchJoinCollection' => null,
'paginationUseOutputWalkers' => null,
'paginationItemsPerPage' => null,
@@ -280,6 +282,7 @@ public function testValidXML(): void
'method' => null,
'priority' => null,
'routePriority' => null,
+ 'paginationLinkHeader' => null,
'processor' => null,
'provider' => null,
'itemUriTemplate' => null,
@@ -390,6 +393,7 @@ public function testValidXML(): void
'method' => null,
'priority' => null,
'routePriority' => null,
+ 'paginationLinkHeader' => null,
'processor' => null,
'provider' => null,
'stateOptions' => null,
diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php
index 62d820e1c84..4f097adbb88 100644
--- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php
+++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php
@@ -55,6 +55,7 @@ public function testValidYaml(): void
'paginationClientItemsPerPage' => null,
'paginationClientPartial' => null,
'paginationEnabled' => null,
+ 'paginationLinkHeader' => null,
'paginationFetchJoinCollection' => null,
'paginationUseOutputWalkers' => null,
'paginationItemsPerPage' => null,
@@ -131,6 +132,7 @@ public function testValidYaml(): void
'paginationClientItemsPerPage' => null,
'paginationClientPartial' => null,
'paginationEnabled' => null,
+ 'paginationLinkHeader' => null,
'paginationFetchJoinCollection' => null,
'paginationUseOutputWalkers' => null,
'paginationItemsPerPage' => null,
@@ -205,6 +207,7 @@ public function testValidYaml(): void
'paginationClientItemsPerPage' => null,
'paginationClientPartial' => null,
'paginationEnabled' => null,
+ 'paginationLinkHeader' => null,
'paginationFetchJoinCollection' => null,
'paginationUseOutputWalkers' => null,
'paginationItemsPerPage' => null,
@@ -321,6 +324,7 @@ public function testValidYaml(): void
'queryParameterValidate' => null,
'priority' => null,
'routePriority' => null,
+ 'paginationLinkHeader' => null,
'processor' => null,
'provider' => null,
'itemUriTemplate' => null,
@@ -413,6 +417,7 @@ public function testValidYaml(): void
'queryParameterValidate' => null,
'priority' => null,
'routePriority' => null,
+ 'paginationLinkHeader' => null,
'processor' => null,
'provider' => null,
'stateOptions' => null,
@@ -462,6 +467,7 @@ public function testValidYaml(): void
'paginationClientItemsPerPage' => null,
'paginationClientPartial' => null,
'paginationEnabled' => null,
+ 'paginationLinkHeader' => null,
'paginationFetchJoinCollection' => null,
'paginationUseOutputWalkers' => null,
'paginationItemsPerPage' => null,
diff --git a/src/State/Processor/PaginationLinkProcessor.php b/src/State/Processor/PaginationLinkProcessor.php
new file mode 100644
index 00000000000..94294aac40d
--- /dev/null
+++ b/src/State/Processor/PaginationLinkProcessor.php
@@ -0,0 +1,108 @@
+
+ *
+ * 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\State\Processor;
+
+use ApiPlatform\Metadata\CollectionOperationInterface;
+use ApiPlatform\Metadata\HttpOperation;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\UrlGeneratorInterface;
+use ApiPlatform\Metadata\Util\IriHelper;
+use ApiPlatform\State\Pagination\Pagination;
+use ApiPlatform\State\Pagination\PaginatorInterface;
+use ApiPlatform\State\Pagination\PartialPaginatorInterface;
+use ApiPlatform\State\ProcessorInterface;
+use ApiPlatform\State\Util\CorsTrait;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\WebLink\GenericLinkProvider;
+use Symfony\Component\WebLink\Link;
+
+/**
+ * A single-page collection still gets `first` and `last`, where the body links emit nothing: a HEAD response
+ * (RFC 9110 §9.3.2) carries no body from which a client could derive them. Cursor pagination is out of
+ * scope: RFC 8288 relations address pages, not cursors.
+ *
+ * @template T1
+ * @template T2
+ *
+ * @implements ProcessorInterface
+ *
+ * @author Julien Robic
+ */
+final class PaginationLinkProcessor implements ProcessorInterface
+{
+ use CorsTrait;
+
+ /**
+ * @param ProcessorInterface $decorated
+ */
+ public function __construct(private readonly ProcessorInterface $decorated, private readonly Pagination $pagination)
+ {
+ }
+
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ if (
+ !($request = $context['request'] ?? null)
+ || !$request instanceof Request
+ || !$operation instanceof HttpOperation
+ || !$operation instanceof CollectionOperationInterface
+ || true !== $operation->getPaginationLinkHeader()
+ || null !== $operation->getPaginationViaCursor()
+ || $this->isPreflightRequest($request)
+ ) {
+ return $this->decorated->process($data, $operation, $uriVariables, $context);
+ }
+
+ $paginator = $context['original_data'] ?? $data;
+ if (!$paginator instanceof PartialPaginatorInterface) {
+ return $this->decorated->process($data, $operation, $uriVariables, $context);
+ }
+
+ $options = $this->pagination->getOptions();
+ $pageParameterName = $options['page_parameter_name'];
+ $parsed = IriHelper::parseIri($request->getUri(), $pageParameterName);
+ $parameters = $parsed['parameters'];
+
+ $itemsPerPage = $paginator->getItemsPerPage();
+
+ $urlGenerationStrategy = $operation->getUrlGenerationStrategy() ?? UrlGeneratorInterface::ABS_PATH;
+ $url = static fn (float $page): string => IriHelper::createIri($parsed['parts'], $parameters, $pageParameterName, $page, $urlGenerationStrategy);
+
+ // REFACTOR-WHEN: a third consumer of the page-link math appears → move Hydra's PaginationHelperTrait to State\Util and share it
+ $currentPage = $paginator->getCurrentPage();
+ $lastPage = $paginator instanceof PaginatorInterface ? $paginator->getLastPage() : null;
+
+ $pages = ['self' => $currentPage];
+ if (null !== $lastPage) {
+ $pages['first'] = 1.;
+ }
+ if ($currentPage > 1.) {
+ $pages['prev'] = $currentPage - 1.;
+ }
+ if ((null !== $lastPage && $currentPage < $lastPage) || (null === $lastPage && \count($paginator) >= $itemsPerPage)) {
+ $pages['next'] = $currentPage + 1.;
+ }
+ if (null !== $lastPage) {
+ $pages['last'] = $lastPage;
+ }
+
+ $linkProvider = $request->attributes->get('_api_platform_links') ?? new GenericLinkProvider();
+ foreach ($pages as $rel => $page) {
+ $linkProvider = $linkProvider->withLink(new Link($rel, $url($page)));
+ }
+ $request->attributes->set('_api_platform_links', $linkProvider);
+
+ return $this->decorated->process($data, $operation, $uriVariables, $context);
+ }
+}
diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php
index 3a75bcba35d..ab4fa55bc40 100644
--- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php
+++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php
@@ -360,6 +360,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array
$loader->load('state/processor.php');
}
$loader->load('state/parameter_provider.php');
+ $loader->load('state/pagination_link.php');
$container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']);
$container->setParameter('api_platform.enable_docs', $config['enable_docs']);
diff --git a/src/Symfony/Bundle/Resources/config/state/pagination_link.php b/src/Symfony/Bundle/Resources/config/state/pagination_link.php
new file mode 100644
index 00000000000..f795e627d08
--- /dev/null
+++ b/src/Symfony/Bundle/Resources/config/state/pagination_link.php
@@ -0,0 +1,27 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+
+use ApiPlatform\State\Processor\PaginationLinkProcessor;
+
+return static function (ContainerConfigurator $container) {
+ $services = $container->services();
+
+ $services->set('api_platform.state_processor.pagination_link', PaginationLinkProcessor::class)
+ ->decorate('api_platform.state_processor.respond', null, 420)
+ ->args([
+ service('api_platform.state_processor.pagination_link.inner'),
+ service('api_platform.pagination'),
+ ]);
+};
diff --git a/tests/Fixtures/TestBundle/ApiResource/PaginationLink/PaginationLinkResource.php b/tests/Fixtures/TestBundle/ApiResource/PaginationLink/PaginationLinkResource.php
new file mode 100644
index 00000000000..f0d90fb4fbf
--- /dev/null
+++ b/tests/Fixtures/TestBundle/ApiResource/PaginationLink/PaginationLinkResource.php
@@ -0,0 +1,160 @@
+
+ *
+ * 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\PaginationLink;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\UrlGeneratorInterface;
+use ApiPlatform\State\Pagination\ArrayPaginator;
+
+#[ApiResource(
+ shortName: 'PaginationLink',
+ operations: [
+ new GetCollection(
+ uriTemplate: '/pagination_links',
+ paginationLinkHeader: true,
+ provider: [self::class, 'provideCollection'],
+ ),
+ new GetCollection(
+ uriTemplate: '/pagination_links_disabled',
+ provider: [self::class, 'provideCollection'],
+ ),
+ new GetCollection(
+ uriTemplate: '/pagination_links_single',
+ paginationLinkHeader: true,
+ provider: [self::class, 'provideSinglePage'],
+ ),
+ new GetCollection(
+ uriTemplate: '/pagination_links_partial',
+ paginationLinkHeader: true,
+ paginationPartial: true,
+ provider: [self::class, 'providePartialCollection'],
+ ),
+ new GetCollection(
+ uriTemplate: '/pagination_links_client_size',
+ paginationLinkHeader: true,
+ paginationClientItemsPerPage: true,
+ provider: [self::class, 'provideCollection'],
+ ),
+ new GetCollection(
+ uriTemplate: '/pagination_links_fixed_size',
+ paginationLinkHeader: true,
+ paginationClientItemsPerPage: false,
+ provider: [self::class, 'provideCollection'],
+ ),
+ new GetCollection(
+ uriTemplate: '/pagination_links_absolute',
+ paginationLinkHeader: true,
+ urlGenerationStrategy: UrlGeneratorInterface::ABS_URL,
+ provider: [self::class, 'provideCollection'],
+ ),
+ ],
+ paginationItemsPerPage: 10,
+)]
+final class PaginationLinkResource
+{
+ public const TOTAL_ITEMS = 25;
+ public const SINGLE_PAGE_TOTAL_ITEMS = 5;
+
+ #[ApiProperty(identifier: true)]
+ public int $id;
+
+ public string $name;
+
+ public function __construct(int $id)
+ {
+ $this->id = $id;
+ $this->name = "Item #{$id}";
+ }
+
+ /**
+ * @param array $uriVariables
+ * @param array $context
+ */
+ public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): ArrayPaginator
+ {
+ $filters = $context['filters'] ?? [];
+ $items = self::filter(self::TOTAL_ITEMS, $filters);
+ $itemsPerPage = self::itemsPerPage($operation, $filters);
+
+ return new ArrayPaginator($items, (self::page($filters) - 1) * $itemsPerPage, $itemsPerPage);
+ }
+
+ /**
+ * @param array $uriVariables
+ * @param array $context
+ */
+ public static function provideSinglePage(Operation $operation, array $uriVariables = [], array $context = []): ArrayPaginator
+ {
+ $filters = $context['filters'] ?? [];
+ $items = self::filter(self::SINGLE_PAGE_TOTAL_ITEMS, $filters);
+ $itemsPerPage = self::itemsPerPage($operation, $filters);
+
+ return new ArrayPaginator($items, (self::page($filters) - 1) * $itemsPerPage, $itemsPerPage);
+ }
+
+ /**
+ * @param array $uriVariables
+ * @param array $context
+ */
+ public static function providePartialCollection(Operation $operation, array $uriVariables = [], array $context = []): PartialItems
+ {
+ $filters = $context['filters'] ?? [];
+ $items = self::filter(self::TOTAL_ITEMS, $filters);
+ $itemsPerPage = self::itemsPerPage($operation, $filters);
+ $page = self::page($filters);
+
+ return new PartialItems(\array_slice($items, ($page - 1) * $itemsPerPage, $itemsPerPage), (float) $page, (float) $itemsPerPage);
+ }
+
+ /**
+ * @param array $filters
+ *
+ * @return list
+ */
+ private static function filter(int $totalItems, array $filters): array
+ {
+ $items = array_map(static fn (int $id): self => new self($id), range(1, $totalItems));
+
+ if (!isset($filters['name'])) {
+ return $items;
+ }
+
+ return array_values(array_filter($items, static fn (self $item): bool => $item->name === $filters['name']));
+ }
+
+ /**
+ * @param array $filters
+ */
+ private static function page(array $filters): int
+ {
+ return max(1, (int) ($filters['page'] ?? 1));
+ }
+
+ /**
+ * @param array $filters
+ */
+ private static function itemsPerPage(Operation $operation, array $filters): int
+ {
+ $itemsPerPage = $operation->getPaginationItemsPerPage() ?? 10;
+
+ if (true === $operation->getPaginationClientItemsPerPage() && isset($filters['itemsPerPage'])) {
+ $itemsPerPage = max(1, (int) $filters['itemsPerPage']);
+ }
+
+ return $itemsPerPage;
+ }
+}
diff --git a/tests/Fixtures/TestBundle/ApiResource/PaginationLink/PartialItems.php b/tests/Fixtures/TestBundle/ApiResource/PaginationLink/PartialItems.php
new file mode 100644
index 00000000000..b13f9be1274
--- /dev/null
+++ b/tests/Fixtures/TestBundle/ApiResource/PaginationLink/PartialItems.php
@@ -0,0 +1,52 @@
+
+ *
+ * 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\PaginationLink;
+
+use ApiPlatform\State\Pagination\PartialPaginatorInterface;
+
+/**
+ * @implements PartialPaginatorInterface
+ */
+final class PartialItems implements \IteratorAggregate, PartialPaginatorInterface
+{
+ /**
+ * @param list $items
+ */
+ public function __construct(
+ private readonly array $items,
+ private readonly float $currentPage,
+ private readonly float $itemsPerPage,
+ ) {
+ }
+
+ public function count(): int
+ {
+ return \count($this->items);
+ }
+
+ public function getCurrentPage(): float
+ {
+ return $this->currentPage;
+ }
+
+ public function getItemsPerPage(): float
+ {
+ return $this->itemsPerPage;
+ }
+
+ public function getIterator(): \Traversable
+ {
+ return new \ArrayIterator($this->items);
+ }
+}
diff --git a/tests/Functional/PaginationHeadersAcceptanceTest.php b/tests/Functional/PaginationHeadersAcceptanceTest.php
new file mode 100644
index 00000000000..6a0d13d7c5a
--- /dev/null
+++ b/tests/Functional/PaginationHeadersAcceptanceTest.php
@@ -0,0 +1,170 @@
+
+ *
+ * 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;
+
+use ApiPlatform\Test\ApiTestCase;
+use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PaginationLink\PaginationLinkResource;
+use ApiPlatform\Tests\SetupClassResourcesTrait;
+use Symfony\Component\WebLink\HttpHeaderParser;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+final class PaginationHeadersAcceptanceTest extends ApiTestCase
+{
+ use SetupClassResourcesTrait;
+
+ private const JSON_LD = ['Accept' => 'application/ld+json'];
+
+ protected static ?bool $alwaysBootKernel = false;
+
+ /**
+ * @return class-string[]
+ */
+ public static function getResources(): array
+ {
+ return [PaginationLinkResource::class];
+ }
+
+ public function testHeadTellsThePageCountWithoutAnyBodyButNotThePageSize(): void
+ {
+ $response = self::createClient()->request('HEAD', '/pagination_links?page=2', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $this->assertSame('', $response->getContent(false));
+ $state = self::paginationState($response);
+ $this->assertSame(2, $state['current_page']);
+ $this->assertSame(3, $state['page_count']);
+ $this->assertNull($state['items_per_page']);
+ $this->assertNull($state['total_min']);
+ $this->assertNull($state['total_max']);
+ }
+
+ public function testGetAdvertisesTheStateInTheLinkHeader(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links?page=3', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $state = self::paginationState($response);
+ $this->assertSame(3, $state['current_page']);
+ $this->assertSame(3, $state['page_count']);
+ $this->assertArrayNotHasKey('next', $state['relations']);
+ $this->assertArrayHasKey('prev', $state['relations']);
+ }
+
+ public function testASinglePageCollectionStillTellsItsPageCountOnHead(): void
+ {
+ $response = self::createClient()->request('HEAD', '/pagination_links_single', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $this->assertSame('', $response->getContent(false));
+ $state = self::paginationState($response);
+ $this->assertSame(1, $state['current_page']);
+ $this->assertSame(1, $state['page_count']);
+ $this->assertNull($state['items_per_page']);
+ $this->assertNull($state['total_min']);
+ $this->assertNull($state['total_max']);
+ $this->assertArrayNotHasKey('prev', $state['relations']);
+ $this->assertArrayNotHasKey('next', $state['relations']);
+ }
+
+ public function testAPartialPaginatorAdmitsItDoesNotKnowTheTotal(): void
+ {
+ $response = self::createClient()->request('HEAD', '/pagination_links_partial?page=2', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $this->assertSame('', $response->getContent(false));
+ $state = self::paginationState($response);
+ $this->assertArrayNotHasKey('last', $state['relations']);
+ $this->assertNull($state['page_count']);
+ $this->assertNull($state['total_min']);
+ $this->assertNull($state['total_max']);
+ $this->assertSame(2, $state['current_page']);
+ $this->assertNull($state['items_per_page']);
+ $this->assertArrayHasKey('next', $state['relations']);
+ }
+
+ public function testTheStateIsIdenticalBetweenHeadAndGet(): void
+ {
+ $client = self::createClient();
+
+ $head = self::paginationState($client->request('HEAD', '/pagination_links?page=2', ['headers' => self::JSON_LD]));
+ $get = self::paginationState($client->request('GET', '/pagination_links?page=2', ['headers' => self::JSON_LD]));
+
+ $this->assertSame($get, $head);
+ }
+
+ public function testTheLinksCarryOnlyWhatTheClientSent(): void
+ {
+ $client = self::createClient();
+
+ $withoutAPageSize = self::paginationState($client->request('GET', '/pagination_links?page=2', ['headers' => self::JSON_LD]));
+ $withAPageSize = self::paginationState($client->request('GET', '/pagination_links_client_size?itemsPerPage=5&page=2', ['headers' => self::JSON_LD]));
+
+ $this->assertSame([], self::pageSizes($withoutAPageSize['relations']));
+ $this->assertSame(['self' => '5', 'first' => '5', 'prev' => '5', 'next' => '5', 'last' => '5'], self::pageSizes($withAPageSize['relations']));
+ }
+
+ /**
+ * @return array{current_page: ?int, items_per_page: ?int, page_count: ?int, total_min: ?int, total_max: ?int, relations: array}
+ */
+ private static function paginationState(ResponseInterface $response): array
+ {
+ $headers = $response->getHeaders(false);
+
+ $relations = [];
+ foreach ((new HttpHeaderParser())->parse($headers['link'] ?? [])->getLinks() as $link) {
+ foreach ($link->getRels() as $rel) {
+ $relations[$rel] = $link->getHref();
+ }
+ }
+
+ $self = self::queryParameters($relations['self'] ?? null);
+ $last = self::queryParameters($relations['last'] ?? null);
+
+ $itemsPerPage = isset($self['itemsPerPage']) ? (int) $self['itemsPerPage'] : null;
+ $pageCount = isset($last['page']) ? (int) $last['page'] : null;
+
+ return [
+ 'current_page' => isset($self['page']) ? (int) $self['page'] : null,
+ 'items_per_page' => $itemsPerPage,
+ 'page_count' => $pageCount,
+ 'total_min' => null === $pageCount || null === $itemsPerPage ? null : ($pageCount - 1) * $itemsPerPage + 1,
+ 'total_max' => null === $pageCount || null === $itemsPerPage ? null : $pageCount * $itemsPerPage,
+ 'relations' => $relations,
+ ];
+ }
+
+ /**
+ * @param array $relations
+ *
+ * @return array
+ */
+ private static function pageSizes(array $relations): array
+ {
+ return array_filter(array_map(static fn (string $href): ?string => self::queryParameters($href)['itemsPerPage'] ?? null, $relations), \is_string(...));
+ }
+
+ /**
+ * @return array
+ */
+ private static function queryParameters(?string $href): array
+ {
+ if (null === $href) {
+ return [];
+ }
+
+ parse_str(parse_url($href, \PHP_URL_QUERY) ?: '', $query);
+
+ return array_filter($query, \is_string(...));
+ }
+}
diff --git a/tests/Functional/PaginationLinkHeaderTest.php b/tests/Functional/PaginationLinkHeaderTest.php
new file mode 100644
index 00000000000..79f701a6ad4
--- /dev/null
+++ b/tests/Functional/PaginationLinkHeaderTest.php
@@ -0,0 +1,211 @@
+
+ *
+ * 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;
+
+use ApiPlatform\Test\ApiTestCase;
+use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PaginationLink\PaginationLinkResource;
+use ApiPlatform\Tests\SetupClassResourcesTrait;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+final class PaginationLinkHeaderTest extends ApiTestCase
+{
+ use SetupClassResourcesTrait;
+
+ private const JSON_LD = ['Accept' => 'application/ld+json'];
+
+ protected static ?bool $alwaysBootKernel = false;
+
+ /**
+ * @return class-string[]
+ */
+ public static function getResources(): array
+ {
+ return [PaginationLinkResource::class];
+ }
+
+ public function testAMiddlePageAdvertisesEveryRelation(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links?page=2', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="prev"', $links);
+ $this->assertStringContainsString('; rel="next"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ }
+
+ public function testAHeadRequestAdvertisesTheSameRelationsWithoutABody(): void
+ {
+ $response = self::createClient()->request('HEAD', '/pagination_links?page=2', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="prev"', $links);
+ $this->assertStringContainsString('; rel="next"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ $this->assertSame('', $response->getContent(false));
+ }
+
+ public function testTheFirstPageHasNoPreviousRelation(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links?page=1', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="next"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ $this->assertStringNotContainsString('rel="prev"', $links);
+ }
+
+ public function testTheLastPageHasNoNextRelation(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links?page=3', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="prev"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ $this->assertStringNotContainsString('rel="next"', $links);
+ }
+
+ public function testASinglePageCollectionStillAdvertisesFirstAndLast(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links_single', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ $this->assertStringNotContainsString('rel="prev"', $links);
+ $this->assertStringNotContainsString('rel="next"', $links);
+ }
+
+ public function testACollectionWithoutTheFlagAdvertisesNoPaginationRelation(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links_disabled?page=2', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringNotContainsString('rel="self"', $links);
+ $this->assertStringNotContainsString('rel="first"', $links);
+ $this->assertStringNotContainsString('rel="prev"', $links);
+ $this->assertStringNotContainsString('rel="next"', $links);
+ $this->assertStringNotContainsString('rel="last"', $links);
+ }
+
+ public function testAClientChosenPageSizeIsCarriedByEveryRelation(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links_client_size?itemsPerPage=5&page=2', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="prev"', $links);
+ $this->assertStringContainsString('; rel="next"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ }
+
+ public function testAPageSizeTheOperationRefusesIsEchoedWithoutBeingApplied(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links_fixed_size?itemsPerPage=5&page=2', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="prev"', $links);
+ $this->assertStringContainsString('; rel="next"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ }
+
+ public function testTheOtherFiltersArePreservedInEveryRelation(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links?name=Item%20%2312&page=1', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ $this->assertStringNotContainsString('rel="prev"', $links);
+ $this->assertStringNotContainsString('rel="next"', $links);
+ }
+
+ public function testAPartialPaginatorAdvertisesNeitherFirstNorLast(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links_partial?page=2', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="prev"', $links);
+ $this->assertStringContainsString('; rel="next"', $links);
+ $this->assertStringNotContainsString('rel="first"', $links);
+ $this->assertStringNotContainsString('rel="last"', $links);
+ }
+
+ public function testAnIncompletePartialPageHasNoNextRelation(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links_partial?page=3', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="prev"', $links);
+ $this->assertStringNotContainsString('rel="next"', $links);
+ $this->assertStringNotContainsString('rel="first"', $links);
+ $this->assertStringNotContainsString('rel="last"', $links);
+ }
+
+ public function testTheRelationsAreFlatUnderJsonApi(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links?page=2', ['headers' => ['Accept' => 'application/vnd.api+json']]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="prev"', $links);
+ $this->assertStringContainsString('; rel="next"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ }
+
+ public function testAnAbsoluteUrlStrategyAdvertisesAbsoluteRelations(): void
+ {
+ $response = self::createClient()->request('GET', '/pagination_links_absolute?page=2', ['headers' => self::JSON_LD]);
+
+ $this->assertResponseStatusCodeSame(200);
+ $links = self::linkHeader($response);
+ $this->assertStringContainsString('; rel="self"', $links);
+ $this->assertStringContainsString('; rel="first"', $links);
+ $this->assertStringContainsString('; rel="prev"', $links);
+ $this->assertStringContainsString('; rel="next"', $links);
+ $this->assertStringContainsString('; rel="last"', $links);
+ }
+
+ private static function linkHeader(ResponseInterface $response): string
+ {
+ return implode(',', $response->getHeaders(false)['link'] ?? []);
+ }
+}
diff --git a/tests/State/PaginationLinkProcessorTest.php b/tests/State/PaginationLinkProcessorTest.php
new file mode 100644
index 00000000000..463a08d18bf
--- /dev/null
+++ b/tests/State/PaginationLinkProcessorTest.php
@@ -0,0 +1,165 @@
+
+ *
+ * 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\State;
+
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\Pagination\ArrayPaginator;
+use ApiPlatform\State\Pagination\Pagination;
+use ApiPlatform\State\Processor\PaginationLinkProcessor;
+use ApiPlatform\State\ProcessorInterface;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\WebLink\GenericLinkProvider;
+use Symfony\Component\WebLink\Link;
+
+final class PaginationLinkProcessorTest extends TestCase
+{
+ /**
+ * @param array $context
+ */
+ #[DataProvider('provideGuardsFallThroughCases')]
+ public function testAGuardFallsThroughUntouched(Operation $operation, mixed $data, array $context): void
+ {
+ $processed = new \stdClass();
+ $decorated = $this->createMock(ProcessorInterface::class);
+ $decorated
+ ->expects($this->once())
+ ->method('process')
+ ->with($data, $operation, [], $context)
+ ->willReturn($processed);
+
+ $processor = new PaginationLinkProcessor($decorated, new Pagination());
+
+ $this->assertSame($processed, $processor->process($data, $operation, [], $context));
+ $this->assertSame([], self::links($context['request'] ?? null));
+ }
+
+ /**
+ * @return iterable}>
+ */
+ public static function provideGuardsFallThroughCases(): iterable
+ {
+ yield 'no request in the context' => [new GetCollection(paginationLinkHeader: true), self::paginator(2), []];
+
+ yield 'item operation' => [(new Get())->withPaginationLinkHeader(true), self::paginator(2), ['request' => Request::create('/books/1')]];
+
+ yield 'flag not set' => [new GetCollection(), self::paginator(2), ['request' => Request::create('/books?page=2')]];
+
+ yield 'cursor pagination' => [new GetCollection(paginationLinkHeader: true, paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']]), self::paginator(2), ['request' => Request::create('/books?page=2')]];
+
+ yield 'cors preflight' => [new GetCollection(paginationLinkHeader: true), self::paginator(2), ['request' => self::preflightRequest()]];
+
+ yield 'data is not a paginator' => [new GetCollection(paginationLinkHeader: true), new \stdClass(), ['request' => Request::create('/books?page=2')]];
+ }
+
+ public function testAMiddlePageAdvertisesEveryRelation(): void
+ {
+ $request = Request::create('/books?page=2');
+ $processor = new PaginationLinkProcessor($this->decorated(), new Pagination());
+
+ $processor->process(self::paginator(2), new GetCollection(paginationLinkHeader: true), [], ['request' => $request]);
+
+ $this->assertSame([
+ 'self' => '/books?page=2',
+ 'first' => '/books?page=1',
+ 'prev' => '/books?page=1',
+ 'next' => '/books?page=3',
+ 'last' => '/books?page=3',
+ ], self::links($request));
+ }
+
+ public function testTheRequestQueryIsCarriedIntoEveryRelationUntouched(): void
+ {
+ $request = Request::create('/books?itemsPerPage=0');
+ $processor = new PaginationLinkProcessor($this->decorated(), new Pagination());
+
+ $processor->process(new ArrayPaginator(range(1, 25), 0, 0), new GetCollection(paginationLinkHeader: true), [], ['request' => $request]);
+
+ $this->assertSame([
+ 'self' => '/books?itemsPerPage=0&page=1',
+ 'first' => '/books?itemsPerPage=0&page=1',
+ 'last' => '/books?itemsPerPage=0&page=1',
+ ], self::links($request));
+ }
+
+ public function testAnAlreadyProvidedLinkIsPreserved(): void
+ {
+ $request = Request::create('/books?page=2');
+ $request->attributes->set('_api_platform_links', new GenericLinkProvider([new Link('preload', '/style.css')]));
+ $processor = new PaginationLinkProcessor($this->decorated(), new Pagination());
+
+ $processor->process(self::paginator(2), new GetCollection(paginationLinkHeader: true), [], ['request' => $request]);
+
+ $links = self::links($request);
+ $this->assertSame('/style.css', $links['preload'] ?? null);
+ $this->assertCount(6, $links);
+ }
+
+ public function testTheOriginalDataWinsOverTheProcessedData(): void
+ {
+ $request = Request::create('/books?page=2');
+ $processor = new PaginationLinkProcessor($this->decorated(), new Pagination());
+
+ $processor->process(new \stdClass(), new GetCollection(paginationLinkHeader: true), [], ['request' => $request, 'original_data' => self::paginator(2)]);
+
+ $this->assertSame([
+ 'self' => '/books?page=2',
+ 'first' => '/books?page=1',
+ 'prev' => '/books?page=1',
+ 'next' => '/books?page=3',
+ 'last' => '/books?page=3',
+ ], self::links($request));
+ }
+
+ /**
+ * @return ProcessorInterface
+ */
+ private function decorated(): ProcessorInterface
+ {
+ $decorated = $this->createStub(ProcessorInterface::class);
+ $decorated->method('process')->willReturn(null);
+
+ return $decorated;
+ }
+
+ private static function preflightRequest(): Request
+ {
+ $request = Request::create('/books?page=2', 'OPTIONS');
+ $request->headers->set('Access-Control-Request-Method', 'GET');
+
+ return $request;
+ }
+
+ private static function paginator(int $page, int $itemsPerPage = 10, int $totalItems = 25): ArrayPaginator
+ {
+ return new ArrayPaginator(range(1, $totalItems), ($page - 1) * $itemsPerPage, $itemsPerPage);
+ }
+
+ /**
+ * @return array
+ */
+ private static function links(?Request $request): array
+ {
+ $links = [];
+
+ foreach ($request?->attributes->get('_api_platform_links')?->getLinks() ?? [] as $link) {
+ $links[implode(' ', $link->getRels())] = $link->getHref();
+ }
+
+ return $links;
+ }
+}