diff --git a/README.md b/README.md index 44f0759..167d2c4 100644 --- a/README.md +++ b/README.md @@ -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 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 +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 @@ -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. diff --git a/phpstan.neon.dist b/phpstan.neon.dist index dd0c6fe..b80039e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -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 diff --git a/src/Cursor/Criteria.php b/src/Cursor/Criteria.php index bff13c4..26f88fd 100644 --- a/src/Cursor/Criteria.php +++ b/src/Cursor/Criteria.php @@ -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; @@ -45,9 +44,10 @@ private function __construct( * Creates a Criteria from the schema and the request. * *
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.
+ * 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. * * @param Schema $schema The query contract the request is validated against. * @param ServerRequestInterface $request The incoming PSR-7 server request. @@ -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(), diff --git a/src/Exceptions/CursorIsInvalid.php b/src/Exceptions/CursorIsInvalid.php index e4a6bbc..5efcaf8 100644 --- a/src/Exceptions/CursorIsInvalid.php +++ b/src/Exceptions/CursorIsInvalid.php @@ -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); } /** @@ -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)); } } diff --git a/src/Internal/Cursor/CursorKeys.php b/src/Internal/Cursor/CursorKeys.php new file mode 100644 index 0000000..6a10468 --- /dev/null +++ b/src/Internal/Cursor/CursorKeys.php @@ -0,0 +1,49 @@ +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; + } +} diff --git a/src/Schema.php b/src/Schema.php index 97136b4..a109730 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -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; @@ -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. * *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: filter, sort,
* and the page family. The default page size is 20 and the maximum is 100.
The cursor must decode into one value per effective sort order, and every value of a field
+ * declared through cursorKey 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.
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.
+ * + * @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. * @@ -140,6 +189,7 @@ public function filterable( operators: $operators ), byDefault: $this->byDefault, + cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, sortableFields: $this->sortableFields, @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/src/ValueKind.php b/src/ValueKind.php index 7943a0d..490171b 100644 --- a/src/ValueKind.php +++ b/src/ValueKind.php @@ -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. * - *A STRING is any non-empty string, an INTEGER is an optionally
- * signed sequence of digits, and a DATETIME is an ISO-8601 date or date-time.
A UUID is the canonical hyphenated form of a UUID in either letter case, a
+ * STRING is any non-empty string, an INTEGER is an optionally signed
+ * sequence of digits, and a DATETIME is an ISO-8601 date or date-time.