Specification: GeoKrety Pagination and Sorting for JSON REST Collections¶
1. Introduction¶
This specification defines how GeoKrety paginates JSON collections inside the shared JSON REST envelope documented in ../json-rest/specification.md. It covers page-based pagination, cursor-based pagination, sorting, filtering, infinite-scroll client behavior, and the minimum validation rules needed for consistent implementation.
2. Purpose & Scope¶
Purpose¶
- Make paginated GeoKrety JSON collections consistent across endpoints
- Standardize page-based navigation for catalog-style lists
- Standardize cursor-based navigation for large collections and infinite scroll
- Keep sorting and filtering deterministic so clients can page safely
- Define link-driven client behavior instead of cursor-field-driven behavior
In Scope¶
- Page-based pagination with
pageandper_page - Cursor-based pagination with
limitand opaquecursor - Top-level pagination links in JSON REST collection responses
- Sorting and filtering rules that affect page identity
- Runtime self-documentation of active filters, active sort, and supported capabilities
- Infinite-scroll frontend behavior
- Backend and frontend implementation guidance
- Validation and testing expectations
Out of Scope¶
- Redefining the generic JSON REST resource-object model
- XML pagination behavior
- GraphQL pagination
- Bidirectional cursor navigation
3. Shared Collection Contract¶
Every paginated JSON collection response uses the shared top-level envelope:
{
"data": [],
"included": [],
"meta": {
"execution_time_ms": 0,
"page": {},
"query": {},
"capabilities": {}
},
"links": {
"self": "/api/v3/example"
}
}
Rules:
datacontains resource objects as defined in ../json-rest/specification.mdincludedis optional and carries side-loaded related resourcesmeta.pagedescribes the current pagination statemeta.queryechoes the effective filters and sortmeta.capabilitiesadvertises the supported filter and sort inputs for that collectionlinkscarries navigation URLs- collection navigation is link-driven, not field-driven
- canonical pagination examples must not use flat fields such as
meta.limit,meta.has_more,meta.sort, ormeta.filters
4. Endpoint Mode Selection and Migration Boundary¶
Each endpoint must document exactly one public pagination mode:
pagecursor
Rules:
- page-based endpoints accept
pageandper_page, and rejectlimitandcursor - cursor-based endpoints accept
limitand optionalcursor, and rejectpageandper_page - requests mixing pagination modes return
INVALID_PAGINATION_MODEwith HTTP 400 - OpenAPI must declare the supported mode for each endpoint explicitly
- when an endpoint supports filtering, the canonical parameter names are
filter[...] - when an endpoint supports sorting, it documents a single
sortparameter with a fixed whitelist
5. Page-Based Pagination¶
5.1 When to Use It¶
Use page-based pagination when:
- users expect numbered pages
- the result set is stable enough for page navigation
- exact total counts are meaningful for the UI
- direct jumps to page N are more important than append-only scrolling
5.2 Request Contract¶
Example request:
Parameters:
| Parameter | Type | Required | Notes |
|---|---|---|---|
page |
integer | No | 1-based page number, defaults to 1 |
per_page |
integer | No | Defaults to 20, maximum 100 |
sort |
string | No | Endpoint-defined whitelist, -field means descending |
filter[...] |
varies | No | Endpoint-specific filter parameters |
5.3 Response Contract¶
{
"data": [
{
"id": "101",
"type": "user",
"attributes": {
"username": "alice",
"status": "active",
"created_at": "2026-03-01T12:00:00Z"
},
"relationships": {
"profile": {
"data": {
"type": "profile",
"id": "501"
},
"links": {
"related": "/api/v1/profiles/501"
}
}
},
"links": {
"self": "/api/v1/users/101"
}
},
{
"id": "102",
"type": "user",
"attributes": {
"username": "bob",
"status": "active",
"created_at": "2026-03-02T08:30:00Z"
},
"links": {
"self": "/api/v1/users/102"
}
}
],
"meta": {
"execution_time_ms": 14,
"page": {
"type": "page",
"number": 2,
"size": 20,
"total_items": 50,
"total_pages": 3
},
"query": {
"filters": {
"status": "active"
},
"sort": "-created_at"
},
"capabilities": {
"filters": {
"status": {
"type": "string"
}
},
"sorts": ["created_at", "-created_at", "username", "-username"]
}
},
"links": {
"self": "/api/v3/example?page=2&per_page=20&filter[status]=active&sort=-created_at",
"first": "/api/v3/example?page=1&per_page=20&filter[status]=active&sort=-created_at",
"prev": "/api/v3/example?page=1&per_page=20&filter[status]=active&sort=-created_at",
"next": "/api/v3/example?page=3&per_page=20&filter[status]=active&sort=-created_at",
"last": "/api/v3/example?page=3&per_page=20&filter[status]=active&sort=-created_at"
}
}
Rules:
links.selfmatches the normalized request URLlinks.previs omitted on the first pagelinks.nextis omitted on the last pagemeta.page.total_itemsandmeta.page.total_pagesare part of the external page-based contract- backends may still compute the page internally with
LIMITandOFFSET, but the public contract remains page-based
Validation behavior:
page < 1returnsINVALID_PAGEwith HTTP 400- missing or too-small
per_pageresolves to the server default of20 per_page > 100returnsLIMIT_EXCEEDEDwith HTTP 400- a first-page request against an empty collection returns HTTP 200 with
data: [],meta.page.number: 1,meta.page.total_items: 0,meta.page.total_pages: 1, andlinks.self,links.first, andlinks.lastall pointing to the normalized first-page URL - a request for
page > 1against an empty collection returnsOUT_OF_BOUNDSwith HTTP 400 - a request for
page > total_pageson a non-empty collection returnsOUT_OF_BOUNDSwith HTTP 400
6. Cursor-Based Pagination¶
6.1 When to Use It¶
Use cursor-based pagination when:
- the collection can grow very large
- the UI is append-only or infinite-scroll oriented
- new rows may appear while the client is paging
- deep page jumps are not required
6.2 Request Contract¶
Example request:
GET /api/v3/moves?limit=20&cursor=eyJkYXRlIjoiMjAyNi0wMy0yOFQwOTowMDowMFoiLCJpZCI6ODQxMzM2fQ==&filter[country]=PL&sort=-date
Parameters:
| Parameter | Type | Required | Notes |
|---|---|---|---|
limit |
integer | No | Defaults to 20, maximum 100 |
cursor |
string | No | Opaque, versioned token for the next window |
sort |
string | No | Endpoint-defined whitelist |
filter[...] |
varies | No | Endpoint-specific filter parameters |
6.3 Response Contract¶
{
"data": [
{
"id": "201",
"type": "move",
"attributes": {
"lat": 48.8566,
"lon": 2.3522,
"date": "2026-03-28T10:00:00Z"
}
},
{
"id": "200",
"type": "move",
"attributes": {
"lat": 51.5074,
"lon": -0.1278,
"date": "2026-03-28T09:58:00Z"
}
}
],
"meta": {
"execution_time_ms": 9,
"page": {
"type": "cursor",
"limit": 20,
"has_more": true
},
"query": {
"filters": {
"country": "PL"
},
"sort": "-date"
},
"capabilities": {
"filters": {
"country": {
"type": "string"
},
"geokret": {
"type": "string"
},
"user": {
"type": "integer"
}
},
"sorts": ["date", "-date", "id", "-id"]
}
},
"links": {
"self": "/api/v3/moves?limit=20&filter[country]=PL&sort=-date",
"next": "/api/v3/moves?limit=20&cursor=eyJkYXRlIjoiMjAyNi0wMy0yOFQwOTo1ODowMFoiLCJpZCI6MjAwfQ==&filter[country]=PL&sort=-date"
}
}
Rules:
links.nextis present only whenmeta.page.has_moreistrue- the cursor remains opaque to clients even if its implementation is a base64-encoded JSON payload
- the response body does not expose
nextCursoras a canonical field - cursor pagination is forward-only in this specification
- missing or too-small
limitresolves to the server default of20 limit > 100returnsLIMIT_EXCEEDEDwith HTTP 400
7. Infinite Scroll Contract¶
The frontend contract for infinite-scroll collections is:
- Request the first page without a cursor.
- Append
datato the current list. - If
meta.page.has_moreis true, calllinks.next. - Repeat until
links.nextis absent ormeta.has_moreis false.
Minimal pseudocode:
8. Sorting and Filtering¶
8.1 Sort Syntax¶
Canonical sort syntax is:
sort=-datesort=-last_move_atsort=name
Rules:
- a leading
-means descending order - no prefix means ascending order
- each endpoint exposes an explicit whitelist
- the response echoes the effective sort in
meta.query.sort - the response advertises the supported sort list in
meta.capabilities.sorts
8.2 Deterministic Sort Requirement¶
Cursor-based pagination requires a stable sort order, for example:
When a client omits the sort parameter, the server applies the endpoint default and echoes it in meta.query.sort.
Reject these cases:
- unknown sort fields with
INVALID_SORT_FIELD
Avoid non-deterministic or mutable primary ordering such as:
ORDER BY RANDOM()ORDER BY moved_at DESCwithout a unique tie-breakerORDER BY updated_at DESCwhenupdated_atcan change during pagination
8.3 Filter and Sort Reset Behavior¶
- page-based navigation must reset to
page=1when filters or sort change - cursor-based navigation must restart from the first request when filters or sort change
- stale cursors must be rejected if they no longer match the active sort or filter context
- servers should preserve active filters and sort expressions in every navigation link they generate
8.4 Sort Discovery¶
OpenAPI is the authoritative place to document allowed filter and sort fields. Collection responses must also expose the active and supported values at runtime through meta.query and meta.capabilities.
9. Security and Error Handling¶
Pagination security is based on:
- authorization checks on the underlying data
- parameterized SQL queries
- server-enforced page-size limits
Pagination security is not based on hiding cursor structure. Cursor opacity is a contract boundary, not a secrecy boundary.
Common errors:
INVALID_PAGINATION_MODEINVALID_PAGEOUT_OF_BOUNDSLIMIT_EXCEEDEDINVALID_CURSORCURSOR_VERSION_MISMATCHINVALID_SORT_FIELDSORT_COMPLEXITY_EXCEEDED
Cursor failure mapping:
- malformed cursor:
INVALID_CURSOR, HTTP 400 - cursor with unsupported version:
CURSOR_VERSION_MISMATCH, HTTP 400 - cursor whose filter or sort context no longer matches the request:
INVALID_CURSOR, HTTP 400
10. Backend and Frontend Implementation Guidance¶
10.1 Backend Guidance¶
- generate
links.selffrom the normalized request URL - generate page-based
first,prev,next, andlastlinks on the server - generate cursor-based
links.nextonly when more rows exist - preserve all active filters and normalized sort expressions in generated links
- echo the effective filter state in
meta.query.filters - echo the effective sort state in
meta.query.sort - advertise supported filters and sorts in
meta.capabilities - validate
page,per_page,limit,cursor, and sort expressions before querying data - page-based handlers may translate
pageandper_pageinto internalLIMITandOFFSET
10.2 Frontend Guidance¶
Frontend composables should store the current links.next URL rather than parsing cursor internals. A minimal state model is:
Behavior:
- call the first URL explicitly
- append returned
data - set
next_linkfromlinks.next - stop when
next_linkis absent ormeta.page.has_moreis false - reset local state when filters or sort change
11. Testing and Rollout Guidance¶
11.1 Contract Tests¶
- validate that page-based responses always include the expected top-level links
- validate that cursor-based responses omit
links.nexton the final page - validate first-page, middle-page, last-page, and empty-page behavior
- validate stale cursor rejection after filter or sort changes
- validate mixed-parameter rejection for unsupported pagination modes
- validate
meta.queryreflects the effective filter and sort state - validate
meta.capabilitieslists the supported filters and sorts for the endpoint - validate that generated links preserve filters and normalized sort expressions
11.2 Documentation Validation¶
- keep all canonical examples valid JSON
- ensure the pagination page links to the global JSON REST page
- ensure docs navigation includes the new JSON REST section
- ensure OpenAPI examples and top-level wording do not contradict this page
11.3 Operational Guidance¶
- verify stable-sort indexes before production rollout of cursor endpoints
- enforce
per_pageandlimitmaximums server-side - prefer cursor-based pagination for large and append-only collections
- use page-based pagination only where exact page navigation is part of the product requirement
- document legacy endpoints explicitly until they are migrated to this contract
12. Acceptance Criteria¶
- canonical paginated examples use top-level
data, optionalincluded,meta, andlinks - page-based examples use
meta.page.number,meta.page.size,meta.page.total_items, andmeta.page.total_pages - cursor-based examples use
meta.page.limit,meta.page.has_more, andlinks.next - collection examples echo active filters and sort through
meta.query - collection examples advertise supported filters and sorts through
meta.capabilities - infinite-scroll guidance tells clients to follow
links.nextinstead of parsing cursor fields from the response body - sort syntax is documented with a JSON REST-friendly expression such as
-date - each endpoint documents exactly one public pagination mode and rejects mixed-mode parameters
- stable sort, filter reset, cursor-versioning, and rollout-boundary guidance remain documented
- the page references the shared JSON REST envelope instead of redefining it from scratch
13. Implementation Checklist¶
- Rewrite canonical examples to use nested
meta.page,meta.query, andmeta.capabilities - Use
filter[...]as the canonical documentation form for list filters - Keep cursor-based examples link-driven and forward-only
- Define endpoint mode selection and mixed-parameter rejection
- Preserve stable sorting, filter-reset, rollout-boundary, and validation guidance
- Require runtime self-documentation of active query state and supported capabilities
- Link this page to the global JSON REST API specification