diff --git a/README.md b/README.md index 70deb2a..5ce4b03 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ + [Cursor pagination](#cursor-pagination) + [Building the store query](#building-the-store-query) + [Rendering navigation links](#rendering-navigation-links) + + [Preserving other query parameters](#preserving-other-query-parameters) * [FAQ](#faq) * [License](#license) * [Contributing](#contributing) @@ -602,6 +603,79 @@ The pagination entries come first, and the supplied ones follow in the order the repeats a pagination key is discarded, so `withMetadata(metadata: ['per_page' => 99])` leaves `per_page` on the real page size. Calling it more than once accumulates. +### Preserving other query parameters + +An endpoint often bounds its collection with query parameters outside `filter`, `sort`, and `page`, a time window for +example. Declare their names with `preserving` on the schema. The criteria read each declared parameter from the +request, and every link, in the body and in the `Link` header, carries it as the request sent it, between the sort and +the pagination. Following a link then keeps the window. A declared parameter the request omits is left out, an +undeclared one is never copied, and without the declaration the links stay as they were. + +```php +preserving(parameters: ['from', 'to']); + +# GET /v1/availability-overrides?from=2026-10-01T00:00:00Z&to=2026-10-31T23:59:59Z&page[size]=20 +/** @var ServerRequestInterface $request */ +$criteria = Criteria::fromQuery(schema: $schema, request: $request); + +/** @var iterable $items */ +$response = $criteria->page(items: $items, total: 45)->toResponse(baseUri: '/v1/availability-overrides'); +``` + +```json +{ + "links": { + "self": "/v1/availability-overrides?from=2026-10-01T00%3A00%3A00Z&to=2026-10-31T23%3A59%3A59Z&page[number]=1&page[size]=20", + "first": "/v1/availability-overrides?from=2026-10-01T00%3A00%3A00Z&to=2026-10-31T23%3A59%3A59Z&page[number]=1&page[size]=20", + "next": "/v1/availability-overrides?from=2026-10-01T00%3A00%3A00Z&to=2026-10-31T23%3A59%3A59Z&page[number]=2&page[size]=20", + "last": "/v1/availability-overrides?from=2026-10-01T00%3A00%3A00Z&to=2026-10-31T23%3A59%3A59Z&page[number]=3&page[size]=20" + } +} +``` + +A cursor page preserves them the same way, so the `next` link of a waitlist scoped by a released slot keeps the slot: + +```php +sortable(fields: ['created_at', 'id']) + ->preserving(parameters: ['slot_start_time', 'slot_end_time']) + ->defaultSort(sort: Sort::fromExpression(expression: 'created_at,id')); + +# GET /v1/eligible-waitlist-entries?slot_start_time=2026-10-14T10:00:00Z&slot_end_time=2026-10-14T11:00:00Z&page[size]=1 +/** @var ServerRequestInterface $request */ +$keyset = Criteria::fromQuery(schema: $schema, request: $request)->keyset(); + +# links.next: /v1/eligible-waitlist-entries?slot_start_time=2026-10-14T10%3A00%3A00Z +# &slot_end_time=2026-10-14T11%3A00%3A00Z&page[cursor]=...&page[size]=1 +/** @var iterable $items */ +$response = $keyset->page(items: $items)->toResponse(baseUri: '/v1/eligible-waitlist-entries'); +``` + +| Rule | Behavior | +|---------------|---------------------------------------------------------------------------------------------------| +| Encoding | Every name, key, and value is percent-encoded, so `:` renders as `%3A` and `+` as `%2B`. | +| Brackets | Declaring `window` carries `window[from]` and `window[to]` under literal bracketed keys. | +| Names | Empty, bracketed, `filter`, `sort`, and `page` raise `PreservedParameterIsInvalid` when declared. | +| Values | A value other than a string or a nested array of strings raises `PreservedParameterIsInvalid`. | +| Links by hand | `Links::from` and the page factories take an optional `PreservedParameters`. | + ## FAQ ### 01. Why does the library never touch a data store? diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b80039e..34e9393 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -47,6 +47,11 @@ parameters: path: src/Internal/AllowedField.php - identifier: missingType.iterableValue path: src/Internal/AllowedFilters.php + # The preserved parameters arrive as the parsed query string and leave as a list of encoded pairs. + - identifier: missingType.iterableValue + path: src/Internal/PreservedPairs.php + - identifier: argument.type + path: src/Internal/PreservedPairs.php # reduce() folds the links into the associative body; phpstan widens the carry to array. - identifier: return.type path: src/Links.php diff --git a/src/Cursor/Criteria.php b/src/Cursor/Criteria.php index 26f88fd..829e888 100644 --- a/src/Cursor/Criteria.php +++ b/src/Cursor/Criteria.php @@ -18,6 +18,7 @@ use TinyBlocks\HttpQuery\Exceptions\SortIsRequired; use TinyBlocks\HttpQuery\Filter; use TinyBlocks\HttpQuery\Internal\Query; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Schema; use TinyBlocks\HttpQuery\Sort; @@ -34,6 +35,7 @@ private function __construct( private Sort $sort, private Filter $filter, + private PreservedParameters $preserved, private Pagination $pagination, private array $comparisons, private Sort $submittedSort @@ -70,6 +72,7 @@ public static function fromQuery(Schema $schema, ServerRequestInterface $request return new Criteria( sort: $query->sort(), filter: $query->filter(), + preserved: $query->preserved(), pagination: Pagination::from(cursor: $cursor, perPage: $query->pageSize()), comparisons: $query->comparisons(), submittedSort: $query->submittedSort() @@ -136,7 +139,8 @@ public function keyset(): Keyset sort: $this->sort, filter: $this->filter, pagination: $this->pagination, - submittedSort: $this->submittedSort + submittedSort: $this->submittedSort, + preserved: $this->preserved ); } diff --git a/src/Cursor/Keyset.php b/src/Cursor/Keyset.php index dc952bf..123a71b 100644 --- a/src/Cursor/Keyset.php +++ b/src/Cursor/Keyset.php @@ -10,6 +10,7 @@ use TinyBlocks\HttpQuery\Internal\Cursor\SortKeys; use TinyBlocks\HttpQuery\Limit; use TinyBlocks\HttpQuery\Order; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Sort; /** @@ -25,6 +26,7 @@ private function __construct( private Sort $sort, private Filter $filter, + private ?PreservedParameters $preserved, private Pagination $pagination, private Sort $submittedSort ) { @@ -37,11 +39,24 @@ private function __construct( * @param Filter $filter The filter preserved in every rendered URI. * @param Pagination $pagination The cursor pagination carrying the incoming cursor and the limit. * @param Sort $submittedSort The submitted sort preserved in every rendered URI. + * @param PreservedParameters|null $preserved The query parameters preserved in every rendered URI, or null + * for none. * @return Keyset The cursor view. */ - public static function from(Sort $sort, Filter $filter, Pagination $pagination, Sort $submittedSort): Keyset - { - return new Keyset(sort: $sort, filter: $filter, pagination: $pagination, submittedSort: $submittedSort); + public static function from( + Sort $sort, + Filter $filter, + Pagination $pagination, + Sort $submittedSort, + ?PreservedParameters $preserved = null + ): Keyset { + return new Keyset( + sort: $sort, + filter: $filter, + preserved: $preserved, + pagination: $pagination, + submittedSort: $submittedSort + ); } /** @@ -62,7 +77,8 @@ public function page(iterable $items, ?Closure $keysOf = null): Page items: $items, filter: $this->filter, keysOf: ($keysOf ?? SortKeys::from(sort: $this->sort)), - pagination: $this->pagination + pagination: $this->pagination, + preserved: $this->preserved ); } diff --git a/src/Cursor/Page.php b/src/Cursor/Page.php index 7d6d418..db8a232 100644 --- a/src/Cursor/Page.php +++ b/src/Cursor/Page.php @@ -12,6 +12,7 @@ use TinyBlocks\HttpQuery\Internal\Cursor\Seek; use TinyBlocks\HttpQuery\Internal\Rendering; use TinyBlocks\HttpQuery\Navigation; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Sort; /** @@ -30,6 +31,7 @@ private function __construct( private Collection $items, private Filter $filter, private bool $hasNext, + private ?PreservedParameters $preserved, private Token $nextCursor, private Pagination $pagination, private array $extraMetadata @@ -49,6 +51,8 @@ private function __construct( * @param Filter $filter The filter preserved in every rendered URI. * @param Closure(TElement): list $keysOf Extracts the ordering key values from an element. * @param Pagination $pagination The pagination describing the current page. + * @param PreservedParameters|null $preserved The query parameters preserved in every rendered URI, or null + * for none. * @return Page The cursor page carrying the trimmed items and the next cursor. */ public static function from( @@ -56,7 +60,8 @@ public static function from( iterable $items, Filter $filter, Closure $keysOf, - Pagination $pagination + Pagination $pagination, + ?PreservedParameters $preserved = null ): Page { /** @var Collection $collection */ $collection = Collection::createFrom(elements: $items); @@ -67,6 +72,7 @@ public static function from( items: $seek->items(), filter: $filter, hasNext: $seek->hasNext(), + preserved: $preserved, nextCursor: $seek->next(), pagination: $pagination, extraMetadata: [] @@ -90,6 +96,7 @@ public function map(Closure $transformation): Page items: $items, filter: $this->filter, hasNext: $this->hasNext, + preserved: $this->preserved, nextCursor: $this->nextCursor, pagination: $this->pagination, extraMetadata: $this->extraMetadata @@ -172,6 +179,7 @@ public function toResponse(string $baseUri): ResponseInterface filter: $this->filter, baseUri: $baseUri, metadata: $this->metadata(), + preserved: $this->preserved, navigation: $this->navigation() ); } @@ -193,6 +201,7 @@ public function withMetadata(array $metadata): Page items: $this->items, filter: $this->filter, hasNext: $this->hasNext, + preserved: $this->preserved, nextCursor: $this->nextCursor, pagination: $this->pagination, extraMetadata: [...$this->extraMetadata, ...$metadata] diff --git a/src/Exceptions/PreservedParameterIsInvalid.php b/src/Exceptions/PreservedParameterIsInvalid.php new file mode 100644 index 0000000..58d2c7b --- /dev/null +++ b/src/Exceptions/PreservedParameterIsInvalid.php @@ -0,0 +1,51 @@ + is empty, bracketed, or reserved.'; + private const string VALUE_NOT_ALLOWED = 'Preserved parameter <%s> must be a string or a nested array of strings.'; + + private function __construct(string $reason) + { + parent::__construct(message: $reason); + } + + /** + * Creates a PreservedParameterIsInvalid from the name that is empty, bracketed, or reserved. + * + * @param string $name The parameter name that cannot be preserved. + * @return PreservedParameterIsInvalid The composed exception describing the rejected name. + */ + public static function fromName(string $name): PreservedParameterIsInvalid + { + $template = PreservedParameterIsInvalid::NAME_NOT_ALLOWED; + + return new PreservedParameterIsInvalid(reason: sprintf($template, $name)); + } + + /** + * Creates a PreservedParameterIsInvalid from the name whose value is not a string or nested strings. + * + * @param string $name The parameter name whose value cannot be preserved, the value kept out of the message. + * @return PreservedParameterIsInvalid The composed exception describing the rejected value. + */ + public static function fromValue(string $name): PreservedParameterIsInvalid + { + $template = PreservedParameterIsInvalid::VALUE_NOT_ALLOWED; + + return new PreservedParameterIsInvalid(reason: sprintf($template, $name)); + } +} diff --git a/src/Internal/ParameterName.php b/src/Internal/ParameterName.php new file mode 100644 index 0000000..87cc905 --- /dev/null +++ b/src/Internal/ParameterName.php @@ -0,0 +1,25 @@ +value), + name: $parameterName, + value: $value + ); + }, + array_keys($parameters), + $parameters + ); + + return new PreservedPairs(pairs: array_merge(...$pairs)); + } + + private static function pairsOf(string $key, ParameterName $name, mixed $value): array + { + if (is_string($value)) { + $template = '%s=%s'; + + return [sprintf($template, $key, rawurlencode($value))]; + } + + if (!is_array($value)) { + throw PreservedParameterIsInvalid::fromValue(name: $name->value); + } + + $template = '%s[%s]'; + + return array_merge(...array_map( + static fn(int|string $index, mixed $nested): array => PreservedPairs::pairsOf( + key: sprintf($template, $key, rawurlencode((string)$index)), + name: $name, + value: $nested + ), + array_keys($value), + $value + )); + } + + public function toQueryString(): string + { + return implode('&', $this->pairs); + } +} diff --git a/src/Internal/Query.php b/src/Internal/Query.php index 67d39b5..f21db1b 100644 --- a/src/Internal/Query.php +++ b/src/Internal/Query.php @@ -10,6 +10,7 @@ use TinyBlocks\HttpQuery\Filter; use TinyBlocks\HttpQuery\Group; use TinyBlocks\HttpQuery\Internal\Rsql\FilterParser; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Schema; use TinyBlocks\HttpQuery\Sort; @@ -19,6 +20,7 @@ private function __construct( private Sort $sort, private Filter $filter, private int $pageSize, + private PreservedParameters $preserved, private int $pageNumber, private array $comparisons, private string $cursorToken, @@ -43,6 +45,7 @@ public static function from(Schema $schema, ServerRequestInterface $request): Qu sort: $schema->sortFor(sort: $submittedSort), filter: $filter, pageSize: $schema->pageSizeFor(requested: $size->toString() === '' ? null : $size->toInteger()), + preserved: $schema->preservedFor(parameters: $parameters->toArray()), pageNumber: $number->toString() === '' ? 1 : $number->toInteger(), comparisons: $schema->comparisonsFor(filter: $filter, expression: $expression), cursorToken: $cursor->toString(), @@ -65,6 +68,11 @@ public function pageSize(): int return $this->pageSize; } + public function preserved(): PreservedParameters + { + return $this->preserved; + } + public function pageNumber(): int { return $this->pageNumber; diff --git a/src/Internal/Rendering.php b/src/Internal/Rendering.php index 273197d..17ccf53 100644 --- a/src/Internal/Rendering.php +++ b/src/Internal/Rendering.php @@ -11,6 +11,7 @@ use TinyBlocks\HttpQuery\Links; use TinyBlocks\HttpQuery\Navigation; use TinyBlocks\HttpQuery\Pagination; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Sort; final class Rendering @@ -26,9 +27,17 @@ public static function of( Filter $filter, string $baseUri, array $metadata, + ?PreservedParameters $preserved, Navigation $navigation ): ResponseInterface { - $links = Links::from(self: $self, sort: $sort, filter: $filter, baseUri: $baseUri, navigation: $navigation); + $links = Links::from( + self: $self, + sort: $sort, + filter: $filter, + baseUri: $baseUri, + navigation: $navigation, + preserved: $preserved + ); return Response::ok([ 'data' => $items->toArray(), diff --git a/src/Internal/Uri.php b/src/Internal/Uri.php index 230de55..f46c312 100644 --- a/src/Internal/Uri.php +++ b/src/Internal/Uri.php @@ -7,6 +7,7 @@ use TinyBlocks\HttpQuery\Filter; use TinyBlocks\HttpQuery\Internal\Rsql\Renderer; use TinyBlocks\HttpQuery\Pagination; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Sort; final class Uri @@ -15,8 +16,13 @@ private function __construct() { } - public static function from(Sort $sort, Filter $filter, string $baseUri, Pagination $pagination): string - { + public static function from( + Sort $sort, + Filter $filter, + string $baseUri, + PreservedParameters $preserved, + Pagination $pagination + ): string { $parameters = []; if (!$filter->isEmpty()) { @@ -29,6 +35,12 @@ public static function from(Sort $sort, Filter $filter, string $baseUri, Paginat $parameters[] = sprintf($template, Uri::encode(value: $sort->toExpression())); } + $preservedQuery = $preserved->toQueryString(); + + if ($preservedQuery !== '') { + $parameters[] = $preservedQuery; + } + $parameters[] = $pagination->toQueryString(); $template = '%s?%s'; diff --git a/src/Links.php b/src/Links.php index 880d0e6..5b25c96 100644 --- a/src/Links.php +++ b/src/Links.php @@ -13,10 +13,11 @@ /** * Navigation of a result, rendered as a JSON:API body links object or an RFC 8288 Link header. * - *

It renders uniformly over a {@see Navigation}, building each URI from the given filter and sort - * plus the pagination of the self link and of every target, so the filter and the sort are - * preserved in every URI. The self link is built from the page's own current pagination, so a cursor - * page renders a cursor self and an offset page renders an offset self.

+ *

It renders uniformly over a {@see Navigation}, building each URI from the given filter, sort, and + * preserved parameters plus the pagination of the self link and of every target, so the filter, the + * sort, and the preserved parameters are kept in every URI. The self link is built from the page's own + * current pagination, so a cursor page renders a cursor self and an offset page renders an offset + * self.

*/ final readonly class Links { @@ -35,6 +36,7 @@ private function __construct(private WebLink $self, private Collection $links) * @param Filter $filter The filter preserved in every URI. * @param string $baseUri The base URI the navigation URIs are built on. * @param Navigation $navigation The navigation the result exposes. + * @param PreservedParameters|null $preserved The query parameters preserved in every URI, or null for none. * @return Links The navigation for the result. */ public static function from( @@ -42,12 +44,16 @@ public static function from( Sort $sort, Filter $filter, string $baseUri, - Navigation $navigation + Navigation $navigation, + ?PreservedParameters $preserved = null ): Links { + $preservedParameters = ($preserved ?? PreservedParameters::from(parameters: [])); + $uriFor = static fn(Pagination $pagination): string => Uri::from( sort: $sort, filter: $filter, baseUri: $baseUri, + preserved: $preservedParameters, pagination: $pagination ); diff --git a/src/Offset/Criteria.php b/src/Offset/Criteria.php index 5bcc601..9b3dc2b 100644 --- a/src/Offset/Criteria.php +++ b/src/Offset/Criteria.php @@ -19,6 +19,7 @@ use TinyBlocks\HttpQuery\Filter; use TinyBlocks\HttpQuery\Internal\Query; use TinyBlocks\HttpQuery\Limit; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Schema; use TinyBlocks\HttpQuery\Sort; @@ -35,6 +36,7 @@ private function __construct( private Sort $sort, private Filter $filter, + private PreservedParameters $preserved, private Pagination $pagination, private array $comparisons, private Sort $submittedSort @@ -67,6 +69,7 @@ public static function fromQuery(Schema $schema, ServerRequestInterface $request return new Criteria( sort: $query->sort(), filter: $query->filter(), + preserved: $query->preserved(), pagination: Pagination::fromPage(page: $query->pageNumber(), perPage: $query->pageSize()), comparisons: $query->comparisons(), submittedSort: $query->submittedSort() @@ -111,7 +114,8 @@ public function page(iterable $items, int $total): Page items: $items, total: $total, filter: $this->filter, - pagination: $this->pagination + pagination: $this->pagination, + preserved: $this->preserved ); } @@ -148,7 +152,8 @@ public function slice(iterable $items): Slice sort: $this->submittedSort, items: $items, filter: $this->filter, - pagination: $this->pagination + pagination: $this->pagination, + preserved: $this->preserved ); } diff --git a/src/Offset/Page.php b/src/Offset/Page.php index 1955f5f..8f9105f 100644 --- a/src/Offset/Page.php +++ b/src/Offset/Page.php @@ -17,6 +17,7 @@ use TinyBlocks\HttpQuery\Internal\Offset\Total; use TinyBlocks\HttpQuery\Internal\Rendering; use TinyBlocks\HttpQuery\Navigation; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Sort; /** @@ -37,6 +38,7 @@ private function __construct( private Filter $filter, private OffsetNavigation $paging, private PageCount $pageCount, + private ?PreservedParameters $preserved, private Pagination $pagination, private array $extraMetadata ) { @@ -51,11 +53,19 @@ private function __construct( * @param iterable $items The items on the current page. * @param Filter $filter The filter preserved in every rendered URI. * @param Pagination $pagination The pagination describing the current page. + * @param PreservedParameters|null $preserved The query parameters preserved in every rendered URI, or null + * for none. * @return Page The page carrying the items, the total, and the navigation. * @throws TotalIsNegative If the total is less than 0. */ - public static function from(Sort $sort, iterable $items, int $total, Filter $filter, Pagination $pagination): Page - { + public static function from( + Sort $sort, + iterable $items, + int $total, + Filter $filter, + Pagination $pagination, + ?PreservedParameters $preserved = null + ): Page { $count = Total::from(value: $total); $limit = Limit::from(value: $pagination->limit()); $pageCount = $count->pageCount(limit: $limit); @@ -74,6 +84,7 @@ public static function from(Sort $sort, iterable $items, int $total, Filter $fil pagination: $pagination ), pageCount: $pageCount, + preserved: $preserved, pagination: $pagination, extraMetadata: [] ); @@ -211,6 +222,7 @@ public function toResponse(string $baseUri): ResponseInterface filter: $this->filter, baseUri: $baseUri, metadata: $this->metadata(), + preserved: $this->preserved, navigation: $this->navigation() ); } @@ -264,6 +276,7 @@ public function withMetadata(array $metadata): Page filter: $this->filter, paging: $this->paging, pageCount: $this->pageCount, + preserved: $this->preserved, pagination: $this->pagination, extraMetadata: [...$this->extraMetadata, ...$metadata] ); diff --git a/src/Offset/Slice.php b/src/Offset/Slice.php index 9817395..034e720 100644 --- a/src/Offset/Slice.php +++ b/src/Offset/Slice.php @@ -12,6 +12,7 @@ use TinyBlocks\HttpQuery\Internal\Rendering; use TinyBlocks\HttpQuery\Internal\Window; use TinyBlocks\HttpQuery\Navigation; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Sort; /** @@ -29,6 +30,7 @@ private function __construct( private Collection $items, private Filter $filter, private OffsetNavigation $paging, + private ?PreservedParameters $preserved, private Pagination $pagination ) { } @@ -44,10 +46,17 @@ private function __construct( * @param iterable $items The items fetched for the page size plus one. * @param Filter $filter The filter preserved in every rendered URI. * @param Pagination $pagination The pagination describing the current page. + * @param PreservedParameters|null $preserved The query parameters preserved in every rendered URI, or null + * for none. * @return Slice The slice carrying the trimmed items and the next-page hint. */ - public static function from(Sort $sort, iterable $items, Filter $filter, Pagination $pagination): Slice - { + public static function from( + Sort $sort, + iterable $items, + Filter $filter, + Pagination $pagination, + ?PreservedParameters $preserved = null + ): Slice { /** @var Collection $collection */ $collection = Collection::createFrom(elements: $items); $window = Window::from(items: $collection, limit: $pagination->limit()); @@ -60,6 +69,7 @@ public static function from(Sort $sort, iterable $items, Filter $filter, Paginat items: $trimmed, filter: $filter, paging: OffsetNavigation::from(hasNext: $window->hasNext(), pagination: $pagination), + preserved: $preserved, pagination: $pagination ); } @@ -168,6 +178,7 @@ public function toResponse(string $baseUri): ResponseInterface filter: $this->filter, baseUri: $baseUri, metadata: $this->metadata(), + preserved: $this->preserved, navigation: $this->navigation() ); } diff --git a/src/PreservedParameters.php b/src/PreservedParameters.php new file mode 100644 index 0000000..885ef7d --- /dev/null +++ b/src/PreservedParameters.php @@ -0,0 +1,46 @@ +Each parameter is a string or a nested array of strings, the shapes a query string parses + * into. A nested array renders under bracketed keys, and every name, key, and value is + * percent-encoded, so a reserved character in a value never splits or ends the query string.

