Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
<?php

declare(strict_types=1);

use Psr\Http\Message\ServerRequestInterface;
use TinyBlocks\HttpQuery\Offset\Criteria;
use TinyBlocks\HttpQuery\Schema;

$schema = Schema::create()->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<mixed> $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
<?php

declare(strict_types=1);

use Psr\Http\Message\ServerRequestInterface;
use TinyBlocks\HttpQuery\Cursor\Criteria;
use TinyBlocks\HttpQuery\Schema;
use TinyBlocks\HttpQuery\Sort;

$schema = Schema::create()
->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<array{id: string, created_at: string}> $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?
Expand Down
5 changes: 5 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed>.
- identifier: return.type
path: src/Links.php
Expand Down
6 changes: 5 additions & 1 deletion src/Cursor/Criteria.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
);
}

Expand Down
24 changes: 20 additions & 4 deletions src/Cursor/Keyset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -25,6 +26,7 @@
private function __construct(
private Sort $sort,
private Filter $filter,
private ?PreservedParameters $preserved,
private Pagination $pagination,
private Sort $submittedSort
) {
Expand All @@ -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
);
}

/**
Expand All @@ -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
);
}

Expand Down
11 changes: 10 additions & 1 deletion src/Cursor/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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
Expand All @@ -49,14 +51,17 @@ private function __construct(
* @param Filter $filter The filter preserved in every rendered URI.
* @param Closure(TElement): list<mixed> $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<TElement> The cursor page carrying the trimmed items and the next cursor.
*/
public static function from(
Sort $sort,
iterable $items,
Filter $filter,
Closure $keysOf,
Pagination $pagination
Pagination $pagination,
?PreservedParameters $preserved = null
): Page {
/** @var Collection<TElement> $collection */
$collection = Collection::createFrom(elements: $items);
Expand All @@ -67,6 +72,7 @@ public static function from(
items: $seek->items(),
filter: $filter,
hasNext: $seek->hasNext(),
preserved: $preserved,
nextCursor: $seek->next(),
pagination: $pagination,
extraMetadata: []
Expand All @@ -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
Expand Down Expand Up @@ -172,6 +179,7 @@ public function toResponse(string $baseUri): ResponseInterface
filter: $this->filter,
baseUri: $baseUri,
metadata: $this->metadata(),
preserved: $this->preserved,
navigation: $this->navigation()
);
}
Expand All @@ -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]
Expand Down
51 changes: 51 additions & 0 deletions src/Exceptions/PreservedParameterIsInvalid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\HttpQuery\Exceptions;

use InvalidArgumentException;

/**
* Raised when a query parameter declared to be preserved in every navigation URI is malformed.
*
* A preserved parameter name must not be empty, must not carry a bracket, and must stay outside the
* filter, sort, and page families the library renders itself. Its value must be a string or a nested
* array of strings, the shapes a query string parses into. The message never carries the value.
*/
final class PreservedParameterIsInvalid extends InvalidArgumentException implements HttpQueryException
{
private const string NAME_NOT_ALLOWED = 'Preserved parameter name <%s> 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));
}
}
25 changes: 25 additions & 0 deletions src/Internal/ParameterName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\HttpQuery\Internal;

use TinyBlocks\HttpQuery\Exceptions\PreservedParameterIsInvalid;

final readonly class ParameterName
{
private const array RESERVED = ['page', 'sort', 'filter'];

private function __construct(public string $value)
{
}

public static function from(string $value): ParameterName
{
if ($value === '' || strpbrk($value, '[]') !== false || in_array($value, self::RESERVED, true)) {
throw PreservedParameterIsInvalid::fromName(name: $value);
}

return new ParameterName(value: $value);
}
}
63 changes: 63 additions & 0 deletions src/Internal/PreservedPairs.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\HttpQuery\Internal;

use TinyBlocks\HttpQuery\Exceptions\PreservedParameterIsInvalid;

final readonly class PreservedPairs
{
private function __construct(private array $pairs)
{
}

public static function from(array $parameters): PreservedPairs
{
$pairs = array_map(
static function (int|string $name, mixed $value): array {
$parameterName = ParameterName::from(value: (string)$name);

return PreservedPairs::pairsOf(
key: rawurlencode($parameterName->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);
}
}
Loading