How-to › Describe your API

How to design one filter and sort vocabulary for all resources#

One filter and sort grammar for every list endpoint: a shared parser, a field declaration per resource, OpenAPI parameter components, and a CI check for drift.

Audience
Platform team
Level
intermediate
Topic
Model resources, relations and actions
Verified

/orders filters with status=open and sorts with sort=-created. The team that shipped /customers chose filter[country]=IE and sort=created&order=desc. A client that works against one collection gets a 400 from the other, and the generated SDK carries two parameter shapes for one idea. Every resource added to the API reopens the argument about which shape wins.

What you get

You will end up with one parser for filters and sorts, a per-resource list of the fields it accepts, and a CI check that fails a list operation redefining the shared parameters. This is for you if you set API guidelines for several teams.

Short answer

Write the filter and sort grammar once, as one parser that takes a resource declaration: which fields accept field=value, which accept field[gte]=value, and which fields sort. Every list endpoint calls that parser with its own declaration and answers anything else with a 400. Put sort and the range parameter in components.parameters, with style: deepObject for the bracketed one, reference them from every list operation, and fail CI when an operation redefines them.

You will need

Node 22 or later, and an OpenAPI 3.1 document with at least two list operations. Verified 2026-10-21 against Node 22.22.0. The sample uses Node’s built-in URLSearchParams and its test runner, installs nothing, and reads the OpenAPI document as JSON. It runs no HTTP server and no database, so the query plan in the pitfall is a model of one, not a measurement.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
GitHub sort and directionA short enum of sort keys per endpoint and one direction for the whole listTwo parameters that mean something only together, and one sort key per request, so a tie-break field has nowhere to goClients need -created,total, or the sort keys differ on every resource
Google AIP-160 filter stringsClients need OR, negation, and nesting in one expressionA parser and a validator for an EBNF grammar that you own, and a filter the SDK types as a stringA dozen flat fields cover every query your clients make
JSON:API filter and sortYou want sort=-created,title and bracketed filters with the sort semantics already written downBrackets that clients must percent-encode, and a filter family the specification leaves agnostic, so you still define the operatorsThe document is OpenAPI and your generators handle deepObject badly
OData $filter and $orderbyA data service whose clients are tools that already speak ODataA full expression language with functions and lambdas, so the server library decides what a client may ask for, and any property can be sortedA product API with a handful of known queries per resource
Stripe and Twilio operator-suffix parametersFlat, typed parameters that generators turn into named argumentsOne parameter per filterable field, so a resource with forty fields has forty parameters on its list callClients need to combine conditions with OR, or to filter on fields you have not enumerated

Flat parameters and a filter language sit at the two ends. Stripe’s created[gte] is typed and generator-friendly, but one request cannot say status=open OR status=paid. AIP-160 can say that and more, and in exchange the SDK parameter is a string, backed by a grammar you parse and validate yourself. JSON:API sits between them, with one grammar for sort and brackets for filters, and leaves the operators to you. The vocabulary below takes that middle: bracketed operators on a declared set of fields.

Write the grammar once and declare each resource against it

The grammar is three forms, and a resource says which of its fields take each. field=value is equality, on fields declared as an enum, an id, or a string. field[gte]=value is a range bound, on timestamp fields, with the four operators Stripe accepts on created.1 sort=-created,total is a comma-separated field list with a minus prefix for descending, the form JSON:API and the Zalando guidelines both specify.2

export function parseListQuery(resource, search) {
  const params = search instanceof URLSearchParams ? search : new URLSearchParams(search)
  const filter = []
  const seen = new Set()
  let sort = null
  for (const [key, raw] of params) {
    // One value per key: status=open&status=paid would ask for both at once and match nothing.
    if (seen.has(key)) {
      if (key === 'sort') throw new QueryError('sort is given twice; join the fields with commas instead')
      throw new QueryError(`"${key}" is given twice; a filter takes one value`)
    }
    seen.add(key)
    if (key === 'sort') {
      sort = parseSort(resource, raw)
      continue
    }
    filter.push(parseFilter(resource, key, raw))
  }
  return { filter, sort: sort ?? parseSort(resource, resource.defaultSort) }
}

