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;
     }