Skip to content

Commit 50f01eb

Browse files
committed
Add support for cursor pagination
1 parent 8e9b44d commit 50f01eb

22 files changed

+754
-144
lines changed

CHANGELOG.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,37 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
### ⚠️ Breaking Changes
12+
13+
- Change `Resource\Paginatable::paginate()` to accept the resolved offset and
14+
limit integers (plus the request context) and return the page of results
15+
instead of mutating the query in place
16+
- New contract for custom `Pagination` implementations:
17+
- Return results from the `paginate(object $query, Context $context): Page`
18+
- Add `meta` and `links` via the `Context` object
19+
20+
### Added
21+
22+
- Add cursor pagination support via `Endpoint\Index::cursorPaginate()` and the
23+
`Resource\CursorPaginatable` contract, following the
24+
`ethanresnick/cursor-pagination` profile
25+
- Introduce `MaxPageSizeExceededException` and
26+
`RangePaginationNotSupportedException` with JSON:API profile links for cursor
27+
pagination errors
28+
- Allow exceptions to attach `meta` and `links` members to the error response
29+
- Add `Context::$meta` and `Context::$links` as `ArrayObject` instances to allow
30+
callbacks to add meta information to the response document
31+
1132
## [1.0.0-beta.5] - 2025-09-27
1233

1334
### ⚠️ Breaking Changes
1435

15-
- `Tobyz\\JsonApiServer\\Laravel\\Filter\\EloquentFilter` renamed to
16-
`ColumnFilter`.
17-
- Remove the `Has` Laravel filter; use `WhereExists` instead.
36+
- `Tobyz\JsonApiServer\Laravel\Filter\EloquentFilter` renamed to `ColumnFilter`
37+
- Remove the `Has` Laravel filter; use `WhereExists` instead
1838
- Remove the `WhereDoesntHave` Laravel filter; use the operator support on
19-
`WhereHas` instead.
39+
`WhereHas` instead
2040
- Remove `Where::asNumber()`; express numeric comparisons with operators such as
21-
`filter[score][gt]=...` or `filter[score][lte]=...`.
41+
`filter[score][gt]=...` or `filter[score][lte]=...`
2242

2343
### Added
2444

