Skip to content

Commit

Permalink
Implement link header aware pagination
Browse files Browse the repository at this point in the history
  • Loading branch information
cerbero90 committed Feb 11, 2024
1 parent 45a55b1 commit 1e54757
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 10 deletions.
1 change: 1 addition & 0 deletions src/Dtos/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion src/LazyJsonPages.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/Paginations/AnyPagination.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Expand Down
104 changes: 104 additions & 0 deletions src/Paginations/LinkHeaderAwarePagination.php
Original file line number Diff line number Diff line change
@@ -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');
});
}
}
1 change: 1 addition & 0 deletions tests/Feature/Datasets.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
26 changes: 26 additions & 0 deletions tests/Feature/PaginationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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');

Expand All @@ -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')
Expand Down
15 changes: 8 additions & 7 deletions tests/Pest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down

0 comments on commit 1e54757

Please sign in to comment.