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 @@ -someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet +someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet 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; + } +}