function parseFilter(resource, key, raw) {
  const m = FILTER_KEY.exec(key)
  if (!m) throw new QueryError(`"${key}" is not a filter; the forms are field=value and field[op]=value`)
  const [, field, op] = m
  // An own property only: resource.filters.constructor is Object, which is not a declared field.
  const decl = Object.hasOwn(resource.filters, field) ? resource.filters[field] : undefined
  if (!decl) {
    throw new QueryError(`"${field}" is not filterable on ${resource.name}; filterable: ${list(Object.keys(resource.filters).sort())}`)
  }
  if (EQUALITY_TYPES.has(decl.type)) {
    if (op) throw new QueryError(`"${field}" takes field=value only; operators apply to timestamp fields`)
    if (decl.type === 'enum' && !decl.values.includes(raw)) {
      throw new QueryError(`"${raw}" is not a ${field} on ${resource.name}; one of: ${list(decl.values)}`)
    }
    return { field, op: 'eq', value: raw }
  }
  // A timestamp field takes a range bound: created[gte]=..., created[lt]=...
  if (!op) throw new QueryError(`"${field}" is a range field; use ${field}[gte], ${field}[gt], ${field}[lte] or ${field}[lt]`)
  if (!RANGE_OPERATORS.includes(op)) {
    throw new QueryError(`"${op}" is not an operator on ${field}; operators: ${list(RANGE_OPERATORS)}`)
  }
  if (!isInstant(raw)) {
    throw new QueryError(`"${raw}" is not an RFC 3339 instant in UTC for ${field}, such as 2026-09-01T00:00:00Z`)
  }
  return { field, op, value: raw }
}

The parser knows nothing about orders or customers. Each resource is a declaration: the fields that filter, their types, the fields that sort, and the order to apply when the client gives none. A QueryError carries a 400, and the handler wraps its message in an RFC 9457 problem document, so every list endpoint also fails in one shape.

export const orders = {
  name: 'orders',
  filters: {
    status: { type: 'enum', values: ['open', 'paid', 'cancelled'] },
    customer: { type: 'id' },
    created: { type: 'timestamp' },
  },
  sortable: ['created', 'total'],
  defaultSort: '-created',
}

export const customers = {
  name: 'customers',
  filters: {
    country: { type: 'string' },
    email: { type: 'string' },
    created: { type: 'timestamp' },
  },
  sortable: ['created', 'name'],
  defaultSort: '-created',
}

Run six requests through the parser, against both resources.

node demo.mjs
GET /orders?status=open&created[gte]=2026-09-01T00:00:00Z&sort=-created,total
  filter  status = open
  filter  created >= 2026-09-01T00:00:00Z
  sort    created desc, total asc
GET /customers?created[gte]=2026-09-01T00:00:00Z&sort=-created
  filter  created >= 2026-09-01T00:00:00Z
  sort    created desc
GET /customers?country=IE
  filter  country = IE
  sort    created desc  (default)
GET /customers?status=open
  400     "status" is not filterable on customers; filterable: country, created, email
GET /orders?created[between]=2026-09-01T00:00:00Z
  400     "between" is not an operator on created; operators: gt, gte, lt, lte
GET /orders?sort=total,-total
  400     "total" appears twice in sort

The first two requests share created[gte] and -created, and the two resources parse those parts to the same entries. The last three are the grammar refusing what the declaration did not allow: a field one resource has and the other lacks, an operator outside the four, and a field sorted twice. Two of the three refusals also name what would have been accepted, so the 400 teaches the vocabulary. JSON:API requires that 400 for a sort the server does not support,3 and this grammar extends the rule to filters.

Put the shared parameters in components and reference them

