Pagination and filtering

The page envelope your list capabilities answer with, the parameters that drive it, and why an undeclared parameter strands every caller on page one.

Every list your app publishes should page, and every list that pages should say so in its contract. This page is about the gap between those two sentences, which is where the single most common app bug lives.

The envelope

A paged list answers with the rows, the paging state, and an echo of the applied filter:

Response
{
  "items": [ /* rows */ ],
  "page": {
    "limit": 50,
    "offset": 0,
    "total": 128,
    "returned": 50,
    "hasMore": true
  },
  "filter": {
    "status": "in_stock"
  }
}
FieldIs
itemsThe rows for this page.
page.limitThe page size actually used — the clamped value, not necessarily what you asked for.
page.offsetThe offset actually used.
page.totalHow many rows match the filter in total.
page.returnedHow many rows are in items.
page.hasMoreWhether another page exists.
filterThe filter the server applied.

Two of those are worth using rather than skimming past.

hasMore is the loop condition. Prefer it to comparing returned against limit, and prefer both to computing offset + limit < total — a total can move underneath you between pages.

filter is the debugging tool. It is what the server applied, not what you sent. A misspelled filter parameter is silently ignored by a list route, so the echo is how you discover that ?statu=in_stock did nothing. If a list returns more rows than you expected, read the echo before you read your code.

Declaring it

The gateway validates and forwards the parameters your capability declares, and only those.

manifest.capabilities.json
{
  "type": "define",
  "capability": "serials.list",
  "version": "1.0.0",
  "summary": "List device serials",
  "route": { "method": "GET", "path": "/serials" },
  "parameters": [
    { "name": "limit", "in": "query",
      "schema": { "type": "integer", "minimum": 1, "maximum": 200, "example": 50 },
      "description": "Page size (default 50, max 200). A larger value is clamped rather than refused." },
    { "name": "offset", "in": "query",
      "schema": { "type": "integer", "minimum": 0 },
      "description": "Row offset for pagination (default 0)." },
    { "name": "order", "in": "query",
      "schema": { "type": "string" },
      "description": "Sort as 'column.asc' | 'column.desc', e.g. 'created_at.desc'." },
    { "name": "status", "in": "query",
      "schema": { "type": "string", "enum": ["in_stock", "sold", "in_service"] },
      "description": "Only serials in this status." }
  ],
  "response": { "title": "SerialPage", "type": "object" }
}

An undeclared parameter strands callers on page one

This is the bug, stated plainly: if your list capability does not declare limit and offset, the gateway does not forward them. Your handler receives no offset, defaults it to 0, and answers page one. Every time. To every caller.

Nothing errors. The response is a valid 200 with a valid envelope. A partner writes the obvious paging loop, gets the same fifty rows forever, and concludes your app is broken — which it is, in the contract rather than in the code.

The same applies to every filter. A status parameter your handler happily reads is invisible to callers until it is declared, and the request that sends it gets an unfiltered list back.

So the rule for a paged list is: declare limit, offset, order, and every filter you intend anybody to use. And the counter-rule, equally worth keeping: declare them only on lists. A get, create, update or delete has no pagination, and declaring some anyway publishes a parameter that does nothing.

If a caller reports being stuck on page one, check the contract first:

Check the contract
curl -s https://api.revenexx.com/v1/openapi.json \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  | grep -A3 '"offset"'

Paging through everything

Client
async function* allSerials(headers, limit = 200) {
  for (let offset = 0; ; offset += limit) {
    const res = await fetch(`https://api.revenexx.com/v1/serials?limit=${limit}&offset=${offset}&order=id.asc`, { headers });
    const { items, page } = await res.json();
    yield* items;
    if (!page.hasMore) return;
  }
}

Always send an order. Offset paging over an unordered result is not stable: without a deterministic sort, a row can appear on two pages or on none, and the bug shows up only under concurrent writes. Order by something monotonic and unique — id.asc, or created_at.asc on a table where that is unique enough.

Filtering

Filters on a mountCrud list are per column and exact:

Request
curl "https://api.revenexx.com/v1/serials?status=in_stock&product_id=$PRODUCT_ID&limit=50" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..."
  • A ?column=value pair is equality on that column.
  • Several pairs are ANDed.
  • One filter per column — there is no OR, no negation, and no range expressed as two entries on the same column.
  • An unknown parameter is ignored rather than rejected.

That last point is why the filter echo exists, and it is also the reason to publish an enum in a filter's schema where the column has one: the API Explorer then offers the valid values instead of letting a caller guess.

When a caller needs a slice these shapes cannot express — a date range, an either-or, a computed threshold — the answer is a capability with its own parameters and its own logic, not a cleverer query string. See The typed client for what the underlying query surface can and cannot do.

Consistency with the rest of the gateway

limit, offset and order are the platform-wide list conventions, documented in API usage. Following them means a partner who has already integrated one revenexx endpoint does not have to learn yours. Inventing page=2&per_page=25 means they do.

Next steps

Was this page helpful?