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? 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 */