From 1e5475795740719b062bf5bd08ea740a85e32587 Mon Sep 17 00:00:00 2001 From: Andrea Marco Sartori <andrea.marco.sartori@gmail.com> Date: Sun, 11 Feb 2024 12:20:57 +1000 Subject: [PATCH] Implement link header aware pagination --- src/Dtos/Config.php | 1 + src/LazyJsonPages.php | 12 +- src/Paginations/AnyPagination.php | 4 +- src/Paginations/LinkHeaderAwarePagination.php | 104 ++++++++++++++++++ tests/Feature/Datasets.php | 1 + tests/Feature/PaginationTest.php | 26 +++++ tests/Pest.php | 15 +-- 7 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 src/Paginations/LinkHeaderAwarePagination.php diff --git a/src/Dtos/Config.php b/src/Dtos/Config.php index 3106e27..0c13352 100644 --- a/src/Dtos/Config.php +++ b/src/Dtos/Config.php @@ -27,6 +27,7 @@ public function __construct( public readonly ?string $cursorKey = null, public readonly ?string $lastPageKey = null, public readonly ?string $offsetKey = null, + public readonly bool $hasLinkHeader = false, public readonly ?string $pagination = null, public readonly int $async = 3, public readonly int $attempts = 3, diff --git a/src/LazyJsonPages.php b/src/LazyJsonPages.php index 99fb3e7..1d95a3d 100644 --- a/src/LazyJsonPages.php +++ b/src/LazyJsonPages.php @@ -135,6 +135,16 @@ public function offset(string $key = 'offset'): self return $this; } + /** + * Set the Link header pagination. + */ + public function linkHeader(): self + { + $this->config['hasLinkHeader'] = true; + + return $this; + } + /** * Set the custom pagination. */ @@ -216,7 +226,7 @@ public function collect(string $dot = '*'): LazyCollection $config = new Config(...$this->config, itemsPointer: DotsConverter::toPointer($dot)); - return new LazyCollection(function () use ($config) { + return new LazyCollection(function() use ($config) { try { yield from new AnyPagination($this->source, $config); } finally { diff --git a/src/Paginations/AnyPagination.php b/src/Paginations/AnyPagination.php index b2987e2..1b51ac0 100644 --- a/src/Paginations/AnyPagination.php +++ b/src/Paginations/AnyPagination.php @@ -18,10 +18,10 @@ class AnyPagination extends Pagination * @var class-string<Pagination>[] */ protected array $supportedPaginations = [ - CursorPagination::class, + CursorAwarePagination::class, CustomPagination::class, LastPageAwarePagination::class, - // LinkHeaderPagination::class, + LinkHeaderAwarePagination::class, TotalItemsAwarePagination::class, TotalPagesAwarePagination::class, ]; diff --git a/src/Paginations/LinkHeaderAwarePagination.php b/src/Paginations/LinkHeaderAwarePagination.php new file mode 100644 index 0000000..a380b50 --- /dev/null +++ b/src/Paginations/LinkHeaderAwarePagination.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +namespace Cerbero\LazyJsonPages\Paginations; + +use Cerbero\LazyJsonPages\Concerns\YieldsItemsByCursor; +use Cerbero\LazyJsonPages\Concerns\YieldsItemsByLength; +use Cerbero\LazyJsonPages\Exceptions\InvalidLinkHeaderException; +use Generator; +use Psr\Http\Message\ResponseInterface; +use Traversable; + +/** + * The pagination using a Link header. + */ +class LinkHeaderAwarePagination extends Pagination +{ + use YieldsItemsByCursor; + use YieldsItemsByLength; + + /** + * The Link header format. + */ + public const FORMAT = '~<\s*(?<uri>[^\s>]+)\s*>.*?"\s*(?<rel>[^\s"]+)\s*"~'; + + /** + * Determine whether the configuration matches this pagination. + */ + public function matches(): bool + { + return $this->config->hasLinkHeader + && $this->config->totalItemsKey === null + && $this->config->totalPagesKey === null + && $this->config->lastPageKey === null; + } + + /** + * Yield the paginated items. + * + * @return Traversable<int, mixed> + * @throws InvalidLinkHeaderException + */ + public function getIterator(): Traversable + { + $links = $this->parseLinkHeader($this->source->response()->getHeaderLine('link')); + + yield from match (true) { + isset($links['last']) => $this->yieldItemsByLastPage($links['last']), + isset($links['next']) => $this->yieldItemsByNextLink(), + default => $this->yieldItemsFrom($this->source->pullResponse()), + }; + } + + /** + * Retrieve the parsed Link header. + * + * @template TParsed of array{last?: int, next?: string|int} + * @template TRelation of string|null + * + * @param TRelation $relation + * @return (TRelation is null ? TParsed : string|int|null) + */ + protected function parseLinkHeader(string $linkHeader, ?string $relation = null): array|string|int|null + { + $links = []; + + preg_match_all(static::FORMAT, $linkHeader, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $links[$match['rel']] = $this->toPage($match['uri'], $match['rel'] != 'next'); + } + + return $relation ? ($links[$relation] ?? null) : $links; + } + + /** + * Yield the paginated items by the given last page. + * + * @return Generator<int, mixed> + */ + protected function yieldItemsByLastPage(int $lastPage): Generator + { + yield from $this->yieldItemsUntilPage(function(ResponseInterface $response) use ($lastPage) { + yield from $this->yieldItemsFrom($response); + + return $this->config->firstPage === 0 ? $lastPage + 1 : $lastPage; + }); + } + + /** + * Yield the paginated items by the given next link. + * + * @return Generator<int, mixed> + */ + protected function yieldItemsByNextLink(): Generator + { + yield from $this->yieldItemsByCursor(function(ResponseInterface $response) { + yield from $this->yieldItemsFrom($response); + + return $this->parseLinkHeader($response->getHeaderLine('link'), 'next'); + }); + } +} diff --git a/tests/Feature/Datasets.php b/tests/Feature/Datasets.php index 369b435..45ad9b6 100644 --- a/tests/Feature/Datasets.php +++ b/tests/Feature/Datasets.php @@ -31,6 +31,7 @@ yield 'total pages aware' => fn(LazyJsonPages $instance) => $instance->totalPages('meta.total_pages'); yield 'total items aware' => fn(LazyJsonPages $instance) => $instance->totalItems('meta.total_items'); yield 'last page aware' => fn(LazyJsonPages $instance) => $instance->lastPage('meta.last_page'); + yield 'link header' => fn(LazyJsonPages $instance) => $instance->linkHeader(); yield 'custom pagination' => fn(LazyJsonPages $instance) => $instance->pagination(TotalPagesAwarePagination::class)->totalPages('meta.total_pages'); yield 'total pages aware by header' => fn(LazyJsonPages $instance) => $instance->totalPages('X-Total-Pages'); yield 'total items aware by header' => fn(LazyJsonPages $instance) => $instance->totalItems('X-Total-Items'); diff --git a/tests/Feature/PaginationTest.php b/tests/Feature/PaginationTest.php index aa66ff8..0d0a67b 100644 --- a/tests/Feature/PaginationTest.php +++ b/tests/Feature/PaginationTest.php @@ -11,6 +11,11 @@ 'https://example.com/api/v1/users' => 'pagination/page1.json', 'https://example.com/api/v1/users?page=2' => 'pagination/page2.json', 'https://example.com/api/v1/users?page=3' => 'pagination/page3.json', + ], headers: [ + 'X-Total-Pages' => 3, + 'X-Total-Items' => 14, + 'X-Last-Page' => 3, + 'Link' => '<https://example.com/api/v1/users?page=3>;rel="last"', ]); })->with('length-aware'); @@ -23,6 +28,11 @@ 'https://example.com/api/v1/users' => 'paginationFirstPage0/page0.json', 'https://example.com/api/v1/users?page=1' => 'paginationFirstPage0/page1.json', 'https://example.com/api/v1/users?page=2' => 'paginationFirstPage0/page2.json', + ], headers: [ + 'X-Total-Pages' => 3, + 'X-Total-Items' => 14, + 'X-Last-Page' => 2, + 'Link' => '<https://example.com/api/v1/users?page=2>;rel="last"', ]); })->with('length-aware'); @@ -38,6 +48,22 @@ ]); }); +it('supports cursor-aware paginations with link header', function () { + $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users') + ->linkHeader() + ->collect('data.*'); + + expect($lazyCollection)->toLoadItemsViaRequests([ + 'https://example.com/api/v1/users' => 'pagination/page1.json', + 'https://example.com/api/v1/users?page=cursor1' => 'pagination/page2.json', + 'https://example.com/api/v1/users?page=cursor2' => 'pagination/page3.json', + ], headers: (function() { + yield ['Link' => '<https://example.com/api/v1/users?page=cursor1>;rel="next"']; + yield ['Link' => '<https://example.com/api/v1/users?page=cursor2>;rel="next"']; + yield ['Link' => '']; + })()); +}); + it('fails if an invalid custom pagination is provided', function () { $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users') ->pagination('Invalid') diff --git a/tests/Pest.php b/tests/Pest.php index 3f985fd..f91e43a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -30,16 +30,17 @@ | */ -expect()->extend('toLoadItemsViaRequests', function (array $requests) { +expect()->extend('toLoadItemsViaRequests', function (array $requests, Generator|array $headers = []) { $responses = $transactions = $expectedUris = []; - $headers = [ - 'X-Total-Pages' => 3, - 'X-Total-Items' => 14, - ]; + $responseHeaders = $headers; foreach ($requests as $uri => $fixture) { - $headers['X-Last-Page'] ??= str_contains($fixture, 'page0') ? 2 : 3; - $responses[] = new Response(body: file_get_contents(fixture($fixture)), headers: $headers); + if ($headers instanceof Generator) { + $responseHeaders = $headers->current(); + $headers->valid() && $headers->next(); + } + + $responses[] = new Response(body: file_get_contents(fixture($fixture)), headers: $responseHeaders); $expectedUris[] = $uri; }