OpenAPI records the vocabulary as two entries under components.parameters, and every list operation points at them with $ref. sort is a string with a pattern for the grammar. created is an object with the four operator properties, serialized with style: deepObject, which the OpenAPI specification defines as created[gte]=... for an object with scalar properties and leaves undefined for anything nested.

    "parameters": {
      "sort": {
        "name": "sort",
        "in": "query",
        "description": "Comma-separated field names, each with an optional leading minus for descending order. The fields an operation accepts are listed in its x-sortable.",
        "schema": { "type": "string", "pattern": "^-?[a-z][a-z0-9_]*(,-?[a-z][a-z0-9_]*)*$" },
        "examples": { "newestFirst": { "value": "-created" }, "largestFirst": { "value": "-total,created" } }
      },
      "created": {
        "name": "created",
        "in": "query",
        "description": "Bounds on the creation time: created[gte], created[gt], created[lte], created[lt], in any combination.",
        "style": "deepObject",
        "explode": true,
        "schema": {
          "type": "object",
          "properties": {
            "gt": { "$ref": "#/components/schemas/Instant" },
            "gte": { "$ref": "#/components/schemas/Instant" },
            "lt": { "$ref": "#/components/schemas/Instant" },
            "lte": { "$ref": "#/components/schemas/Instant" }
          },
          "additionalProperties": false
        }
      }
    },

