14. Paging styles
Paging imposes no convention (A31). A paging style lists the semantic roles a list uses and maps each one to a location and a name of the contract’s choosing: a query parameter or a request header for the request roles, a body member, a response header or an RFC 8288 Link relation for the response roles. Styles live in the pagings catalogue; a format pack provides presets of them (section 15). Roles are core semantics: the core knows what each one means and who fills it, never what it is called.
# shared/base.yaml (excerpt)pagings: cursor: request: cursor: { query: cursor } limit: { query: limit } sort: { query: sort, style: signed } response: body: $items links: { next: $next } changes: request: since: { query: since } limit: { query: limit } response: body: { items: $items, next: $nextSince, complete: $complete }| Key | Meaning |
|---|---|
request |
request roles, each { query: <name> } or { header: <name> }; page also takes base (1 by default, or 0), sort takes style (signed by default) and, for style: split, direction |
response.meta |
members added to the format’s page: each maps a member name to a template over the response roles, and joins the $meta slot of the format’s success.page |
response.body |
instead of meta: the whole page body, replacing the format’s success.page body; a template whose slots are the response roles and $meta, with $items exactly once |
response.headers |
response headers, each a template (X-Total-Count: $total) |
response.links |
RFC 8288 relations written in one Link header, each mapped to a URL role (next: $next) |
absent |
as on a format; omit by default |
A style without body keeps the format’s page shape and adds to it, so the wrapper stays the format’s and a style survives a change of format. Under @seampact/pack-laravel, whose page shape is { data: $items, meta: $meta }:
# shared/base.yaml (excerpt)pagings: pages: request: page: { query: page } limit: { query: perPage } response: meta: { page: $page, lastPage: $totalPages, perPage: $limit, total: $total }A page of pages is { data: [...], meta: { page, lastPage, perPage, total } }, plus the format’s own meta fields when it declares some (a servedAt filled with now); a member named by both the format and the style is a compile error naming both. A style with meta under a format whose page shape writes no $meta, such as bare ($items), is a compile error naming the style and the format, with the fix “write response.body”. A style with body replaces the page body, as Hydra, HAL and JSON:API need; inside it, $meta holds the format’s meta fields only, and those fields appear in a page only where a template writes $meta. headers and links add to the page in both cases.
Request roles. A style has exactly one position role (cursor, page, offset or since), or the pair after and before, which sets its kind:
| Role | Meaning |
|---|---|
cursor |
an opaque position the handler issued |
after, before |
a cursor kind whose two directions travel in two parameters (Stripe’s starting_after and ending_before): records after the position, or before it. A request sends at most one of the two; both answer 400 api.schema_invalid with one issue at the before parameter, rule exclusiveCursors |
page |
a page number, counted from base |
offset |
the number of records to skip |
since |
a changes-since token: opaque, or a date-time with token: instant (SCN-082) |
limit |
the page size, from 1 to the route’s maxLimit. A larger value answers 400 (SCN-012), or, with overLimit: clamp, is served as maxLimit, which the limit response role then reports, as Spring Data, GitHub and API Platform do. A style without it serves defaultLimit records per page. Under clamp the handler receives the clamped limit. Known difference per family: an exponent form such as per_page=1e3 is read as 1000 on Node, and answers 400 on Spring, PHP (Laravel, Symfony) and Python (FastAPI), which read an integer query value only as plain digits |
sort |
the sort keys of sortable; style is signed (sort=-createdAt,title), pair (sort=createdAt,desc, repeated), keyed (the name holds {key}: order[createdAt]=desc) or split (one key, its direction asc or desc in the parameter direction names: sort=created&direction=desc) |
A pair item takes a direction in every item: sort=createdAt alone is refused with a 400, never read as ascending. A signed list splits on , and reads a leading - as descending, so a sort key whose name holds a , or starts with - is a compile error naming the key and the style, under the signed style only.
Response roles, each a slot of the body, a header or a link:
| Role | Value | Filled by | Kinds |
|---|---|---|---|
items |
the page’s records | handler | all |
nextCursor, previousCursor |
the cursors of the next and previous pages, absent at either end; with after and before, the values those parameters take |
handler | cursor |
more |
whether records follow this page (Stripe’s has_more, a hasNext flag) |
handler | all |
page, offset, limit |
the request’s position and the limit applied | boundary | page, offset; limit all |
from, to |
the one-based positions of the first and last record of the page | boundary | page, offset |
total |
the number of records of the whole list | handler | cursor, page, offset |
totalPages |
total divided by limit, rounded up |
boundary | page, offset |
self, first, previous, next, last |
page URLs: the request’s query with its position replaced, under urls: relative a query-only reference (?page=2) that resolves against the URL the client called, so it keeps a path prefix a reverse proxy stripped |
boundary | previous all but since, which has no previous token; last page and offset only |
nextSince, complete |
the token for the next call, and whether every change up to it is included | handler | since |
An empty list, a total of 0, counts no page: totalPages is 0, and last, as first, is the URL of the first page, the empty page the list answers.
The handler result is typed from the roles the style uses: the items, plus nextCursor, previousCursor, more, nextSince and complete when used, and total when the style uses total, totalPages or last, whether or not a template writes $total (a lastPage member without the total is that case). A page or offset style that needs no total also asks the handler for more, which drives next. A cursor style’s next and previous URLs need nextCursor and previousCursor, and a since style’s next needs nextSince, written or not. A server target refuses a page that writes a slot it does not fill ($resource, $collection, an item’s id or URL, or $page under an offset style) at generation (paging.response.slot.unsupported), and a page fact it does not generate, such as complete. A role used outside its kinds, a sort key on a style without a sort role, and a request name already used by the route’s query are compile errors naming the style. Two request roles of a style naming one parameter (a sort role’s direction parameter included) or one header, headers compared without regard to case, are a compile error naming the style. The route sets the limits (maxLimit, defaultLimit, overLimit), or they come from conventions.paging, each one on its own: a conventions.paging.defaultLimit above the route’s maxLimit is lowered to it, while a route’s defaultLimit above the maxLimit it gets, or a conventions.paging.defaultLimit above conventions.paging.maxLimit, is a compile error.
Two third-party styles show the roles at work, Stripe’s list object and GitHub’s pages:
# shared/base.yaml (excerpt)pagings: stripeList: request: after: { query: starting_after } before: { query: ending_before } limit: { query: limit } response: body: { object: list, data: $items, has_more: $more, url: $path } githubPages: request: page: { query: page } limit: { query: per_page } sort: { query: sort, style: split, direction: direction } response: body: $items links: { next: $next, prev: $previous, first: $first, last: $last }conventions: paging: { maxLimit: 100, overLimit: clamp }