+ */ +final readonly class PreservedParameters +{ + private function __construct(private string $query) + { + } + + /** + * Creates a PreservedParameters from the parameters keyed by name, kept in the given order. + * + * @param array $parameters The parameters keyed by name, each a string or a nested array + * of strings. + * @return PreservedParameters The parameters every navigation URI preserves. + * @throws PreservedParameterIsInvalid If a name is empty, bracketed, or reserved, or a value is not a string + * or a nested array of strings. + */ + public static function from(array $parameters): PreservedParameters + { + return new PreservedParameters(query: PreservedPairs::from(parameters: $parameters)->toQueryString()); + } + + /** + * Returns the PreservedParameters as a query string fragment. + * + * @return string The fragment carrying every parameter, empty when there is none. + */ + public function toQueryString(): string + { + return $this->query; + } +} diff --git a/src/Schema.php b/src/Schema.php index a109730..73a67cd 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -11,17 +11,20 @@ use TinyBlocks\HttpQuery\Exceptions\FilterShapeNotSupported; use TinyBlocks\HttpQuery\Exceptions\FilterValueNotAllowed; use TinyBlocks\HttpQuery\Exceptions\PageSizeOutOfRange; +use TinyBlocks\HttpQuery\Exceptions\PreservedParameterIsInvalid; use TinyBlocks\HttpQuery\Exceptions\SortFieldNotAllowed; use TinyBlocks\HttpQuery\Internal\AllowedFilters; use TinyBlocks\HttpQuery\Internal\Conjunction; use TinyBlocks\HttpQuery\Internal\Cursor\CursorKeys; +use TinyBlocks\HttpQuery\Internal\ParameterName; /** * Declarative contract of the query an endpoint accepts, used to validate an incoming request. * *

It declares the filterable fields with their permitted operators, values, and kinds, the * client-sortable fields, the kind each cursor key value must match, the sort applied when the - * client sends none, and the page-size bounds. + * client sends none, the page-size bounds, and the other query parameters every navigation URI + * preserves. * The query parameter names follow JSON:API and are fixed: filter, sort, * and the page family. The default page size is 20 and the maximum is 100.

*/ @@ -30,6 +33,7 @@ private function __construct( private AllowedFilters $allowed, private Sort $byDefault, + private array $preserved, private CursorKeys $cursorKeys, private int $maxPerPage, private int $defaultPerPage, @@ -75,6 +79,7 @@ public static function default(): Schema return new Schema( allowed: AllowedFilters::createFromEmpty(), byDefault: Sort::fromExpression(expression: ''), + preserved: [], cursorKeys: CursorKeys::createFromEmpty(), maxPerPage: 100, defaultPerPage: 20, @@ -116,6 +121,7 @@ public function sortable(array $fields): Schema return new Schema( allowed: $this->allowed, byDefault: $this->byDefault, + preserved: $this->preserved, cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, @@ -158,6 +164,7 @@ public function cursorKey(string $field, ValueKind $valueKind): Schema return new Schema( allowed: $this->allowed, byDefault: $this->byDefault, + preserved: $this->preserved, cursorKeys: $this->cursorKeys->with(kind: $valueKind, field: $field), maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, @@ -189,6 +196,7 @@ public function filterable( operators: $operators ), byDefault: $this->byDefault, + preserved: $this->preserved, cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, @@ -209,6 +217,7 @@ public function maxPerPage(int $maxPerPage): Schema return new Schema( allowed: $this->allowed, byDefault: $this->byDefault, + preserved: $this->preserved, cursorKeys: $this->cursorKeys, maxPerPage: $maxPerPage, defaultPerPage: $this->defaultPerPage, @@ -217,6 +226,35 @@ public function maxPerPage(int $maxPerPage): Schema ); } + /** + * Returns a copy of the Schema declaring the query parameters every navigation URI preserves. + * + *

The parameters sit outside the filter, sort, and page families, a time window bounding the + * collection for example. Each one the request carries is copied into the self link and into every + * navigation link, so following a link keeps the window. A declared parameter the request omits is + * left out. The declaration replaces any previous one.

+ * + * @param list $parameters The names of the query parameters every navigation URI preserves. + * @return Schema A copy carrying the preserved parameter names. + * @throws PreservedParameterIsInvalid If a name is empty, bracketed, or one of filter, sort, or page. + */ + public function preserving(array $parameters): Schema + { + return new Schema( + allowed: $this->allowed, + byDefault: $this->byDefault, + preserved: array_flip(array_map( + static fn(string $name): string => ParameterName::from(value: $name)->value, + $parameters + )), + cursorKeys: $this->cursorKeys, + maxPerPage: $this->maxPerPage, + defaultPerPage: $this->defaultPerPage, + sortableFields: $this->sortableFields, + allowsDisjunction: $this->allowsDisjunction + ); + } + /** * Returns a copy of the Schema with the sort applied when the client sends none. * @@ -228,6 +266,7 @@ public function defaultSort(Sort $sort): Schema return new Schema( allowed: $this->allowed, byDefault: $sort, + preserved: $this->preserved, cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, @@ -254,6 +293,19 @@ public function pageSizeFor(?int $requested): int return $size; } + /** + * Returns the declared parameters the request carries, preserved in every navigation URI. + * + * @param array $parameters The query parameters parsed from the request. + * @return PreservedParameters The declared parameters present in the request, in the request order. + * @throws PreservedParameterIsInvalid If a declared parameter carries a value that is not a string or a + * nested array of strings. + */ + public function preservedFor(array $parameters): PreservedParameters + { + return PreservedParameters::from(parameters: array_intersect_key($parameters, $this->preserved)); + } + /** * Returns the validated comparisons read from the filter. * @@ -295,6 +347,7 @@ public function defaultPerPage(int $defaultPerPage): Schema return new Schema( allowed: $this->allowed, byDefault: $this->byDefault, + preserved: $this->preserved, cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $defaultPerPage, @@ -313,6 +366,7 @@ public function allowDisjunction(): Schema return new Schema( allowed: $this->allowed, byDefault: $this->byDefault, + preserved: $this->preserved, cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, diff --git a/tests/Unit/EndToEndTest.php b/tests/Unit/EndToEndTest.php index 13fb227..f519b35 100644 --- a/tests/Unit/EndToEndTest.php +++ b/tests/Unit/EndToEndTest.php @@ -134,4 +134,272 @@ public function testPipelineWhenOffsetRequestGivenThenLinkHeaderFoldsEveryRelati sprintf($template, $base, 24, 'last') ]), $header); } + + public function testPipelineWhenRequestCarriesUndeclaredParametersThenLinksLeaveThemOut(): void + { + /** @Given a request carrying a window the default schema never declared */ + $query = Query::from(parameters: ['from' => '2026-10-01', 'to' => '2026-10-31', 'page' => ['size' => '1']]); + + /** @When rendering the first page of a two-element result as a JSON:API response */ + $response = OffsetCriteria::fromQueryWithDefaultSchema(request: $query) + ->page(items: ['a'], total: 2) + ->toResponse(baseUri: '/v1/overrides'); + + /** @Then every link renders only the pagination, exactly as before */ + self::assertSame([ + 'data' => ['a'], + 'meta' => [ + 'total' => 2, + 'per_page' => 1, + 'total_pages' => 2, + 'current_page' => 1, + 'has_next' => true, + 'has_previous' => false + ], + 'links' => [ + 'self' => '/v1/overrides?page[number]=1&page[size]=1', + 'first' => '/v1/overrides?page[number]=1&page[size]=1', + 'next' => '/v1/overrides?page[number]=2&page[size]=1', + 'last' => '/v1/overrides?page[number]=2&page[size]=1' + ] + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testPipelineWhenDeclaredParametersAreAbsentFromTheRequestThenLinksLeaveThemOut(): void + { + /** @Given the query contract of an endpoint windowed by from and to */ + $schema = Schema::create()->preserving(parameters: ['from', 'to']); + + /** @And a request carrying only the window start */ + $query = Query::from(parameters: ['from' => '2026-10-01', 'page' => ['size' => '1']]); + + /** @When rendering the first page of a two-element result as a JSON:API response */ + $response = OffsetCriteria::fromQuery(schema: $schema, request: $query) + ->page(items: ['a'], total: 2) + ->toResponse(baseUri: '/v1/overrides'); + + /** @Then every link carries the window start and no empty window end */ + self::assertSame([ + 'data' => ['a'], + 'meta' => [ + 'total' => 2, + 'per_page' => 1, + 'total_pages' => 2, + 'current_page' => 1, + 'has_next' => true, + 'has_previous' => false + ], + 'links' => [ + 'self' => '/v1/overrides?from=2026-10-01&page[number]=1&page[size]=1', + 'first' => '/v1/overrides?from=2026-10-01&page[number]=1&page[size]=1', + 'next' => '/v1/overrides?from=2026-10-01&page[number]=2&page[size]=1', + 'last' => '/v1/overrides?from=2026-10-01&page[number]=2&page[size]=1' + ] + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testPipelineWhenDeclaredParameterCarriesBracketedKeysThenEveryLinkPreservesThem(): void + { + /** @Given the query contract of an endpoint windowed by a bracketed window parameter */ + $schema = Schema::create()->preserving(parameters: ['window']); + + /** @And a request carrying window[from] and window[to] */ + $query = Query::from(parameters: [ + 'page' => ['size' => '1'], + 'window' => ['from' => '2026-10-01', 'to' => '2026-10-31'] + ]); + + /** @When rendering the first page of a two-element result as a JSON:API response */ + $response = OffsetCriteria::fromQuery(schema: $schema, request: $query) + ->page(items: ['a'], total: 2) + ->toResponse(baseUri: '/v1/overrides'); + + /** @Then every link preserves both bracketed keys */ + self::assertSame([ + 'data' => ['a'], + 'meta' => [ + 'total' => 2, + 'per_page' => 1, + 'total_pages' => 2, + 'current_page' => 1, + 'has_next' => true, + 'has_previous' => false + ], + 'links' => [ + 'self' => '/v1/overrides?window[from]=2026-10-01&window[to]=2026-10-31&page[number]=1&page[size]=1', + 'first' => '/v1/overrides?window[from]=2026-10-01&window[to]=2026-10-31&page[number]=1&page[size]=1', + 'next' => '/v1/overrides?window[from]=2026-10-01&window[to]=2026-10-31&page[number]=2&page[size]=1', + 'last' => '/v1/overrides?window[from]=2026-10-01&window[to]=2026-10-31&page[number]=2&page[size]=1' + ] + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testPipelineWhenSliceRequestCarriesDeclaredParametersThenEveryLinkPreservesThem(): void + { + /** @Given the query contract of an endpoint windowed by from and to */ + $schema = Schema::create()->preserving(parameters: ['from', 'to']); + + /** @And a request carrying the window and the first page */ + $query = Query::from(parameters: ['from' => '2026-10-01', 'to' => '2026-10-31', 'page' => ['size' => '1']]); + + /** @When rendering a slice fetched with one element beyond the page size as a JSON:API response */ + $response = OffsetCriteria::fromQuery(schema: $schema, request: $query) + ->slice(items: ['a', 'b']) + ->toResponse(baseUri: '/v1/overrides'); + + /** @Then every link preserves the window */ + self::assertSame([ + 'data' => ['a'], + 'meta' => [ + 'per_page' => 1, + 'current_page' => 1, + 'has_next' => true, + 'has_previous' => false + ], + 'links' => [ + 'self' => '/v1/overrides?from=2026-10-01&to=2026-10-31&page[number]=1&page[size]=1', + 'first' => '/v1/overrides?from=2026-10-01&to=2026-10-31&page[number]=1&page[size]=1', + 'next' => '/v1/overrides?from=2026-10-01&to=2026-10-31&page[number]=2&page[size]=1' + ] + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testPipelineWhenCursorRequestCarriesDeclaredParametersThenEveryLinkPreservesThem(): void + { + /** @Given the query contract of an endpoint scoped by a released slot */ + $schema = Schema::create() + ->sortable(fields: ['id']) + ->preserving(parameters: ['slot_start_time', 'slot_end_time']); + + /** @And a request carrying the released slot, a sort, and a page size of one */ + $query = Query::from(parameters: [ + 'sort' => 'id', + 'page' => ['size' => '1'], + 'slot_end_time' => '2026-10-14T11:00:00Z', + 'slot_start_time' => '2026-10-14T10:00:00Z' + ]); + + /** @And the base URI carrying the sort and the released slot */ + $base = '/v1/eligible?sort=id&slot_end_time=2026-10-14T11%3A00%3A00Z&slot_start_time=2026-10-14T10%3A00%3A00Z'; + + /** @When rendering the cursor page built through the keyset view as a JSON:API response */ + $response = CursorCriteria::fromQuery(schema: $schema, request: $query) + ->keyset() + ->page(items: [['id' => 10], ['id' => 20]]) + ->toResponse(baseUri: '/v1/eligible'); + + /** @Then the self and next links preserve the released slot */ + self::assertSame([ + 'data' => [['id' => 10]], + 'meta' => [ + 'per_page' => 1, + 'has_next' => true + ], + 'links' => [ + 'self' => sprintf('%s&page[size]=1', $base), + 'next' => sprintf('%s&page[cursor]=%s&page[size]=1', $base, Token::fromKeys(keys: [10])->toString()) + ] + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testPipelineWhenOffsetRequestCarriesDeclaredParametersThenEveryLinkPreservesThem(): void + { + /** @Given the query contract of an endpoint windowed by from and to */ + $schema = Schema::create()->sortable(fields: ['id'])->preserving(parameters: ['from', 'to']); + + /** @And a request carrying the window, an undeclared parameter, a sort, and the second page */ + $query = Query::from(parameters: [ + 'to' => '2026-10-31T23:59:59+00:00', + 'from' => '2026-10-01T00:00:00+00:00', + 'sort' => 'id', + 'page' => ['number' => '2', 'size' => '1'], + 'debug' => 'true' + ]); + + /** @And the base URI carrying the sort and the window as the request ordered it */ + $base = '/overrides?sort=id&to=2026-10-31T23%3A59%3A59%2B00%3A00&from=2026-10-01T00%3A00%3A00%2B00%3A00'; + + /** @When rendering the second page of a three-element result as a JSON:API response */ + $response = OffsetCriteria::fromQuery(schema: $schema, request: $query) + ->page(items: ['b'], total: 3) + ->toResponse(baseUri: '/overrides'); + + /** @Then every link preserves the window and drops the undeclared parameter */ + self::assertSame([ + 'data' => ['b'], + 'meta' => [ + 'total' => 3, + 'per_page' => 1, + 'total_pages' => 3, + 'current_page' => 2, + 'has_next' => true, + 'has_previous' => true + ], + 'links' => [ + 'self' => sprintf('%s&page[number]=2&page[size]=1', $base), + 'first' => sprintf('%s&page[number]=1&page[size]=1', $base), + 'prev' => sprintf('%s&page[number]=1&page[size]=1', $base), + 'next' => sprintf('%s&page[number]=3&page[size]=1', $base), + 'last' => sprintf('%s&page[number]=3&page[size]=1', $base) + ] + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testPipelineWhenCursorRequestCarriesDeclaredParametersThenLinkHeaderPreservesThem(): void + { + /** @Given the query contract of an endpoint scoped by a released slot */ + $schema = Schema::create() + ->sortable(fields: ['id']) + ->preserving(parameters: ['slot_start_time', 'slot_end_time']); + + /** @And a request carrying the released slot, a sort, and a page size of one */ + $query = Query::from(parameters: [ + 'sort' => 'id', + 'page' => ['size' => '1'], + 'slot_start_time' => '2026-10-14T10:00:00Z', + 'slot_end_time' => '2026-10-14T11:00:00Z' + ]); + + /** @And the base URI carrying the sort and the released slot */ + $base = '/v1/eligible?sort=id&slot_start_time=2026-10-14T10%3A00%3A00Z&slot_end_time=2026-10-14T11%3A00%3A00Z'; + + /** @When rendering the cursor page and reading its RFC 8288 Link header line */ + $header = CursorCriteria::fromQuery(schema: $schema, request: $query) + ->keyset() + ->page(items: [['id' => 10], ['id' => 20]]) + ->toResponse(baseUri: '/v1/eligible') + ->getHeaderLine('Link'); + + /** @Then the self and next relations preserve the released slot */ + self::assertSame(implode(', ', [ + sprintf('<%s&page[size]=1>; rel="self"', $base), + sprintf('<%s&page[cursor]=%s&page[size]=1>; rel="next"', $base, Token::fromKeys(keys: [10])->toString()) + ]), $header); + } + + public function testPipelineWhenOffsetRequestCarriesDeclaredParametersThenLinkHeaderPreservesThem(): void + { + /** @Given the query contract of an endpoint windowed by from and to */ + $schema = Schema::create()->preserving(parameters: ['from', 'to']); + + /** @And a request carrying the window and the first page */ + $query = Query::from(parameters: ['from' => '2026-10-01', 'to' => '2026-10-31', 'page' => ['size' => '1']]); + + /** @And the Link header template rendered per relation */ + $template = '; rel="%s"'; + + /** @When rendering the first page of a two-element result and reading its RFC 8288 Link header line */ + $header = OffsetCriteria::fromQuery(schema: $schema, request: $query) + ->page(items: ['a'], total: 2) + ->toResponse(baseUri: '/v1/overrides') + ->getHeaderLine('Link'); + + /** @Then every relation in the line preserves the window */ + self::assertSame(implode(', ', [ + sprintf($template, 1, 'self'), + sprintf($template, 1, 'first'), + sprintf($template, 2, 'next'), + sprintf($template, 2, 'last') + ]), $header); + } } diff --git a/tests/Unit/LinksTest.php b/tests/Unit/LinksTest.php index c832fb7..e5e725c 100644 --- a/tests/Unit/LinksTest.php +++ b/tests/Unit/LinksTest.php @@ -8,6 +8,7 @@ use ReflectionClass; use ReflectionMethod; use Test\TinyBlocks\HttpQuery\Models\Query; +use TinyBlocks\Http\LinkRelation; use TinyBlocks\HttpQuery\Comparison; use TinyBlocks\HttpQuery\Group; use TinyBlocks\HttpQuery\Internal\Rsql\Renderer; @@ -18,6 +19,7 @@ use TinyBlocks\HttpQuery\Offset\Criteria; use TinyBlocks\HttpQuery\Offset\Pagination; use TinyBlocks\HttpQuery\Operator; +use TinyBlocks\HttpQuery\PreservedParameters; use TinyBlocks\HttpQuery\Sort; final class LinksTest extends TestCase @@ -385,6 +387,34 @@ public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheSt self::assertInstanceOf(Renderer::class, $renderer); } + public function testToArrayWhenPreservedParametersGivenThenEveryUriCarriesThemBeforeThePagination(): void + { + /** @Given the parameters every navigation URI preserves */ + $preserved = PreservedParameters::from(parameters: ['from' => '2026-10-01', 'to' => '2026-10-31']); + + /** @And a navigation exposing the next page */ + $navigation = Navigation::empty()->with( + target: Pagination::fromPage(page: 2, perPage: 20), + relation: LinkRelation::NEXT + ); + + /** @When rendering the navigation for a sorted page carrying those parameters */ + $links = Links::from( + self: Pagination::fromPage(page: 1, perPage: 20), + sort: Sort::fromExpression(expression: 'id'), + filter: Group::none(), + baseUri: '/v1/overrides', + navigation: $navigation, + preserved: $preserved + ); + + /** @Then the self and next links carry the parameters between the sort and the pagination */ + self::assertSame([ + 'self' => '/v1/overrides?sort=id&from=2026-10-01&to=2026-10-31&page[number]=1&page[size]=20', + 'next' => '/v1/overrides?sort=id&from=2026-10-01&to=2026-10-31&page[number]=2&page[size]=20' + ], $links->toArray()); + } + public function testToArrayWhenValueCarriesUnsafeAndStructuralCharactersThenEncodesOnlyTheUnsafeOnes(): void { /** @Given a comparison whose value carries query-unsafe and RSQL-structural characters */ diff --git a/tests/Unit/PreservedParametersTest.php b/tests/Unit/PreservedParametersTest.php new file mode 100644 index 0000000..b12cc78 --- /dev/null +++ b/tests/Unit/PreservedParametersTest.php @@ -0,0 +1,172 @@ + [], 'from' => '2026-10-01']; + + /** @When creating the preserved parameters from them */ + $actual = PreservedParameters::from(parameters: $parameters)->toQueryString(); + + /** @Then only the scalar parameter renders */ + self::assertSame('from=2026-10-01', $actual); + } + + public function testFromWhenNameIsNumericThenRendersItAsAName(): void + { + /** @Given a parameter whose name the query string parsed into an integer key */ + $parameters = [2026 => 'year']; + + /** @When creating the preserved parameters from it */ + $actual = PreservedParameters::from(parameters: $parameters)->toQueryString(); + + /** @Then the numeric name renders as written */ + self::assertSame('2026=year', $actual); + } + + public function testFromWhenNestedMapThenRendersBracketedKeys(): void + { + /** @Given a window parameter carrying a nested map of bounds */ + $parameters = ['window' => ['from' => '2026-10-01', 'to' => '2026-10-31']]; + + /** @When creating the preserved parameters from it */ + $actual = PreservedParameters::from(parameters: $parameters)->toQueryString(); + + /** @Then each bound renders under a bracketed key */ + self::assertSame('window[from]=2026-10-01&window[to]=2026-10-31', $actual); + } + + public function testFromWhenListThenRendersIndexedBracketedKeys(): void + { + /** @Given a parameter carrying a list of identifiers */ + $parameters = ['ids' => ['10', '20']]; + + /** @When creating the preserved parameters from it */ + $actual = PreservedParameters::from(parameters: $parameters)->toQueryString(); + + /** @Then each identifier renders under its index */ + self::assertSame('ids[0]=10&ids[1]=20', $actual); + } + + public function testFromWhenNoParameterThenRendersAnEmptyFragment(): void + { + /** @Given no parameter to preserve */ + $parameters = []; + + /** @When creating the preserved parameters from it */ + $actual = PreservedParameters::from(parameters: $parameters)->toQueryString(); + + /** @Then the fragment is empty */ + self::assertSame('', $actual); + } + + public function testFromWhenValueCarriesBracketsThenPercentEncodesThem(): void + { + /** @Given a value carrying brackets */ + $parameters = ['label' => '[draft]']; + + /** @When creating the preserved parameters from it */ + $actual = PreservedParameters::from(parameters: $parameters)->toQueryString(); + + /** @Then the brackets in the value are percent-encoded */ + self::assertSame('label=%5Bdraft%5D', $actual); + } + + public function testFromWhenDeeplyNestedMapThenRendersEveryLevelBracketed(): void + { + /** @Given a parameter nested two levels deep */ + $parameters = ['range' => ['start' => ['date' => '2026-10-01']]]; + + /** @When creating the preserved parameters from it */ + $actual = PreservedParameters::from(parameters: $parameters)->toQueryString(); + + /** @Then every level renders under its own bracketed key */ + self::assertSame('range[start][date]=2026-10-01', $actual); + } + + #[DataProvider('invalidNameCases')] + public function testFromWhenNameIsInvalidThenThrowsPreservedParameterIsInvalid(string $name): void + { + /** @Given a parameter name that is empty, bracketed, or reserved */ + + /** @Then an exception indicating the preserved parameter is invalid is raised */ + $this->expectException(PreservedParameterIsInvalid::class); + $this->expectExceptionMessage( + sprintf('Preserved parameter name <%s> is empty, bracketed, or reserved.', $name) + ); + + /** @When creating the preserved parameters from it */ + PreservedParameters::from(parameters: [$name => 'value']); + } + + public function testFromWhenValueCarriesReservedCharactersThenPercentEncodesThem(): void + { + /** @Given instants carrying a colon and a plus offset, next to a note carrying separators */ + $parameters = ['from' => '2026-10-01T09:00:00+00:00', 'note' => 'a&b=c #d']; + + /** @When creating the preserved parameters from them */ + $actual = PreservedParameters::from(parameters: $parameters)->toQueryString(); + + /** @Then every reserved character in the values is percent-encoded */ + self::assertSame('from=2026-10-01T09%3A00%3A00%2B00%3A00¬e=a%26b%3Dc%20%23d', $actual); + } + + #[DataProvider('invalidValueCases')] + public function testFromWhenValueIsNotAStringThenThrowsPreservedParameterIsInvalid(mixed $value): void + { + /** @Given a parameter value that is neither a string nor a nested array of strings */ + + /** @Then an exception indicating the preserved parameter is invalid is raised */ + $this->expectException(PreservedParameterIsInvalid::class); + $this->expectExceptionMessage('Preserved parameter must be a string or a nested array of strings.'); + + /** @When creating the preserved parameters from it */ + PreservedParameters::from(parameters: ['from' => $value]); + } + + public function testFromWhenNameAndKeyCarryReservedCharactersThenPercentEncodesThem(): void + { + /** @Given a name and a nested key carrying a space and an ampersand */ + $parameters = ['time zone' => ['a&b' => 'UTC']]; + + /** @When creating the preserved parameters from it */ + $actual = PreservedParameters::from(parameters: $parameters)->toQueryString(); + + /** @Then the name and the key are percent-encoded around literal brackets */ + self::assertSame('time%20zone[a%26b]=UTC', $actual); + } + + public static function invalidNameCases(): array + { + return [ + 'Empty' => ['name' => ''], + 'Page family' => ['name' => 'page'], + 'Sort parameter' => ['name' => 'sort'], + 'Filter parameter' => ['name' => 'filter'], + 'Opening bracket' => ['name' => 'window['], + 'Closing bracket' => ['name' => 'window]'], + 'Bracketed path name' => ['name' => 'window[from]'] + ]; + } + + public static function invalidValueCases(): array + { + return [ + 'Null' => ['value' => null], + 'Integer' => ['value' => 10], + 'Boolean' => ['value' => true], + 'Nested integer' => ['value' => ['start' => 10]] + ]; + } +} diff --git a/tests/Unit/SchemaTest.php b/tests/Unit/SchemaTest.php index 2511ab8..e86745b 100644 --- a/tests/Unit/SchemaTest.php +++ b/tests/Unit/SchemaTest.php @@ -4,10 +4,12 @@ namespace Test\TinyBlocks\HttpQuery\Unit; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionMethod; use TinyBlocks\HttpQuery\Exceptions\PageSizeOutOfRange; +use TinyBlocks\HttpQuery\Exceptions\PreservedParameterIsInvalid; use TinyBlocks\HttpQuery\Internal\Conjunction; use TinyBlocks\HttpQuery\Schema; @@ -149,6 +151,21 @@ public function testDefaultPerPageWhenBelowMinimumThenThrowsPageSizeOutOfRange() $schema->defaultPerPage(defaultPerPage: 0); } + #[DataProvider('invalidNameCases')] + public function testPreservingWhenNameIsInvalidThenThrowsPreservedParameterIsInvalid(string $name): void + { + /** @Given a parameter name that is empty, bracketed, or reserved */ + + /** @Then an exception indicating the preserved parameter is invalid is raised */ + $this->expectException(PreservedParameterIsInvalid::class); + $this->expectExceptionMessage( + sprintf('Preserved parameter name <%s> is empty, bracketed, or reserved.', $name) + ); + + /** @When declaring it as a parameter every navigation URI preserves */ + Schema::create()->preserving(parameters: ['from', $name]); + } + public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheStaticOnlyConjunction(): void { /** @Given an uninitialized instance of the static-only conjunction flattener */ @@ -160,4 +177,34 @@ public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheSt /** @Then the static-only conjunction flattener is instantiated */ self::assertInstanceOf(Conjunction::class, $flattener); } + + public function testPreservedForWhenUndeclaredParameterPresentThenKeepsOnlyTheDeclaredInRequestOrder(): void + { + /** @Given a schema preserving the window bounds and a parameter the request omits */ + $schema = Schema::create()->preserving(parameters: ['from', 'to', 'timezone']); + + /** @When reading the preserved parameters out of query parameters carrying an undeclared one */ + $actual = $schema->preservedFor(parameters: [ + 'to' => '2026-10-31', + 'page' => ['size' => '1'], + 'from' => '2026-10-01', + 'debug' => 'true' + ]); + + /** @Then only the declared parameters the request carries are kept, in the request order */ + self::assertSame('to=2026-10-31&from=2026-10-01', $actual->toQueryString()); + } + + public static function invalidNameCases(): array + { + return [ + 'Empty' => ['name' => ''], + 'Page family' => ['name' => 'page'], + 'Sort parameter' => ['name' => 'sort'], + 'Filter parameter' => ['name' => 'filter'], + 'Opening bracket' => ['name' => 'window['], + 'Closing bracket' => ['name' => 'window]'], + 'Bracketed path name' => ['name' => 'window[from]'] + ]; + } }