{"id":52095047,"url":"https://github.com/tiny-blocks/http-query","last_synced_at":"2026-08-04T10:30:20.424Z","repository":{"id":363575816,"uuid":"1259798382","full_name":"tiny-blocks/http-query","owner":"tiny-blocks","description":"Typed, framework-independent toolkit for HTTP collection queries (RSQL filtering, sorting, and offset and cursor pagination) that never touches a data store.","archived":false,"fork":false,"pushed_at":"2026-08-02T18:41:07.000Z","size":169,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-08-02T20:12:36.251Z","etag":null,"topics":["cursor-pagination","http-query","json-api","link-header","open-source","pagination","php","query-string","rsql","tiny-blocks"],"latest_commit_sha":null,"homepage":"https://packagist.org/packages/tiny-blocks/http-query","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tiny-blocks.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-04T21:45:33.000Z","updated_at":"2026-08-02T18:41:17.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tiny-blocks/http-query","commit_stats":null,"previous_names":["tiny-blocks/http-query"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/tiny-blocks/http-query","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fhttp-query","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fhttp-query/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fhttp-query/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fhttp-query/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tiny-blocks","download_url":"https://codeload.github.com/tiny-blocks/http-query/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fhttp-query/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":36273623,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"online","status_checked_at":"2026-08-04T02:00:06.901Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cursor-pagination","http-query","json-api","link-header","open-source","pagination","php","query-string","rsql","tiny-blocks"],"created_at":"2026-08-04T10:30:19.707Z","updated_at":"2026-08-04T10:30:20.412Z","avatar_url":"https://github.com/tiny-blocks.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Http Query\n\n[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/http-query/blob/main/LICENSE)\n\n* [Overview](#overview)\n* [Installation](#installation)\n* [How to use](#how-to-use)\n    + [Declaring the query contract](#declaring-the-query-contract)\n    + [Filtering with RSQL](#filtering-with-rsql)\n    + [Sorting](#sorting)\n    + [Offset pagination](#offset-pagination)\n    + [Cursor pagination](#cursor-pagination)\n    + [Building the store query](#building-the-store-query)\n    + [Rendering navigation links](#rendering-navigation-links)\n* [FAQ](#faq)\n* [License](#license)\n* [Contributing](#contributing)\n\n## Overview\n\nA typed, framework-independent toolkit for querying an HTTP collection endpoint. The endpoint declares the query\ncontract once on a `Schema`, the library parses an incoming request query string against it, and the consumer reads an\nalready-validated result: a conjunction of comparisons for filtering, an effective sort, and a pagination view. The\nresult renders as a JSON:API response carrying an RFC 8288 `Link` header and a body `links` object.\n\nThe library never touches a data store. It turns the query string into value objects the consumer applies to its own\nstore, and it renders the response the consumer returns. Every computation is O (1) value-object math over the inputs\nthe consumer supplies.\n\nThe pagination approach is a fixed decision of the endpoint, not a runtime property of a request. The public API splits\ninto two contexts, `Cursor` and `Offset`, each complete and approach-only, with the building blocks common to both at\nthe root. Each page renders a `self` link consistent with its own approach, so a cursor page renders a cursor `self`\nand an offset page renders an offset `self`.\n\n## Installation\n\n```bash\ncomposer require tiny-blocks/http-query\n```\n\n## How to use\n\nThere are two entry points, one per pagination approach. `TinyBlocks\\HttpQuery\\Offset\\Criteria` reads an offset request\n(`page[number]`, `page[size]`) and builds offset pages, while `TinyBlocks\\HttpQuery\\Cursor\\Criteria` reads a keyset\nrequest (`page[cursor]`, `page[size]`) and builds forward-only cursor pages. Both read the same `filter` and `sort`\nparameters, validate them against the schema, and expose the same `comparisons()` and `sort()` building blocks.\n\n### Declaring the query contract\n\n`Schema` is the contract of the query an endpoint accepts. `filterable` declares a field with its permitted operators\nand, optionally, the permitted values and the `ValueKind` every value must match. `sortable` declares the fields the\nclient may sort by. `defaultSort` declares the sort applied when the client sends none. `maxPerPage` and\n`defaultPerPage` bound the page size. The query parameter names follow JSON:API and are fixed: `filter`, `sort`, and the\n`page` family.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse TinyBlocks\\HttpQuery\\Offset\\Criteria;\nuse TinyBlocks\\HttpQuery\\Operator;\nuse TinyBlocks\\HttpQuery\\Schema;\nuse TinyBlocks\\HttpQuery\\Sort;\nuse TinyBlocks\\HttpQuery\\ValueKind;\n\n$schema = Schema::create()\n    -\u003emaxPerPage(maxPerPage: 100)\n    -\u003esortable(fields: ['created_at', 'id'])\n    -\u003edefaultSort(sort: Sort::fromExpression(expression: '-created_at'))\n    -\u003efilterable(field: 'total', operators: [Operator::GREATER_THAN_OR_EQUAL], valueKind: ValueKind::INTEGER)\n    -\u003efilterable(field: 'status', operators: [Operator::EQUAL, Operator::IN], allowedValues: ['paid', 'pending']);\n\n# GET /v1/orders?filter=status==paid;total=ge=100\u0026sort=-created_at,id\u0026page[number]=3\u0026page[size]=20\n/** @var ServerRequestInterface $request */\n$criteria = Criteria::fromQuery(schema: $schema, request: $request);\n```\n\nFor an endpoint that declares no contract, `Criteria::fromQueryWithDefaultSchema` is the request-only entry point. It\napplies the default schema, an empty contract: the default page-size bounds, no filterable or sortable field, and no\ndefault sort. Any incoming filter or sort is then rejected. Both `Offset\\Criteria` and `Cursor\\Criteria` expose it, so\neither pagination parses a request without building a schema.\n\n| Setting          | Default | Meaning                                         |\n|------------------|---------|-------------------------------------------------|\n| `defaultPerPage` | `20`    | The page size applied when the query omits one. |\n| `maxPerPage`     | `100`   | The maximum allowed page size.                  |\n\nA page size above `maxPerPage` raises `PageSizeOutOfRange`.\n\n### Filtering with RSQL\n\nThe `filter` parameter is an RSQL expression. It is validated against the schema at parse and flattened into the\nconjunction of comparisons the consumer applies to its store. `Criteria::comparisons()` returns that validated\n`list\u003cComparison\u003e`, empty when there is no filter.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse TinyBlocks\\HttpQuery\\Offset\\Criteria;\nuse TinyBlocks\\HttpQuery\\Operator;\n\n# filter=status==paid;total=ge=100  -\u003e  a validated list\u003cComparison\u003e.\n/** @var Criteria $criteria */\nforeach ($criteria-\u003ecomparisons() as $comparison) {\n    $comparison-\u003efield();                                 # 'status', then 'total'.\n    $comparison-\u003evalues();                                # ['paid'], then ['100'].\n    $comparison-\u003efirstValue();                            # The first compared value, 'paid'.\n    $comparison-\u003ehasField(field: 'status');               # True for the status leaf.\n    $comparison-\u003ehasOperator(operator: Operator::EQUAL);  # True for an equality leaf.\n}\n```\n\nThe supported operators map to their RSQL tokens through the `Operator` enum (`==`, `!=`, `=lt=`, `=gt=`, `=le=`,\n`=ge=`, `=in=`, `=out=`, `=sw=`). The logical connectives map through the `LogicalOperator` enum, where `;` (AND)\nbinds tighter than `,` (OR), and parentheses group. By default, the filter must be a single comparison or an AND group\nof comparisons, and any other shape raises `FilterShapeNotSupported`. A schema that calls `allowDisjunction`\naccepts OR groups and nested groups, validates every leaf the same way, and the consumer then reads the full tree from\n`Criteria::filter()` to render it. A malformed expression raises `FilterExpressionIsInvalid`, and anything outside the\ncontract raises a dedicated exception, each implementing `HttpQueryException`.\n\n| Exception                   | Raised when                                                                       |\n|-----------------------------|-----------------------------------------------------------------------------------|\n| `FilterExpressionIsInvalid` | The filter expression cannot be parsed as RSQL.                                   |\n| `FilterShapeNotSupported`   | The filter is an OR group or a nested group and the schema disallows disjunction. |\n| `FilterFieldNotAllowed`     | A comparison targets a field that was never declared filterable.                  |\n| `FilterOperatorNotAllowed`  | A comparison uses an operator not allowed for its field.                          |\n| `FilterValueNotAllowed`     | A compared value falls outside the permitted set or the expected kind.            |\n\n### Prefix matching with `=sw=`\n\n`=sw=` compares the value as a **literal prefix** of the column. It renders as an anchored `LIKE` so a B-tree index on\nthe column is still usable:\n\n```sql\ncli.name LIKE :filter_0 ESCAPE '!'\n```\n\nOnly the trailing `%` is added by the library. Every `%`, `_`, and `!` inside the value is escaped before binding, so a\nsearch for `100%` matches a name starting with the literal `100%` instead of expanding into a wildcard. There is no\nunanchored counterpart (contains, ends with) by design: an unanchored comparison cannot use the index and degrades the\nlisting into a table scan as the data grows.\n\nThe escape character is `!` rather than the customary backslash on purpose. A backslash has to be written `'\\\\'` in a\nSQL literal under the default MySQL mode and `'\\'` under `NO_BACKSLASH_ESCAPES`, so no single spelling is valid in both.\nUnder `NO_BACKSLASH_ESCAPES` the backslash form fails with `Incorrect arguments to ESCAPE`. A `!` needs no quoting in\neither mode, so the rendered predicate is independent of the server configuration.\n\n**Case and accent sensitivity come from the column collation, not from this library.** The rendered SQL carries no\n`COLLATE`, so a column declared `utf8mb4_0900_ai_ci` (accent-insensitive, case-insensitive) makes `jose` match `José`,\nwhile a binary collation matches exactly. Declare the collation the endpoint promises.\n\n`ValueKind` is the kind a value is validated against. For the multivalued operators (`=in=`, `=out=`) every value is\nchecked.\n\n| Kind                  | Matches                                                |\n|-----------------------|--------------------------------------------------------|\n| `ValueKind::STRING`   | A non-empty string.                                    |\n| `ValueKind::INTEGER`  | An optionally signed sequence of digits, `-7` or `42`. |\n| `ValueKind::DATETIME` | An ISO-8601 date or date-time, `2023-01-15T10:30:00Z`. |\n\n### Sorting\n\nThe `sort` parameter is a comma-separated list of fields, where a leading minus marks descending order, following the\nJSON:API convention. `Criteria::sort()` returns the effective `Sort`: the client sort when present, validated so every\nfield is declared `sortable`, otherwise the schema `defaultSort`. An order by an undeclared field raises\n`SortFieldNotAllowed`, and a malformed expression raises `SortExpressionIsInvalid`.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse TinyBlocks\\HttpQuery\\Direction;\nuse TinyBlocks\\HttpQuery\\Offset\\Criteria;\n\n# sort=-created_at,id  -\u003e  the effective Sort, ordered as requested.\n/** @var Criteria $criteria */\nforeach ($criteria-\u003esort()-\u003eorders() as $order) {\n    $order-\u003efield();                               # 'created_at' then 'id'.\n    $order-\u003edirection() === Direction::DESCENDING; # true then false.\n}\n```\n\n### Offset pagination\n\n`Offset\\Criteria::page` builds an `Offset\\Page` from the total element count and the items of the current page. The page\nderives the offset and the total pages from the request, so the consumer never builds the pagination itself. The items\nare any `iterable`, and `items()` returns them as a `Collection`.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse TinyBlocks\\HttpQuery\\Offset\\Criteria;\nuse TinyBlocks\\HttpQuery\\Schema;\n\n# GET /v1/orders?page[number]=3\u0026page[size]=20\n/** @var ServerRequestInterface $request */\n$criteria = Criteria::fromQuery(schema: Schema::create(), request: $request);\n\n/** @var iterable\u003cmixed\u003e $items */\n$page = $criteria-\u003epage(total: 480, items: $items);\n\n$page-\u003ehasNext();     # true\n$page-\u003emetadata();    # The JSON:API meta contents.\n$page-\u003etotalPages();  # 24\n```\n\nUse `Offset\\Criteria::slice` instead of `page` when the total is unknown. The consumer fetches one element beyond the\npage size, and the `Offset\\Slice` trims it and reads its presence as the next-page hint.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse TinyBlocks\\HttpQuery\\Offset\\Criteria;\nuse TinyBlocks\\HttpQuery\\Schema;\n\n# GET /v1/orders?page[number]=2\u0026page[size]=20\n/** @var ServerRequestInterface $request */\n$criteria = Criteria::fromQuery(schema: Schema::create(), request: $request);\n\n/** @var iterable\u003cmixed\u003e $items */\n$slice = $criteria-\u003eslice(items: $items);\n\n$slice-\u003ehasNext(); # Inferred from the extra fetched element.\n```\n\n### Cursor pagination\n\nCursor pages are forward-only. `Cursor\\Criteria::keyset` pairs the effective sort with the cursor view, exposing the\nseek inputs the consumer needs before fetching: the page size, the orders, and the incoming cursor key values keyed by\nsort field. A keyset needs a deterministic order, so the schema must declare a `defaultSort` or the client must sort,\notherwise `keyset` raises `SortIsRequired`. A `Token` is an opaque, URI-safe value wrapping the last-seen ordering key\nvalues, encoded as URL-safe base64.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse TinyBlocks\\HttpQuery\\Cursor\\Criteria;\nuse TinyBlocks\\HttpQuery\\Schema;\n\n$schema = Schema::create()-\u003esortable(fields: ['created_at', 'id']);\n\n# GET /v1/orders?sort=-created_at,id\u0026page[cursor]=BS3RvKY4LqEjYD19mQ0mCpJ\u0026page[size]=20\n/** @var ServerRequestInterface $request */\n$keyset = Criteria::fromQuery(schema: $schema, request: $request)-\u003ekeyset();\n\n$keyset-\u003elimit()-\u003etoInteger();  # The page size, 20.\n$keyset-\u003eorders();              # The list\u003cOrder\u003e the seek is ordered by.\n$keyset-\u003ecursor();              # ['created_at' =\u003e ..., 'id' =\u003e ...], null per field on the first page.\n```\n\nThe seek inputs feed the store query. The consumer fetches one element beyond the page size and hands the rows to\n`page`, which trims the extra element and reads its presence as the next-page hint. The ordering keys default to the\nsort fields read from each array-shaped row, so the cursor keys come from the source rows. Pass an explicit `keysOf` to\nextract them differently.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse TinyBlocks\\HttpQuery\\Cursor\\Keyset;\n\n/** @var Keyset $keyset */\n/** @var iterable\u003carray{id: int, created_at: string}\u003e $items */\n$cursorPage = $keyset-\u003epage(items: $items);\n\n$cursorPage-\u003enext();    # The Cursor\\Pagination for the next page, or null.\n$cursorPage-\u003ehasNext(); # Inferred from the extra fetched element.\n```\n\n`map` projects the items for rendering while preserving the cursor, so raw rows drive the ordering keys and a view is\nrendered from the same page.\n\n```php\n$cursorPage-\u003emap(transformation: static fn(array $row): array =\u003e ['id' =\u003e $row['id']]);\n```\n\nAn invalid cursor token raises `CursorIsInvalid` when it is decoded.\n\n### Building the store query\n\nThe library decides what to fetch and hands the consumer typed SQL fragments to apply against its own store. It builds\nno statement: the `SELECT`, the `FROM`, the joins, the `LIMIT`, and the dialect stay with the consumer (or its query\nbuilder). Every fragment is a `Clause\\SqlClause` exposing `sql()`, `parameters()`, and `isEmpty()`, so the consumer\nprograms the `WHERE` assembly against one abstraction.\n\n`Clause\\FilterColumns` maps each queryable field to its column. It is an immutable fluent builder, with `plain`,\n`boolean`, and `wrapped` shortcuts, and `with` for a custom `FilterColumn`. The same mapping feeds the filter, the seek,\nand the sort.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse TinyBlocks\\HttpQuery\\Clause\\Every;\nuse TinyBlocks\\HttpQuery\\Clause\\FilterColumns;\nuse TinyBlocks\\HttpQuery\\Clause\\Filters;\nuse TinyBlocks\\HttpQuery\\Clause\\SeekClause;\nuse TinyBlocks\\HttpQuery\\Clause\\SortClause;\nuse TinyBlocks\\HttpQuery\\Comparison;\nuse TinyBlocks\\HttpQuery\\Cursor\\Keyset;\n\n/** @var Keyset $keyset */\n/** @var list\u003cComparison\u003e $comparisons The validated comparisons read from Criteria::comparisons(). */\n$columns = FilterColumns::create()\n    -\u003eplain(field: 'status', column: 'pay.status')\n    -\u003eplain(field: 'created_at', column: 'pay.created_at')\n    -\u003ewrapped(field: 'id', column: 'pay.id', binding: 'UUID_TO_BIN(%s)');\n\n# Filters renders the comparisons, SeekClause renders the keyset predicate, both SqlClause.\n$predicate = Every::of(\n    Filters::from(columns: $columns, comparisons: $comparisons),\n    SeekClause::from(keyset: $keyset, columns: $columns)\n);\n\n$sort = SortClause::from(orders: $keyset-\u003eorders(), columns: $columns);\n$limit = $keyset-\u003elimit()-\u003eplusOne();\n\n$where = $predicate-\u003eisEmpty() ? '' : sprintf(' WHERE %s', $predicate-\u003esql());\n$sql = sprintf('%s%s ORDER BY %s LIMIT %d', $base, $where, $sort-\u003esql(), $limit-\u003etoInteger());\n# Bind $predicate-\u003eparameters() and run $sql against your store.\n```\n\n`Clause\\Every::of` combines several `SqlClause` predicates, dropping the empty ones, joining the rest with `AND`, and\nmerging their parameters. It is itself a `SqlClause`, so a consumer-owned predicate (for example a tenant scope) drops\ninto the same combination by implementing `SqlClause`.\n\n`Filters::from` renders a flat list of comparisons joined with `AND`, which fits the default conjunction-only contract.\nWhen the schema calls `allowDisjunction`, the filter may be an OR group or a nested group, so the consumer renders the\nfull tree with `Filters::fromTree(filter: $criteria-\u003efilter(), columns: $columns)` instead. It walks the tree, joins\neach group by its own connective, wraps every group in parentheses, and threads the placeholder offsets across the whole\ntree.\n\n`Limit` is the page size value object. `plus` raises it by an amount and `plusOne` raises it by one, the extra row a\nkeyset page fetches to detect a next page. `toInteger` reads the value back.\n\n`Filters::from` renders each comparison with the built-in operator mapping (`==`, `!=`, `=in=`, `=out=`, `=lt=`, `=gt=`,\n`=le=`, `=ge=`, `=sw=`). To render a shape the built-in mapping does not cover, pass a `Clause\\OperatorRenderer`. The\nfirst renderer that `supports` a comparison's operator renders it, otherwise the built-in mapping does.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse TinyBlocks\\HttpQuery\\Clause\\FilterColumn;\nuse TinyBlocks\\HttpQuery\\Clause\\Fragment;\nuse TinyBlocks\\HttpQuery\\Clause\\OperatorRenderer;\nuse TinyBlocks\\HttpQuery\\Comparison;\nuse TinyBlocks\\HttpQuery\\Operator;\n\nfinal readonly class CaseInsensitiveContains implements OperatorRenderer\n{\n    public function render(FilterColumn $column, int $offset, Comparison $comparison): Fragment\n    {\n        $name = sprintf('filter_%d', $offset);\n        $bind = sprintf($column-\u003ebinding(), sprintf(':%s', $name));\n\n        return Fragment::of(\n            sql: sprintf('LOWER(%s) LIKE LOWER(%s)', $column-\u003ecolumn(), $bind),\n            parameters: [$name =\u003e sprintf('%%%s%%', $comparison-\u003efirstValue())]\n        );\n    }\n\n    public function supports(Operator $operator): bool\n    {\n        return $operator === Operator::EQUAL;\n    }\n}\n\n# Filters::from($columns, $comparisons, new CaseInsensitiveContains());\n```\n\nA custom renderer reuses an existing RSQL operator token (the grammar the `Scanner` accepts is fixed), so a comparison\nthe parser already produces is rendered differently. It does not add a new token to the filter grammar.\n\n### Rendering navigation links\n\n`toResponse` renders a result as a JSON:API response in one call. It builds the body from the data, the meta, and the\nnavigation links, and folds the same relations into an RFC 8288 `Link` header. It returns a PSR-7 `ResponseInterface`.\nThe submitted filter and sort are preserved in every URI, and the `self` link is built from the page's own current\npagination, so an offset page renders an offset `self` and a cursor page renders a cursor `self`.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse TinyBlocks\\HttpQuery\\Offset\\Criteria;\nuse TinyBlocks\\HttpQuery\\Schema;\n\n$schema = Schema::create()-\u003esortable(fields: ['created_at', 'id']);\n\n# GET /v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=3\u0026page[size]=20\n/** @var ServerRequestInterface $request */\n$criteria = Criteria::fromQuery(schema: $schema, request: $request);\n\n/** @var iterable\u003cmixed\u003e $items */\n$response = $criteria-\u003epage(total: 480, items: $items)-\u003etoResponse(baseUri: '/v1/orders');\n```\n\nThe body carries the data, the meta, and the links, with the filter and the sort preserved in every URI. The two\napproaches return different shapes, shown below.\n\nAn **offset** page exposes the full window (`total`, `total_pages`, `current_page`, `has_previous`) and a link for every\nrelation:\n\n```json\n{\n    \"data\": [\n        {\n            \"id\": 4821,\n            \"status\": \"paid\",\n            \"total\": 12990,\n            \"created_at\": \"2026-01-29T14:05:00Z\"\n        },\n        {\n            \"id\": 4820,\n            \"status\": \"paid\",\n            \"total\": 8400,\n            \"created_at\": \"2026-01-29T13:51:00Z\"\n        }\n    ],\n    \"meta\": {\n        \"total\": 480,\n        \"per_page\": 20,\n        \"total_pages\": 24,\n        \"current_page\": 3,\n        \"has_next\": true,\n        \"has_previous\": true\n    },\n    \"links\": {\n        \"self\": \"/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=3\u0026page[size]=20\",\n        \"first\": \"/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=1\u0026page[size]=20\",\n        \"prev\": \"/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=2\u0026page[size]=20\",\n        \"next\": \"/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=4\u0026page[size]=20\",\n        \"last\": \"/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=24\u0026page[size]=20\"\n    }\n}\n```\n\n```text\nLink: \u003c/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=3\u0026page[size]=20\u003e; rel=\"self\",\n      \u003c/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=1\u0026page[size]=20\u003e; rel=\"first\",\n      \u003c/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=2\u0026page[size]=20\u003e; rel=\"prev\",\n      \u003c/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=4\u0026page[size]=20\u003e; rel=\"next\",\n      \u003c/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[number]=24\u0026page[size]=20\u003e; rel=\"last\"\n```\n\nA **cursor** page is forward-only: `meta` carries only `has_next` and `per_page`, and `links` only `self` and `next`.\nFor `GET /v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[cursor]=BS3RvKY4LqEjYD19mQ0mCpJ\u0026page[size]=20`:\n\n```json\n{\n    \"data\": [\n        {\n            \"id\": 4821,\n            \"status\": \"paid\",\n            \"total\": 12990,\n            \"created_at\": \"2026-01-29T14:05:00Z\"\n        },\n        {\n            \"id\": 4820,\n            \"status\": \"paid\",\n            \"total\": 8400,\n            \"created_at\": \"2026-01-29T13:51:00Z\"\n        }\n    ],\n    \"meta\": {\n        \"per_page\": 20,\n        \"has_next\": true\n    },\n    \"links\": {\n        \"self\": \"/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[cursor]=BS3RvKY4LqEjYD19mQ0mCpJ\u0026page[size]=20\",\n        \"next\": \"/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[cursor]=Pj9rZ0sB2xN7wK1dQmvY4La\u0026page[size]=20\"\n    }\n}\n```\n\n```text\nLink: \u003c/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[cursor]=BS3RvKY4LqEjYD19mQ0mCpJ\u0026page[size]=20\u003e; rel=\"self\",\n      \u003c/v1/orders?filter=status==paid\u0026sort=-created_at,id\u0026page[cursor]=Pj9rZ0sB2xN7wK1dQmvY4La\u0026page[size]=20\u003e; rel=\"next\"\n```\n\nThe `links` keys are the canonical JSON:API relations, in the semantic order below. Unavailable relations are omitted,\nso the first offset page carries no `prev` and the last carries no `next`, and an `Offset\\Slice` omits `last` (it has no\ntotal).\n\n| Relation | Meaning            |\n|----------|--------------------|\n| `self`   | The current page.  |\n| `first`  | The first page.    |\n| `prev`   | The previous page. |\n| `next`   | The next page.     |\n| `last`   | The last page.     |\n\n#### Adding your own meta contents\n\nA page derives its own `meta` from the pagination. When the endpoint owns a counter the page cannot derive, an unread\ntotal for example, `withMetadata` returns a copy carrying it. The value renders inside `meta`, so the response keeps the\nsingle JSON:API envelope and the RFC 8288 `Link` header. Both pagination approaches expose it.\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse TinyBlocks\\HttpQuery\\Cursor\\Keyset;\n\n/** @var Keyset $keyset */\n/** @var iterable\u003carray{id: int, created_at: string}\u003e $items */\n$response = $keyset-\u003epage(items: $items)\n    -\u003ewithMetadata(metadata: ['unread_count' =\u003e 7])\n    -\u003etoResponse(baseUri: '/v1/notifications');\n```\n\n```json\n{\n    \"meta\": {\n        \"per_page\": 20,\n        \"has_next\": true,\n        \"unread_count\": 7\n    }\n}\n```\n\nThe pagination entries come first, and the supplied ones follow in the order they were given. A supplied key that\nrepeats a pagination key is discarded, so `withMetadata(metadata: ['per_page' =\u003e 99])` leaves `per_page` on the real\npage size. Calling it more than once accumulates.\n\n## FAQ\n\n### 01. Why does the library never touch a data store?\n\nThe query of a collection has two halves: deciding what to fetch, and fetching it. This library owns only the first\nhalf. It parses the request into typed specifications and renders the response navigation, leaving the consumer free to\napply the specifications to any store, SQL, a document database, or an in-memory list. Keeping the store out makes every\noperation pure value-object math and keeps the library framework- and database-agnostic.\n\n### 02. Why is the query validated at parse instead of by the consumer?\n\nA filter or a sort enters the system at parse, so that is where it is validated. The endpoint declares the contract once\non the `Schema`, and `Criteria::fromQuery` returns an already-validated result. The consumer reads `comparisons()` and\n`sort()` without flattening a tree, enforcing an allowlist, or rendering an expression. A request outside the contract\nfails at the boundary with a dedicated `HttpQueryException`, never reaching the store.\n\n### 03. Why RSQL for filtering instead of ad-hoc query parameters?\n\nRSQL is a small, URI-safe grammar with a published reference, so the filter survives a query string without encoding and\nthe same expression reads the same on the client and the server. The library is conjunction-only for the consumer: a\nsingle comparison or an AND group of comparisons flattens into the `list\u003cComparison\u003e` the consumer applies. An OR group,\nor a group nested inside another group, is rejected as an unsupported shape.\n\n\u003e Zdenek Jirutka, *RSQL / FIQL parser*.\n\n### 04. Why are the two pagination approaches split into separate contexts?\n\nThe pagination approach is a fixed decision of the endpoint, not a runtime property of a request. A single specification\nthat infers the approach at runtime renders an inconsistent `self` link: the first page of a cursor feed would carry an\noffset-style `self` while its `next` is cursor-style. Splitting the API into a `Cursor` context and an `Offset` context,\neach complete and approach-only, with the common building blocks at the root, makes every `self` link consistent with\nthe page's own approach.\n\n### 05. How does the library help guard against injection?\n\nField and operator names are validated against the `Schema` allowlist while parsing, so only declared identifiers ever\nreach your store. Comparison and cursor values are returned as data for you to bind as parameters, and the library\nbuilds no SQL. A cursor token is decoded only as a list of scalar values. Any unsafe character in the filter and sort\nechoed into the `links` object and the `Link` header is percent-encoded. Binding the values is still your\nresponsibility.\n\n## License\n\nHttp Query is licensed under [MIT](LICENSE).\n\n## Contributing\n\nPlease follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to\ncontribute to the project.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftiny-blocks%2Fhttp-query","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftiny-blocks%2Fhttp-query","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftiny-blocks%2Fhttp-query/lists"}