From 209c060e78b4862faa52e3dc165b7a17405e6434 Mon Sep 17 00:00:00 2001
From: Andrea Marco Sartori <andrea.marco.sartori@gmail.com>
Date: Tue, 30 Jan 2024 19:49:44 +1000
Subject: [PATCH] Support custom pagination

---
 README.md                                     | 35 ++++++++++++++++++
 src/Dtos/Config.php                           | 10 ++++++
 src/Exceptions/InvalidPaginationException.php | 23 ++++++++++++
 src/LazyJsonPages.php                         | 10 ++++++
 src/Paginations/AnyPagination.php             | 12 +------
 src/Paginations/CustomPagination.php          | 36 +++++++++++++++++++
 src/Paginations/Pagination.php                | 16 ++++++---
 tests/Feature/PaginationTest.php              | 25 +++++++++++++
 8 files changed, 151 insertions(+), 16 deletions(-)
 create mode 100644 src/Exceptions/InvalidPaginationException.php
 create mode 100644 src/Paginations/CustomPagination.php

diff --git a/README.md b/README.md
index ba83c47..56f63aa 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ composer require cerbero/lazy-json-pages
 * [🏛️ Pagination structure](#%EF%B8%8F-pagination-structure)
 * [📏 Length-aware paginations](#-length-aware-paginations)
 * [↪️ Cursor and next-page paginations](#%EF%B8%8F-cursor-and-next-page-paginations)
+* [👽 Custom pagination](#-custom-pagination)
 * [🚀 Requests optimization](#-requests-optimization)
 * [💢 Errors handling](#-errors-handling)
 
@@ -177,6 +178,40 @@ LazyJsonPages::from($source)
 > The documentation of this feature is a work in progress.
 
 
+### 👽 Custom pagination
+
+Lazy JSON Pages provides several methods to extract items from the most popular pagination mechanisms. However if we need a custom solution, we can implement our own pagination.
+
+To implement a custom pagination, we need to extend `Pagination` and implement 1 method:
+
+```php
+use Cerbero\LazyJsonPages\Paginations\Pagination;
+use Traversable;
+
+class CustomPagination extends Pagination
+{
+    public function getIterator(): Traversable
+    {
+        // return a Traversable holding the paginated items
+    }
+}
+```
+
+The parent class `Pagination` gives us access to 2 properties:
+- `$source`: the mean pointing to the paginated JSON API (see [sources](#-sources))
+- `$config`: the configuration that we generated by chaining methods like `totalPages()`
+
+The method `getIterator()` defines the logic to extract paginated items in a memory-efficient way. Please refer to the [already existing paginations](https://github.com/cerbero90/json-parser/tree/master/src/Paginations) to see some implementations.
+
+Once the custom pagination is implemented, we can instruct Lazy JSON Pages to use it:
+
+```php
+LazyJsonPages::from($source)->pagination(CustomPagination::class);
+```
+
+If you find yourself implementing the same custom pagination in different projects, feel free to send a PR and we will consider to support your custom pagination by default. Thank you in advance for any contribution!
+
+
 ### 🚀 Requests optimization
 
 > [!WARNING]
diff --git a/src/Dtos/Config.php b/src/Dtos/Config.php
index 9aab571..29244b8 100644
--- a/src/Dtos/Config.php
+++ b/src/Dtos/Config.php
@@ -4,10 +4,19 @@
 
 namespace Cerbero\LazyJsonPages\Dtos;
 
+use Cerbero\LazyJsonPages\Paginations\Pagination;
 use Closure;
 
+/**
+ * The configuration
+ *
+ * @property-read class-string<Pagination> $pagination
+ */
 final class Config
 {
+    /**
+     * Instantiate the class.
+     */
     public function __construct(
         public readonly string $pointer,
         public readonly string $pageName = 'page',
@@ -22,6 +31,7 @@ public function __construct(
         public readonly ?string $nextPageKey = null,
         public readonly ?int $lastPage = null,
         public readonly ?string $offsetKey = null,
+        public readonly ?string $pagination = null,
         public readonly int $async = 3,
         public readonly int $attempts = 3,
         public readonly ?Closure $backoff = null,
diff --git a/src/Exceptions/InvalidPaginationException.php b/src/Exceptions/InvalidPaginationException.php
new file mode 100644
index 0000000..97f4012
--- /dev/null
+++ b/src/Exceptions/InvalidPaginationException.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cerbero\LazyJsonPages\Exceptions;
+
+use Cerbero\LazyJsonPages\Paginations\Pagination;
+
+/**
+ * The exception to throw when the given pagination is invalid.
+ */
+class InvalidPaginationException extends LazyJsonPagesException
+{
+    /**
+     * Instantiate the class.
+     */
+    public function __construct(public readonly string $class)
+    {
+        $pagination = Pagination::class;
+
+        parent::__construct("The class [{$class}] should extend [{$pagination}].");
+    }
+}
diff --git a/src/LazyJsonPages.php b/src/LazyJsonPages.php
index 0e543d7..c7307e7 100644
--- a/src/LazyJsonPages.php
+++ b/src/LazyJsonPages.php
@@ -163,6 +163,16 @@ public function offset(string $key = 'offset'): self
         return $this;
     }
 
+    /**
+     * Set the custom pagination.
+     */
+    public function pagination(string $class): self
+    {
+        $this->config['pagination'] = $class;
+
+        return $this;
+    }
+
     /**
      * Fetch pages synchronously.
      */
diff --git a/src/Paginations/AnyPagination.php b/src/Paginations/AnyPagination.php
index 2c01700..dd56b4e 100644
--- a/src/Paginations/AnyPagination.php
+++ b/src/Paginations/AnyPagination.php
@@ -19,23 +19,13 @@ class AnyPagination extends Pagination
      */
     protected array $supportedPaginations = [
         // CursorPagination::class,
-        // CustomPagination::class,
+        CustomPagination::class,
         // LastPageAwarePagination::class,
-        // LimitPagination::class,
         // LinkHeaderPagination::class,
-        // OffsetPagination::class,
         TotalItemsAwarePagination::class,
         TotalPagesAwarePagination::class,
     ];
 
-    /**
-     * Determine whether this pagination matches the configuration.
-     */
-    public function matches(): bool
-    {
-        return true;
-    }
-
     /**
      * Yield the paginated items.
      *
diff --git a/src/Paginations/CustomPagination.php b/src/Paginations/CustomPagination.php
new file mode 100644
index 0000000..18339f2
--- /dev/null
+++ b/src/Paginations/CustomPagination.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cerbero\LazyJsonPages\Paginations;
+
+use Cerbero\LazyJsonPages\Exceptions\InvalidPaginationException;
+use Traversable;
+
+/**
+ * The user-defined pagination.
+ */
+class CustomPagination extends LengthAwarePagination
+{
+    /**
+     * Determine whether the configuration matches this pagination.
+     */
+    public function matches(): bool
+    {
+        return $this->config->pagination !== null;
+    }
+
+    /**
+     * Yield the paginated items.
+     *
+     * @return Traversable<int, mixed>
+     */
+    public function getIterator(): Traversable
+    {
+        if (!is_subclass_of($this->config->pagination, Pagination::class)) {
+            throw new InvalidPaginationException($this->config->pagination);
+        }
+
+        yield from new $this->config->pagination($this->source, $this->config);
+    }
+}
diff --git a/src/Paginations/Pagination.php b/src/Paginations/Pagination.php
index 9de6856..c9cfd80 100644
--- a/src/Paginations/Pagination.php
+++ b/src/Paginations/Pagination.php
@@ -32,11 +32,6 @@ abstract class Pagination implements IteratorAggregate
      */
     protected readonly int $itemsPerPage;
 
-    /**
-     * Determine whether the configuration matches this pagination.
-     */
-    abstract public function matches(): bool;
-
     /**
      * Yield the paginated items.
      *
@@ -44,10 +39,21 @@ abstract public function matches(): bool;
      */
     abstract public function getIterator(): Traversable;
 
+    /**
+     * Instantiate the class.
+     */
     final public function __construct(
         protected readonly Source $source,
         protected readonly Config $config,
     ) {
         $this->book = new Book();
     }
+
+    /**
+     * Determine whether the configuration matches this pagination.
+     */
+    public function matches(): bool
+    {
+        return true;
+    }
 }
diff --git a/tests/Feature/PaginationTest.php b/tests/Feature/PaginationTest.php
index 7013a45..1ee9e58 100644
--- a/tests/Feature/PaginationTest.php
+++ b/tests/Feature/PaginationTest.php
@@ -1,6 +1,8 @@
 <?php
 
+use Cerbero\LazyJsonPages\Exceptions\InvalidPaginationException;
 use Cerbero\LazyJsonPages\LazyJsonPages;
+use Cerbero\LazyJsonPages\Paginations\TotalPagesAwarePagination;
 
 it('supports paginations aware of their total pages', function () {
     $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users')
@@ -25,3 +27,26 @@
         'https://example.com/api/v1/users?page=3' => 'lengthAware/page3.json',
     ]);
 });
+
+it('supports custom paginations', function () {
+    $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users')
+        ->pagination(TotalPagesAwarePagination::class)
+        ->totalPages('meta.total_pages')
+        ->collect('data.*');
+
+    expect($lazyCollection)->toLoadItemsViaRequests([
+        'https://example.com/api/v1/users' => 'lengthAware/page1.json',
+        'https://example.com/api/v1/users?page=2' => 'lengthAware/page2.json',
+        'https://example.com/api/v1/users?page=3' => 'lengthAware/page3.json',
+    ]);
+});
+
+it('fails if an invalid custom pagination is provided', function () {
+    $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users')
+        ->pagination('Invalid')
+        ->collect('data.*');
+
+    expect($lazyCollection)->toLoadItemsViaRequests([
+        'https://example.com/api/v1/users' => 'lengthAware/page1.json',
+    ]);
+})->throws(InvalidPaginationException::class, 'The class [Invalid] should extend [Cerbero\LazyJsonPages\Paginations\Pagination].');