docs/list.md

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,11 @@ The JSON:API specification reserves the `page` query parameter for
288288
[paginating collections](https://jsonapi.org/format/#fetching-pagination). The
289289
specification is agnostic about the pagination strategy used by the server.
290290

291-
Currently json-api-server supports an offset pagination strategy, using the
292-
`page[limit]` and `page[offset]` query parameters. Support for cursor pagination
293-
is planned.
291+
json-api-server supports both offset and cursor pagination strategies. Offset
292+
pagination uses the `page[limit]` and `page[offset]` query parameters, while
293+
cursor pagination follows the
294+
[`ethanresnick/cursor-pagination` profile](https://jsonapi.org/profiles/ethanresnick/cursor-pagination/)
295+
and relies on the `page[size]`, `page[after]`, and `page[before]` parameters.
294296

295297
### Offset Pagination
296298

@@ -306,25 +308,83 @@ is 50. If you would like to use different values, pass them as arguments to the
306308
`paginate` method:
307309

308310
```php
309-
Index::make()->paginate(10, 100);
311+
Index::make()->paginate(defaultLimit: 10, maxLimit: 100);
310312
```
311313

312314
You will also need to implement the `Tobyz\JsonApiServer\Resource\Paginatable`
313-
interface on your resource and specify how the limit and offset values should be
314-
applied to your query:
315+
interface on your resource and return a page of results:
315316

316317
```php
317318
use Tobyz\JsonApiServer\Context;
318319
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
320+
use Tobyz\JsonApiServer\Pagination\Page;
319321
use Tobyz\JsonApiServer\Resource\{Listable, Paginatable, AbstractResource};
320322

321323
class PostsResource extends AbstractResource implements Listable, Paginatable
322324
{
323-
// ...
325+
public function paginate(
326+
object $query,
327+
int $offset,
328+
int $limit,
329+
Context $context,
330+
): Page {
331+
return new Page(
332+
results: $this->results(
333+
$query->offset($offset)->limit($limit + 1),
334+
$context,
335+
),
336+
isLastPage: count($results) <= $limit,
337+
);
338+
}
339+
}
340+
```
341+
342+
### Cursor Pagination
343+
344+
Cursor pagination is enabled by calling the `cursorPaginate` method on the
345+
`Index` endpoint:
346+
347+
```php
348+
Index::make()->cursorPaginate();
349+
```
350+
351+
By default the page size is 20 with a maximum of 50. You can customise these
352+
values by passing arguments:
353+
354+
```php
355+
Index::make()->cursorPaginate(defaultSize: 25, maxSize: 100);
356+
```
357+
358+
Cursor pagination requires implementing the
359+
`Tobyz\JsonApiServer\Resource\CursorPaginatable` interface on the resource. The
360+
`cursorPaginate` method must return page of results, while the `itemCursor`
361+
method must return a cursor for the given model/query.
362+
363+
```php
364+
use Tobyz\JsonApiServer\Context;
365+
use Tobyz\JsonApiServer\Pagination\CursorPagination;
366+
use Tobyz\JsonApiServer\Pagination\Page;
367+
use Tobyz\JsonApiServer\Resource\CursorPaginatable;
368+
369+
class PostsResource extends AbstractResource implements
370+
Listable,
371+
CursorPaginatable
372+
{
373+
public function cursorPaginate(
374+
object $query,
375+
int $size,
376+
?string $after,
377+
?string $before,
378+
Context $context,
379+
): Page {
380+
// ...
381+
382+
return new Page($results, $isFirstPage, $isLastPage);
383+
}
324384

325-
public function paginate(object $query, OffsetPagination $pagination): void
385+
public function itemCursor($model, object $query, Context $context): string
326386
{
327-
$query->offset($pagination->offset)->limit($pagination->limit);
387+
// ...
328388
}
329389
}
330390
```

src/Context.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Tobyz\JsonApiServer;
44

5+
use ArrayObject;
56
use Psr\Http\Message\ServerRequestInterface;
67
use Tobyz\JsonApiServer\Endpoint\Endpoint;
78
use Tobyz\JsonApiServer\Resource\Collection;
@@ -19,6 +20,9 @@ class Context
1920
public mixed $model = null;
2021
public ?Field $field = null;
2122
public ?array $include = null;
23+
public ArrayObject $documentMeta;
24+
public ArrayObject $documentLinks;
25+
public WeakMap $resourceMeta;
2226

2327
private ?array $body;
2428
private ?string $path;
@@ -30,6 +34,11 @@ public function __construct(public JsonApi $api, public ServerRequestInterface $
3034
{
3135
$this->fields = new WeakMap();
3236
$this->sparseFields = new WeakMap();
37+
38+
$this->documentMeta = new ArrayObject();
39+
$this->documentLinks = new ArrayObject();
40+
41+
$this->resourceMeta = new WeakMap();
3342
}
3443

3544
/**
@@ -219,4 +228,11 @@ public function withInclude(?array $include): static
219228
$new->include = $include;
220229
return $new;
221230
}
231+
232+
public function resourceMeta($model, array $meta): static
233+
{
234+
$this->resourceMeta[$model] = array_merge($this->resourceMeta[$model] ?? [], $meta);
235+
236+
return $this;
237+
}
222238
}

src/Endpoint/Index.php

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace Tobyz\JsonApiServer\Endpoint;
44

5-
use Closure;
65
use Psr\Http\Message\ResponseInterface as Response;
76
use RuntimeException;
87
use Tobyz\JsonApiServer\Context;
@@ -13,7 +12,9 @@
1312
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
1413
use Tobyz\JsonApiServer\Exception\Sourceable;
1514
use Tobyz\JsonApiServer\OpenApi\OpenApiPathsProvider;
15+
use Tobyz\JsonApiServer\Pagination\CursorPagination;
1616
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
17+
use Tobyz\JsonApiServer\Pagination\Pagination;
1718
use Tobyz\JsonApiServer\Resource\Collection;
1819
use Tobyz\JsonApiServer\Resource\Countable;
1920
use Tobyz\JsonApiServer\Resource\Listable;
@@ -34,26 +35,24 @@ class Index implements Endpoint, OpenApiPathsProvider
3435
use HasDescription;
3536
use BuildsOpenApiPaths;
3637

37-
public Closure $paginationResolver;
38+
public ?Pagination $pagination = null;
3839
public ?string $defaultSort = null;
3940

40-
public function __construct()
41+
public static function make(): static
4142
{
42-
$this->paginationResolver = fn() => null;
43+
return new static();
4344
}
4445

45-
public static function make(): static
46+
public function paginate(int $defaultLimit = 20, ?int $maxLimit = 50): static
4647
{
47-
return new static();
48+
$this->pagination = new OffsetPagination($defaultLimit, $maxLimit);
49+
50+
return $this;
4851
}
4952

50-
public function paginate(int $defaultLimit = 20, int $maxLimit = 50): static
53+
public function cursorPaginate(int $defaultSize = 20, ?int $maxSize = 50): static
5154
{
52-
$this->paginationResolver = fn(Context $context) => new OffsetPagination(
53-
$context,
54-
$defaultLimit,
55-
$maxLimit,
56-
);
55+
$this->pagination = new CursorPagination($defaultSize, $maxSize);
5756

5857
return $this;
5958
}
@@ -94,22 +93,19 @@ public function handle(Context $context): ?Response
9493
$this->applySorts($query, $context);
9594
$this->applyFilters($query, $context);
9695

97-
$meta = $this->serializeMeta($context);
98-
$links = [];
99-
10096
if (
10197
$collection instanceof Countable &&
10298
!is_null($total = $collection->count($query, $context))
10399
) {
104-
$meta['page']['total'] = $collection->count($query, $context);
100+
$context->documentMeta['page']['total'] = $total;
105101
}
106102

107-
if ($pagination = ($this->paginationResolver)($context)) {
108-
$pagination->apply($query);
103+
if ($this->pagination) {
104+
$models = $this->pagination->paginate($query, $context);
105+
} else {
106+
$models = $collection->results($query, $context);
109107
}
110108

111-
$models = $collection->results($query, $context);
112-
113109
$serializer = new Serializer($context);
114110

115111
$include = $this->getInclude($context);
@@ -124,11 +120,14 @@ public function handle(Context $context): ?Response
124120

125121
[$data, $included] = $serializer->serialize();
126122

127-
if ($pagination) {
128-
$meta['page'] = array_merge($meta['page'] ?? [], $pagination->meta());
129-
$links = array_merge($links, $pagination->links(count($data), $total ?? null));
123+
$meta = $context->documentMeta;
124+
125+
foreach ($this->serializeMeta($context) as $key => $value) {
126+
$meta[$key] = $value;
130127
}
131128

129+
$links = $context->documentLinks;
130+
132131
return json_api_response(compact('data', 'included', 'meta', 'links'));
133132
}
134133

src/Exception/Concerns/SingleError.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
trait SingleError
88
{
99
protected ?array $source = null;
10+
protected ?array $meta = null;
11+
protected ?array $links = null;
1012

1113
public function setSource(?array $source): static
1214
{
@@ -24,6 +26,20 @@ public function prependSource(array $source): static
2426
return $this;
2527
}
2628

29+
public function setMeta(?array $meta): static
30+
{
31+
$this->meta = $meta;
32+
33+
return $this;
34+
}
35+
36+
public function setLinks(?array $links): static
37+
{
38+
$this->links = $links;
39+
40+
return $this;
41+
}
42+
2743
public function getJsonApiErrors(): array
2844
{
2945
$members = [];
@@ -36,6 +52,14 @@ public function getJsonApiErrors(): array
3652
$members['source'] = $this->source;
3753
}
3854

55+
if ($this->meta) {
56+
$members['meta'] = $this->meta;
57+
}
58+
59+
if ($this->links) {
60+
$members['links'] = $this->links;
61+
}
62+
3963
return [
4064
[
4165
'status' => $this->getJsonApiStatus(),

src/Laravel/EloquentCollection.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
use RuntimeException;
66
use Tobyz\JsonApiServer\Context;
7-
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
7+
use Tobyz\JsonApiServer\Pagination\Page;
88
use Tobyz\JsonApiServer\Resource\Collection;
99
use Tobyz\JsonApiServer\Resource\Countable;
1010
use Tobyz\JsonApiServer\Resource\Listable;
@@ -95,9 +95,11 @@ public function sorts(): array
9595
return [];
9696
}
9797

98-
public function paginate(object $query, OffsetPagination $pagination): void
98+
public function paginate(object $query, int $offset, int $limit, Context $context): Page
9999
{
100-
$query->take($pagination->limit)->skip($pagination->offset);
100+
$results = $this->results($query->take($limit + 1)->skip($offset), $context);
101+
102+
return new Page(array_slice($results, 0, $limit), isLastPage: count($results) <= $limit);
101103
}
102104

103105
public function count(object $query, Context $context): ?int

0 commit comments

Comments
 (0)