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
46 changes: 37 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ parameters, validate them against the schema, and expose the same `comparisons()

`Schema` is the contract of the query an endpoint accepts. `filterable` declares a field with its permitted operators
and, optionally, the permitted values and the `ValueKind` every value must match. `sortable` declares the fields the
client may sort by. `defaultSort` declares the sort applied when the client sends none. `maxPerPage` and
`defaultPerPage` bound the page size. The query parameter names follow JSON:API and are fixed: `filter`, `sort`, and the
`page` family.
client may sort by. `cursorKey` declares the `ValueKind` every cursor value of a field must match (see
[Cursor pagination](#cursor-pagination)). `defaultSort` declares the sort applied when the client sends none.
`maxPerPage` and `defaultPerPage` bound the page size. The query parameter names follow JSON:API and are fixed:
`filter`, `sort`, and the `page` family.

```php
<?php
Expand Down Expand Up @@ -156,11 +157,12 @@ while a binary collation matches exactly. Declare the collation the endpoint pro
`ValueKind` is the kind a value is validated against. For the multivalued operators (`=in=`, `=out=`) every value is
checked.

| Kind | Matches |
|-----------------------|--------------------------------------------------------|
| `ValueKind::STRING` | A non-empty string. |
| `ValueKind::INTEGER` | An optionally signed sequence of digits, `-7` or `42`. |
| `ValueKind::DATETIME` | An ISO-8601 date or date-time, `2023-01-15T10:30:00Z`. |
| Kind | Matches |
|-----------------------|----------------------------------------------------------------------------------|
| `ValueKind::UUID` | A hyphenated UUID in either letter case, `01900000-0000-7099-8000-000000000001`. |
| `ValueKind::STRING` | A non-empty string. |
| `ValueKind::INTEGER` | An optionally signed sequence of digits, `-7` or `42`. |
| `ValueKind::DATETIME` | An ISO-8601 date or date-time, `2023-01-15T10:30:00Z`. |

### Sorting

