From 1d225397dcff1683c7ac1cd65ff28fc2915a9f64 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 3 Aug 2026 16:04:51 -0300 Subject: [PATCH 1/2] feat: Add withMetadata to the offset and cursor pages. A page derives its meta from the pagination alone, so an endpoint that owns a counter the page cannot derive had to decode and rewrap the rendered response, dropping the RFC 8288 Link header on the way. The supplied entries merge ahead of the pagination ones, so a repeated key never shadows the real page size or the navigation flags. --- src/Cursor/Page.php | 41 ++++++++++++-- src/Offset/Page.php | 39 +++++++++++-- tests/Unit/Cursor/PageTest.php | 100 +++++++++++++++++++++++++++++++++ tests/Unit/Offset/PageTest.php | 90 +++++++++++++++++++++++++++++ 4 files changed, 261 insertions(+), 9 deletions(-) diff --git a/src/Cursor/Page.php b/src/Cursor/Page.php index 14c8c54..16bc169 100644 --- a/src/Cursor/Page.php +++ b/src/Cursor/Page.php @@ -23,6 +23,7 @@ { /** * @param Collection $items + * @param array $extraMetadata */ private function __construct( private Sort $sort, @@ -30,7 +31,8 @@ private function __construct( private Filter $filter, private bool $hasNext, private Token $nextCursor, - private Pagination $pagination + private Pagination $pagination, + private array $extraMetadata ) { } @@ -66,7 +68,8 @@ public static function from( filter: $filter, hasNext: $seek->hasNext(), nextCursor: $seek->next(), - pagination: $pagination + pagination: $pagination, + extraMetadata: [] ); } @@ -88,7 +91,8 @@ public function map(Closure $transformation): Page filter: $this->filter, hasNext: $this->hasNext, nextCursor: $this->nextCursor, - pagination: $this->pagination + pagination: $this->pagination, + extraMetadata: $this->extraMetadata ); } @@ -127,12 +131,16 @@ public function hasNext(): bool /** * Returns the cursor page as the JSON:API meta contents. * - * @return array The meta contents, counts and sizes first, then the boolean - * flags, each by ascending key-name length. + *

Any metadata supplied through withMetadata comes first, in the order it was given. The + * pagination contents come last, so a supplied key never shadows them.

+ * + * @return array The meta contents, the supplied metadata first, then the + * pagination counts and sizes, then the boolean flags, each by ascending key-name length. */ public function metadata(): array { return [ + ...$this->extraMetadata, 'per_page' => $this->pagination->limit(), 'has_next' => $this->hasNext ]; @@ -166,4 +174,27 @@ public function toResponse(string $baseUri): ResponseInterface navigation: $this->navigation() ); } + + /** + * Returns a copy of the cursor page carrying the supplied metadata in its meta contents. + * + *

The supplied metadata is the place for a counter the consumer owns and the page cannot + * derive, an unread total for example. It renders inside meta, so the response keeps the + * single JSON:API envelope and the RFC 8288 Link header.

+ * + * @param array $metadata The metadata added to the meta contents. + * @return Page A copy carrying the supplied metadata, preserving the items and the cursor. + */ + public function withMetadata(array $metadata): Page + { + return new Page( + sort: $this->sort, + items: $this->items, + filter: $this->filter, + hasNext: $this->hasNext, + nextCursor: $this->nextCursor, + pagination: $this->pagination, + extraMetadata: [...$this->extraMetadata, ...$metadata] + ); + } } diff --git a/src/Offset/Page.php b/src/Offset/Page.php index 5af16c3..3b905a1 100644 --- a/src/Offset/Page.php +++ b/src/Offset/Page.php @@ -28,6 +28,7 @@ { /** * @param Collection $items + * @param array $extraMetadata */ private function __construct( private Sort $sort, @@ -36,7 +37,8 @@ private function __construct( private Filter $filter, private OffsetNavigation $paging, private PageCount $pageCount, - private Pagination $pagination + private Pagination $pagination, + private array $extraMetadata ) { } @@ -72,7 +74,8 @@ public static function from(Sort $sort, iterable $items, int $total, Filter $fil pagination: $pagination ), pageCount: $pageCount, - pagination: $pagination + pagination: $pagination, + extraMetadata: [] ); } @@ -149,12 +152,16 @@ public function hasNext(): bool /** * Returns the page as the JSON:API meta contents. * - * @return array The meta contents, counts and sizes first, then the boolean - * flags, each by ascending key-name length. + *

Any metadata supplied through withMetadata comes first, in the order it was given. The + * pagination contents come last, so a supplied key never shadows them.

+ * + * @return array The meta contents, the supplied metadata first, then the + * pagination counts and sizes, then the boolean flags, each by ascending key-name length. */ public function metadata(): array { return [ + ...$this->extraMetadata, 'total' => $this->total->value(), 'per_page' => $this->paging->limit(), 'total_pages' => $this->pageCount->value(), @@ -236,4 +243,28 @@ public function hasPrevious(): bool { return $this->paging->hasPrevious(); } + + /** + * Returns a copy of the page carrying the supplied metadata in its meta contents. + * + *

The supplied metadata is the place for a counter the consumer owns and the page cannot + * derive, an unread total for example. It renders inside meta, so the response keeps the + * single JSON:API envelope and the RFC 8288 Link header.

+ * + * @param array $metadata The metadata added to the meta contents. + * @return Page A copy carrying the supplied metadata, preserving the items and the navigation. + */ + public function withMetadata(array $metadata): Page + { + return new Page( + sort: $this->sort, + items: $this->items, + total: $this->total, + filter: $this->filter, + paging: $this->paging, + pageCount: $this->pageCount, + pagination: $this->pagination, + extraMetadata: [...$this->extraMetadata, ...$metadata] + ); + } } diff --git a/tests/Unit/Cursor/PageTest.php b/tests/Unit/Cursor/PageTest.php index 6eb7283..677b361 100644 --- a/tests/Unit/Cursor/PageTest.php +++ b/tests/Unit/Cursor/PageTest.php @@ -26,6 +26,24 @@ protected function setUp(): void $this->filter = Group::none(); } + public function testMapWhenPageCarriesMetadataThenTheCopyKeepsIt(): void + { + /** @Given a cursor page carrying a counter the page cannot derive */ + $page = Page::from( + sort: $this->sort, + items: [10, 20], + filter: $this->filter, + keysOf: static fn(int $element): array => [$element], + pagination: Pagination::from(cursor: Token::none(), perPage: 2) + )->withMetadata(metadata: ['unread_count' => 7]); + + /** @When the items are projected through a transformation */ + $mapped = $page->map(transformation: static fn(int $element): int => ($element * 2)); + + /** @Then the copy keeps the supplied metadata ahead of the pagination contents */ + self::assertSame(['unread_count' => 7, 'per_page' => 2, 'has_next' => false], $mapped->metadata()); + } + public function testNavigationWhenNoExtraElementThenHasNoNextPage(): void { /** @Given a keyset pagination with an absent incoming cursor and a page size of two */ @@ -53,6 +71,29 @@ public function testNavigationWhenNoExtraElementThenHasNoNextPage(): void self::assertSame(['per_page' => 2, 'has_next' => false], $page->metadata()); } + public function testWithMetadataWhenAppliedTwiceThenBothEntriesAreKept(): void + { + /** @Given a cursor page with no supplied metadata */ + $page = Page::from( + sort: $this->sort, + items: [10, 20], + filter: $this->filter, + keysOf: static fn(int $element): array => [$element], + pagination: Pagination::from(cursor: Token::none(), perPage: 2) + ); + + /** @When metadata is supplied twice */ + $counted = $page->withMetadata(metadata: ['unread_count' => 7])->withMetadata(metadata: ['muted_count' => 3]); + + /** @Then both entries reach the meta contents, in the order they were supplied */ + self::assertSame([ + 'unread_count' => 7, + 'muted_count' => 3, + 'per_page' => 2, + 'has_next' => false + ], $counted->metadata()); + } + public function testToResponseWhenFirstCursorPageThenSelfLinkIsCursorStyle(): void { /** @Given a cursor page on the first page with no incoming cursor */ @@ -81,6 +122,24 @@ public function testToResponseWhenFirstCursorPageThenSelfLinkIsCursorStyle(): vo ], json_decode($response->getBody()->getContents(), true)); } + public function testWithMetadataWhenAKeyCollidesThenThePaginationEntryWins(): void + { + /** @Given a cursor page with a page size of two */ + $page = Page::from( + sort: $this->sort, + items: [10, 20], + filter: $this->filter, + keysOf: static fn(int $element): array => [$element], + pagination: Pagination::from(cursor: Token::none(), perPage: 2) + ); + + /** @When metadata reusing a pagination key is supplied */ + $counted = $page->withMetadata(metadata: ['per_page' => 99]); + + /** @Then the pagination entry stands and the supplied value never shadows it */ + self::assertSame(['per_page' => 2, 'has_next' => false], $counted->metadata()); + } + public function testToResponseWhenCursorPageGivenThenRendersBodyAndLinkHeader(): void { /** @Given an opaque token produced from ordering key values */ @@ -145,6 +204,47 @@ public function testNavigationWhenExtraElementFetchedThenListsOnlyTheNextTarget( ); } + public function testWithMetadataWhenRenderedThenMetaCarriesItAndTheLinkHeaderHolds(): void + { + /** @Given a cursor page carrying a counter the page cannot derive */ + $page = Page::from( + sort: $this->sort, + items: [10, 20, 30], + filter: $this->filter, + keysOf: static fn(int $element): array => [$element], + pagination: Pagination::from(cursor: Token::none(), perPage: 2) + )->withMetadata(metadata: ['unread_count' => 7]); + + /** @When rendering the cursor page as a JSON:API response over the notifications base URI */ + $response = $page->toResponse(baseUri: '/v1/notifications'); + + /** @Then the supplied counter renders inside meta, ahead of the pagination contents */ + self::assertSame([ + 'data' => [10, 20], + 'meta' => [ + 'unread_count' => 7, + 'per_page' => 2, + 'has_next' => true + ], + 'links' => [ + 'self' => '/v1/notifications?page[size]=2', + 'next' => sprintf( + '/v1/notifications?page[cursor]=%s&page[size]=2', + Token::fromKeys(keys: [20])->toString() + ) + ] + ], json_decode($response->getBody()->getContents(), true)); + + /** @And the RFC 8288 Link header still folds the self and next relations */ + self::assertSame(implode(', ', [ + '; rel="self"', + sprintf( + '; rel="next"', + Token::fromKeys(keys: [20])->toString() + ) + ]), $response->getHeaderLine('Link')); + } + public function testMapWhenTransformationGivenThenProjectsItemsAndPreservesTheCursor(): void { /** @Given a cursor page built over items fetched for the page size plus one */ diff --git a/tests/Unit/Offset/PageTest.php b/tests/Unit/Offset/PageTest.php index 7dce5b5..87e5ff7 100644 --- a/tests/Unit/Offset/PageTest.php +++ b/tests/Unit/Offset/PageTest.php @@ -143,6 +143,32 @@ public function testTotalWhenPageGivenThenReturnsTheTotalElementCount(): void self::assertSame(480, $page->total()); } + public function testWithMetadataWhenAppliedTwiceThenBothEntriesAreKept(): void + { + /** @Given a criteria on the first page with a page size of twenty */ + $criteria = Criteria::fromQueryWithDefaultSchema( + request: Query::from(parameters: ['page' => ['number' => '1', 'size' => '20']]) + ); + + /** @And a page with no supplied metadata */ + $page = $criteria->page(items: ['a', 'b'], total: 2); + + /** @When metadata is supplied twice */ + $counted = $page->withMetadata(metadata: ['unread_count' => 7])->withMetadata(metadata: ['muted_count' => 3]); + + /** @Then both entries reach the meta contents, in the order they were supplied */ + self::assertSame([ + 'unread_count' => 7, + 'muted_count' => 3, + 'total' => 2, + 'per_page' => 20, + 'total_pages' => 1, + 'current_page' => 1, + 'has_next' => false, + 'has_previous' => false + ], $counted->metadata()); + } + public function testTotalPagesWhenTotalIsNotAMultipleOfPerPageThenRoundsUp(): void { /** @Given a criteria on the first page with a page size of twenty */ @@ -157,6 +183,30 @@ public function testTotalPagesWhenTotalIsNotAMultipleOfPerPageThenRoundsUp(): vo self::assertSame(2, $page->totalPages()); } + public function testWithMetadataWhenAKeyCollidesThenThePaginationEntryWins(): void + { + /** @Given a criteria on the first page with a page size of twenty */ + $criteria = Criteria::fromQueryWithDefaultSchema( + request: Query::from(parameters: ['page' => ['number' => '1', 'size' => '20']]) + ); + + /** @And a page built from a total of two */ + $page = $criteria->page(items: ['a', 'b'], total: 2); + + /** @When metadata reusing a pagination key is supplied */ + $counted = $page->withMetadata(metadata: ['total' => 99]); + + /** @Then the pagination entry stands and the supplied value never shadows it */ + self::assertSame([ + 'total' => 2, + 'per_page' => 20, + 'total_pages' => 1, + 'current_page' => 1, + 'has_next' => false, + 'has_previous' => false + ], $counted->metadata()); + } + public function testMetadataWhenMiddlePageGivenThenCarriesEveryFlagAndCount(): void { /** @Given a criteria on a middle page with a page size of twenty */ @@ -272,6 +322,46 @@ public function testNavigationWhenMiddlePageGivenThenExposesEverySurroundingPage self::assertSame(24, $page->last()?->page()); } + public function testWithMetadataWhenRenderedThenMetaCarriesItAndTheLinkHeaderHolds(): void + { + /** @Given a criteria on the first page with a page size of twenty */ + $criteria = Criteria::fromQueryWithDefaultSchema( + request: Query::from(parameters: ['page' => ['number' => '1', 'size' => '20']]) + ); + + /** @And a page carrying a counter the page cannot derive */ + $page = $criteria->page(items: ['a', 'b'], total: 2)->withMetadata(metadata: ['unread_count' => 7]); + + /** @When rendering the page as a JSON:API response over the notifications base URI */ + $response = $page->toResponse(baseUri: '/v1/notifications'); + + /** @Then the supplied counter renders inside meta, ahead of the pagination contents */ + self::assertSame([ + 'data' => ['a', 'b'], + 'meta' => [ + 'unread_count' => 7, + 'total' => 2, + 'per_page' => 20, + 'total_pages' => 1, + 'current_page' => 1, + 'has_next' => false, + 'has_previous' => false + ], + 'links' => [ + 'self' => '/v1/notifications?page[number]=1&page[size]=20', + 'first' => '/v1/notifications?page[number]=1&page[size]=20', + 'last' => '/v1/notifications?page[number]=1&page[size]=20' + ] + ], json_decode($response->getBody()->getContents(), true)); + + /** @And the RFC 8288 Link header still folds every present relation */ + self::assertSame(implode(', ', [ + '; rel="self"', + '; rel="first"', + '; rel="last"' + ]), $response->getHeaderLine('Link')); + } + public function testNavigationWhenMiddlePageGivenThenTheFirstTargetPointsAtTheFirstPage(): void { /** @Given a criteria on a middle page of a multi-page result */ From aa026954e9990a1e60cd9adfe9ba5c6b882af30c Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 3 Aug 2026 16:04:51 -0300 Subject: [PATCH 2/2] docs: Document supplying extra meta contents on a page. --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 0c777dd..678b480 100644 --- a/README.md +++ b/README.md @@ -510,6 +510,40 @@ total). | `next` | The next page. | | `last` | The last page. | +#### Adding your own meta contents + +A page derives its own `meta` from the pagination. When the endpoint owns a counter the page cannot derive, an unread +total for example, `withMetadata` returns a copy carrying it. The value renders inside `meta`, so the response keeps the +single JSON:API envelope and the RFC 8288 `Link` header. Both pagination approaches expose it. + +```php + $items */ +$response = $keyset->page(items: $items) + ->withMetadata(metadata: ['unread_count' => 7]) + ->toResponse(baseUri: '/v1/notifications'); +``` + +```json +{ + "meta": { + "unread_count": 7, + "per_page": 20, + "has_next": true + } +} +``` + +The supplied entries come first, in the order they were given, and the pagination entries come last. A supplied key that +repeats a pagination key never shadows it, so `withMetadata(metadata: ['per_page' => 99])` leaves `per_page` on the real +page size. Calling it more than once accumulates. + ## FAQ ### 01. Why does the library never touch a data store?