A $ref cannot be specialized per operation, so the fields an operation sorts by are listed beside it, in x-sortable. The equality filters are declared inline, one per field, because they differ per resource and each one is a plain scalar.

    "/orders": {
      "get": {
        "operationId": "listOrders",
        "summary": "List orders",
        "x-sortable": ["created", "total"],
        "parameters": [
          { "$ref": "#/components/parameters/sort" },
          { "$ref": "#/components/parameters/created" },
          { "name": "status", "in": "query", "schema": { "$ref": "#/components/schemas/OrderStatus" } },
          { "name": "customer", "in": "query", "schema": { "type": "string" } }
        ],

Run the check over the document.

node check-list-params.mjs openapi.json
openapi.json
  GET /orders      sort ref, created ref      sortable: created, total       filters: status, customer
  GET /customers   sort ref, created ref      sortable: created, name        filters: country, email
2 list operations, 0 problems

Both list operations reference both shared components. Every field in x-sortable and every inline filter is a property of the item the operation returns. The check finds that item by following the 200 response to its array, and the array to its item schema.

Fail the build when a list operation drifts

The check is what keeps the vocabulary consistent after the design review ends. It reads the document and finds every GET on a collection path. Three rules apply to each: the shared parameters arrive by $ref, every sortable field is a property of the item, and so is every inline filter.

export function checkDocument(doc) {
  const rows = []
  const problems = []
  for (const [path, item] of Object.entries(doc.paths)) {
    const op = item.get
    if (!op || !isCollection(path)) continue
    const params = op.parameters ?? []
    const refs = params.filter((p) => p.$ref).map((p) => p.$ref.split('/').pop())
    const inline = params.filter((p) => !p.$ref && (p.in ?? 'query') === 'query').map((p) => p.name)
    const sortable = op['x-sortable'] ?? []
    const { name, properties } = itemOf(doc, op)
    const where = `GET ${path}`

    for (const shared of SHARED) {
      if (inline.includes(shared)) problems.push(`${where}: ${shared} is declared inline instead of $ref #/components/parameters/${shared}`)
      else if (!refs.includes(shared)) problems.push(`${where}: does not reference #/components/parameters/${shared}`)
    }
    if (!sortable.length) problems.push(`${where}: no x-sortable, so sort accepts nothing`)
    for (const field of sortable) {
      if (!properties.includes(field)) {
        problems.push(`${where}: x-sortable names "${field}", which is not a property of ${name} (properties: ${properties.join(', ')})`)
      }
    }
    for (const filter of inline) {
      if (!SHARED.includes(filter) && !properties.includes(filter)) problems.push(`${where}: filter "${filter}" is not a property of ${name}`)
    }
    rows.push({ where, refs, inline, sortable, name })
  }
  return { rows, problems }
}

openapi-drift.json is the same document with an /invoices list added by someone who copied GitHub’s shape: sort as an inline enum, a separate direction parameter, and amount_due in x-sortable where the schema calls the field total.

node check-list-params.mjs openapi-drift.json
openapi-drift.json
  GET /orders      sort ref, created ref      sortable: created, total       filters: status, customer
  GET /customers   sort ref, created ref      sortable: created, name        filters: country, email
  GET /invoices    sort INLINE, created ref   sortable: created, amount_due  filters: direction, status
  ! GET /invoices: sort is declared inline instead of $ref #/components/parameters/sort
  ! GET /invoices: x-sortable names "amount_due", which is not a property of Invoice (properties: id, status, total, created)
  ! GET /invoices: filter "direction" is not a property of Invoice
3 list operations, 3 problems

The command exits 1, so it fails a pipeline. Each of the three findings is drift a reviewer skims past. The inline sort is a second grammar. direction reads to the check as a filter on a field that does not exist. amount_due is copied from Stripe’s invoice object, where it is a different amount from total.

Check it worked

node --test vocabulary.test.mjs
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 90.056972

The line to read is # pass 13. Two of the tests hold the design together. One parses the same query string against both resources and expects a deep-equal result. The other reads the x-sortable lists out of the document and expects them to equal the sortable arrays in resources.mjs, so the specification and the server cannot disagree about a field unnoticed.

When it goes wrong

The database sorts a column with no index because sort accepted any field name. The shortcut strips the minus and hands the name to the query, which works for every indexed column until the first request names a column without one. pitfall.mjs runs two requests through that shortcut and through the declared grammar, against a model of the orders table with four indexed columns.

node pitfall.mjs
GET /orders?sort=-created
  any field       200  index scan on created, one page costs one read
  declared only   200  index scan on created, one page costs one read
GET /orders?sort=-internal_notes
  any field       200  sort 2,400,000 rows on internal_notes with no index, on every page of every call
  declared only   400  "internal_notes" is not sortable on orders; sortable: created, total

The shortcut answers 200 to both and sorts the whole table for the second, on every page. The declared grammar answers 400 and names the two fields that sort. The declaration is the allowlist and the documentation at once, so adding an index and adding the field to sortable happen in the same change.

A generated client sends a nested bracket the server cannot read. filter[created][gte] reads well in a design document and has no wire form in OpenAPI. deepObject is defined for an object with scalar properties, and the specification leaves nested properties undefined, so each generator invents its own. Keep operators one level deep, as created[gte], with the field as the parameter name.

Two pages of one listing overlap because the list has no default order. Rows come back in storage order, which can change between requests, so a client walking the collection sees one row twice and misses another. The declaration carries defaultSort, and the parser applies it when the client sends no sort, so every page is ordered by the same key. Two orders created in the same instant can still trade places, so end the query’s ORDER BY on a unique column such as id.

When not to do this

Do not adopt a filter language to serve a dozen flat fields. AIP-160 and OData buy OR, negation, and functions. Each also buys you a parser, a validator, and a planner that has to bound what an arbitrary expression may ask the database to do.4 A product API whose clients run the same twenty queries is better served by naming those queries as parameters.

Do not let sort or a filter accept a field the declaration does not list, however convenient the pass-through is. That pitfall is the whole case.

Do not stretch this grammar to free text. A q parameter that matches words across fields wants relevance ranking and an index that lags writes. That is a search endpoint with a contract of its own, not a fourth filter form.

Keep pagination out of the declaration as well. Page size, cursors, and the Link header are a grammar of their own, and choose a pagination style for a list endpoint covers that choice.

Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed, run in the page’s code directory. The query planner in the pitfall is a model, a set of indexed columns and a row count, not a database. The vendor conventions in the table were read from the documentation each row links and were not exercised against a vendor’s API.

Footnotes

  1. Twilio splices the operator into the parameter name rather than into brackets. The Message resource filters sent messages with DateSent>=2008-01-02, and the paging links returned beside a page of results carry it as DateSent%3E=2008-01-02, a parameter whose name contains a greater-than sign. Stripe puts the same operator in brackets, created[gte], and the name stays a name. ↩︎ Back to text

  2. Three sort conventions disagree about where the direction goes. JSON:API puts a minus in front of the field. AIP-132 puts the word desc after it, separated by a space, and adds that redundant spaces are insignificant. GitHub puts the direction in a second parameter whose default is desc, so a GitHub list arrives newest first by default and the other two arrive ascending. ↩︎ Back to text

  3. JSON:API is the one specification in the table that says what to do with a sort field the server does not support: it MUST return 400 Bad Request. The same document shows fields[articles] and filter with bare square brackets, then says beside them that the brackets are shown without encoding for readability and should be percent-encoded in practice. ↩︎ Back to text

  4. AIP-160 notes that list filters have fuzzy matching characteristics, with support for result ranking and scoring. For deterministic evaluation it points developers at CEL, the Common Expression Language, which is a different language: its grammar writes AND as && and = as ==. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.