Expand Down Expand Up @@ -291,6 +293,31 @@ $cursorPage->map(transformation: static fn(array $row): array => ['id' => $row['

An invalid cursor token raises `CursorIsInvalid` when it is decoded.

A cursor token round-trips through the client, so its values are untrusted input. `cursorKey` declares the `ValueKind`
every cursor value of a field must match, and `Criteria::fromQuery` then raises `CursorIsInvalid` for a token whose
value for that field does not match, before the value reaches the store. A forged identifier is refused at parse
instead of failing inside a `UUID_TO_BIN` binding.

```php
<?php

declare(strict_types=1);

use TinyBlocks\HttpQuery\Schema;
use TinyBlocks\HttpQuery\Sort;
use TinyBlocks\HttpQuery\ValueKind;

$schema = Schema::create()
->sortable(fields: ['created_at', 'id'])
->cursorKey(field: 'id', valueKind: ValueKind::UUID)
->defaultSort(sort: Sort::fromExpression(expression: '-created_at,-id'));
```

A field without a declared kind is not checked, and an absent cursor or a null value carries no key, so it always
passes. The kind describes the value as the cursor carries it, which is the form read from the source rows, not the
form a client sends in a filter. A `ValueKind::DATETIME` key therefore needs ISO-8601 values in the rows (or a
`keysOf` that emits them), since a raw SQL timestamp such as `2026-01-15 10:30:00.000000` does not match it.

### Building the store query

The library decides what to fetch and hands the consumer typed SQL fragments to apply against its own store. It builds
Expand Down Expand Up @@ -581,7 +608,8 @@ the page's own approach.

Field and operator names are validated against the `Schema` allowlist while parsing, so only declared identifiers ever
reach your store. Comparison and cursor values are returned as data for you to bind as parameters, and the library
builds no SQL. A cursor token is decoded only as a list of scalar values. Any unsafe character in the filter and sort
builds no SQL. A cursor token is decoded only as a list of scalar values, and a value whose field declares a
`cursorKey` kind must match it. Any unsafe character in the filter and sort
echoed into the `links` object and the `Link` header is percent-encoded. Binding the values is still your
responsibility.

Expand Down
6 changes: 6 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ parameters:
# The cursor codec encodes and decodes opaque key values whose types are not known.
- identifier: missingType.iterableValue
path: src/Internal/Cursor/CursorCodec.php
# The cursor key kinds are a map of field to ValueKind carried on an internal constructor, so
# the kind read back from it is widened to mixed.
- identifier: missingType.iterableValue
path: src/Internal/Cursor/CursorKeys.php
- identifier: argument.type
path: src/Internal/Cursor/CursorKeys.php
# Test fixtures and data providers carry raw arrays at the test boundary.
- identifier: missingType.iterableValue
path: tests
Expand Down
15 changes: 6 additions & 9 deletions src/Cursor/Criteria.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
use TinyBlocks\HttpQuery\Exceptions\SortIsRequired;
use TinyBlocks\HttpQuery\Filter;
use TinyBlocks\HttpQuery\Internal\Query;
use TinyBlocks\HttpQuery\Order;
use TinyBlocks\HttpQuery\Schema;
use TinyBlocks\HttpQuery\Sort;

Expand All @@ -45,9 +44,10 @@ private function __construct(
* Creates a Criteria from the schema and the request.
*
* <p>It parses the request query string and validates it against the schema, the incoming cursor
* included: a token that cannot be decoded into one value per effective sort order is rejected
* here, and never later while the seek is being built. The pagination always carries the
* incoming cursor token and the page size.</p>
* included: a token that cannot be decoded into one value per effective sort order, or whose
* value breaks the kind the schema declares for its cursor key, is rejected here, and never
* later while the seek is being built. The pagination always carries the incoming cursor token
* and the page size.</p>
*
* @param Schema $schema The query contract the request is validated against.
* @param ServerRequestInterface $request The incoming PSR-7 server request.
Expand All @@ -60,15 +60,12 @@ private function __construct(
* @throws FilterOperatorNotAllowed If a comparison uses an operator not allowed for its field.
* @throws FilterValueNotAllowed If a compared value falls outside the permitted set or kind.
* @throws SortFieldNotAllowed If the sort orders by a field that was never declared sortable.
* @throws CursorIsInvalid If the incoming cursor cannot be decoded into one value per sort order.
* @throws CursorIsInvalid If the incoming cursor is not one value per sort order or breaks a cursor key kind.
*/
public static function fromQuery(Schema $schema, ServerRequestInterface $request): Criteria
{
$query = Query::from(schema: $schema, request: $request);
$cursor = Token::from(token: $query->cursorToken());
$fields = array_map(static fn(Order $order): string => $order->field(), $query->sort()->orders());

$cursor->keyedBy(fields: $fields);
$cursor = $schema->cursorFor(sort: $query->sort(), cursor: Token::from(token: $query->cursorToken()));

return new Criteria(
sort: $query->sort(),
Expand Down
32 changes: 25 additions & 7 deletions src/Exceptions/CursorIsInvalid.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,23 @@

use InvalidArgumentException;
use Throwable;
use TinyBlocks\HttpQuery\ValueKind;

/**
* Raised when an opaque cursor token cannot be decoded back into its ordering key values.
*
* A cursor token is produced by the library and must round-trip through its codec. A token that
* was truncated, tampered with, or generated elsewhere fails to decode.
* was truncated, tampered with, or generated elsewhere fails to decode. A token that decodes but
* carries a value outside the kind declared for its cursor key is rejected the same way.
*/
final class CursorIsInvalid extends InvalidArgumentException implements HttpQueryException
{
private const string REASON_TEMPLATE = 'Cursor token <%s> is invalid and could not be decoded.';
private const string KIND_MISMATCH = 'Cursor token <%s> does not match the %s kind for cursor key <%s>.';
private const string NOT_DECODABLE = 'Cursor token <%s> is invalid and could not be decoded.';

private function __construct(string $token, ?Throwable $previous)
private function __construct(string $reason, ?Throwable $previous = null)
{
$template = CursorIsInvalid::REASON_TEMPLATE;

parent::__construct(message: sprintf($template, $token), previous: $previous);
parent::__construct(message: $reason, previous: $previous);
}

/**
Expand All @@ -33,6 +34,23 @@ private function __construct(string $token, ?Throwable $previous)
*/
public static function from(string $token, ?Throwable $previous = null): CursorIsInvalid
{
return new CursorIsInvalid(token: $token, previous: $previous);
$template = CursorIsInvalid::NOT_DECODABLE;

return new CursorIsInvalid(reason: sprintf($template, $token), previous: $previous);
}

/**
* Creates a CursorIsInvalid signaling that a decoded value does not match its cursor key kind.
*
* @param ValueKind $kind The value kind declared for the cursor key.
* @param string $field The cursor key whose value was rejected.
* @param string $token The opaque cursor token carrying the rejected value.
* @return CursorIsInvalid The composed exception describing the kind mismatch.
*/
public static function kindMismatch(ValueKind $kind, string $field, string $token): CursorIsInvalid
{
$template = CursorIsInvalid::KIND_MISMATCH;

return new CursorIsInvalid(reason: sprintf($template, $token, $kind->value, $field));
}
}
49 changes: 49 additions & 0 deletions src/Internal/Cursor/CursorKeys.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\HttpQuery\Internal\Cursor;

use TinyBlocks\HttpQuery\Cursor\Token;
use TinyBlocks\HttpQuery\Exceptions\CursorIsInvalid;
use TinyBlocks\HttpQuery\Order;
use TinyBlocks\HttpQuery\Sort;
use TinyBlocks\HttpQuery\ValueKind;

final readonly class CursorKeys
{
private function __construct(private array $kinds)
{
}

public static function createFromEmpty(): CursorKeys
{
return new CursorKeys(kinds: []);
}

private function fits(mixed $key, ValueKind $kind): bool
{
return is_null($key) || ((is_string($key) || is_int($key)) && $kind->matches(value: (string) $key));
}

public function with(ValueKind $kind, string $field): CursorKeys
{
return new CursorKeys(kinds: [...$this->kinds, $field => $kind]);
}

public function permit(Sort $sort, Token $cursor): Token
{
$fields = array_map(static fn(Order $order): string => $order->field(), $sort->orders());
$keys = $cursor->keyedBy(fields: $fields);

foreach ($fields as $field) {
$kind = ($this->kinds[$field] ?? null);

if (!is_null($kind) && !$this->fits(key: $keys[$field], kind: $kind)) {
throw CursorIsInvalid::kindMismatch(kind: $kind, field: $field, token: $cursor->toString());
}
}

return $cursor;
}
}
56 changes: 55 additions & 1 deletion src/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace TinyBlocks\HttpQuery;

use TinyBlocks\HttpQuery\Cursor\Token;
use TinyBlocks\HttpQuery\Exceptions\CursorIsInvalid;
use TinyBlocks\HttpQuery\Exceptions\FilterFieldNotAllowed;
use TinyBlocks\HttpQuery\Exceptions\FilterOperatorNotAllowed;
use TinyBlocks\HttpQuery\Exceptions\FilterShapeNotSupported;
Expand All @@ -12,12 +14,14 @@
use TinyBlocks\HttpQuery\Exceptions\SortFieldNotAllowed;
use TinyBlocks\HttpQuery\Internal\AllowedFilters;
use TinyBlocks\HttpQuery\Internal\Conjunction;
use TinyBlocks\HttpQuery\Internal\Cursor\CursorKeys;

/**
* Declarative contract of the query an endpoint accepts, used to validate an incoming request.
*
* <p>It declares the filterable fields with their permitted operators, values, and kinds, the
* client-sortable fields, the sort applied when the client sends none, and the page-size bounds.
* client-sortable fields, the kind each cursor key value must match, the sort applied when the
* client sends none, and the page-size bounds.
* The query parameter names follow JSON:API and are fixed: <code>filter</code>, <code>sort</code>,
* and the <code>page</code> family. The default page size is 20 and the maximum is 100.</p>
*/
Expand All @@ -26,6 +30,7 @@
private function __construct(
private AllowedFilters $allowed,
private Sort $byDefault,
private CursorKeys $cursorKeys,
private int $maxPerPage,
private int $defaultPerPage,
private array $sortableFields,
Expand Down Expand Up @@ -70,6 +75,7 @@ public static function default(): Schema
return new Schema(
allowed: AllowedFilters::createFromEmpty(),
byDefault: Sort::fromExpression(expression: ''),
cursorKeys: CursorKeys::createFromEmpty(),
maxPerPage: 100,
defaultPerPage: 20,
sortableFields: [],
Expand Down Expand Up @@ -110,13 +116,56 @@ public function sortable(array $fields): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
sortableFields: $fields,
allowsDisjunction: $this->allowsDisjunction
);
}

/**
* Returns the incoming cursor, validated against the effective sort and the declared cursor keys.
*
* <p>The cursor must decode into one value per effective sort order, and every value of a field
* declared through <code>cursorKey</code> must match the declared kind. An absent cursor and a
* null value carry no key, so they always pass. A field without a declared kind is not checked.</p>
*
* @param Sort $sort The effective sort the cursor values are keyed by.
* @param Token $cursor The incoming cursor read from the request.
* @return Token The incoming cursor, unchanged once validated.
* @throws CursorIsInvalid If the cursor is not one value per sort order or breaks a cursor key kind.
*/
public function cursorFor(Sort $sort, Token $cursor): Token
{
return $this->cursorKeys->permit(sort: $sort, cursor: $cursor);
}

/**
* Returns a copy of the Schema declaring the kind every cursor value of the field must match.
*
* <p>A cursor token round-trips through the client, so its values are untrusted input. A cursor
* whose value for the field does not match the kind is rejected while the request is parsed,
* before the value reaches the store. The kind describes the value as the cursor carries it,
* that is as it was read from the source rows.</p>
*
* @param string $field The ordering field whose cursor values are checked.
* @param ValueKind $valueKind The kind every cursor value of the field must match.
* @return Schema A copy carrying the original contract plus the cursor key kind.
*/
public function cursorKey(string $field, ValueKind $valueKind): Schema
{
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
cursorKeys: $this->cursorKeys->with(kind: $valueKind, field: $field),
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
sortableFields: $this->sortableFields,
allowsDisjunction: $this->allowsDisjunction
);
}

/**
* Returns a copy of the Schema allowing the field under the operators, values, and kind.
*
Expand All @@ -140,6 +189,7 @@ public function filterable(
operators: $operators
),
byDefault: $this->byDefault,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
sortableFields: $this->sortableFields,
Expand All @@ -159,6 +209,7 @@ public function maxPerPage(int $maxPerPage): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
cursorKeys: $this->cursorKeys,
maxPerPage: $maxPerPage,
defaultPerPage: $this->defaultPerPage,
sortableFields: $this->sortableFields,
Expand All @@ -177,6 +228,7 @@ public function defaultSort(Sort $sort): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $sort,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
sortableFields: $this->sortableFields,
Expand Down Expand Up @@ -243,6 +295,7 @@ public function defaultPerPage(int $defaultPerPage): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $defaultPerPage,
sortableFields: $this->sortableFields,
Expand All @@ -260,6 +313,7 @@ public function allowDisjunction(): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
sortableFields: $this->sortableFields,
Expand Down
9 changes: 6 additions & 3 deletions src/ValueKind.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
use TinyBlocks\HttpQuery\Internal\Iso8601;

/**
* Kind a filter value is validated against, backed by its canonical token.
* Kind a filter value or a cursor value is validated against, backed by its canonical token.
*
* <p>A <code>STRING</code> is any non-empty string, an <code>INTEGER</code> is an optionally
* signed sequence of digits, and a <code>DATETIME</code> is an ISO-8601 date or date-time.</p>
* <p>A <code>UUID</code> is the canonical hyphenated form of a UUID in either letter case, a
* <code>STRING</code> is any non-empty string, an <code>INTEGER</code> is an optionally signed
* sequence of digits, and a <code>DATETIME</code> is an ISO-8601 date or date-time.</p>
*/
enum ValueKind: string
{
case UUID = 'uuid';
case STRING = 'string';
case INTEGER = 'integer';
case DATETIME = 'datetime';
Expand All @@ -27,6 +29,7 @@ enum ValueKind: string
public function matches(string $value): bool
{
return match ($this) {
ValueKind::UUID => preg_match('/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i', $value) === 1,
ValueKind::STRING => $value !== '',
ValueKind::INTEGER => preg_match('/^-?\d+$/', $value) === 1,
ValueKind::DATETIME => Iso8601::isValid(value: $value)
Expand Down
Loading
Loading