/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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| GitHub sort and direction | A short enum of sort keys per endpoint and one direction for the whole list | Two parameters that mean something only together, and one sort key per request, so a tie-break field has nowhere to go | Clients need -created,total, or the sort keys differ on every resource |
| Google AIP-160 filter strings | Clients need OR, negation, and nesting in one expression | A parser and a validator for an EBNF grammar that you own, and a filter the SDK types as a string | A dozen flat fields cover every query your clients make |
| JSON:API filter and sort | You want sort=-created,title and bracketed filters with the sort semantics already written down | Brackets that clients must percent-encode, and a filter family the specification leaves agnostic, so you still define the operators | The document is OpenAPI and your generators handle deepObject badly |
OData $filter and $orderby | A data service whose clients are tools that already speak OData | A full expression language with functions and lambdas, so the server library decides what a client may ask for, and any property can be sorted | A product API with a handful of known queries per resource |
| Stripe and Twilio operator-suffix parameters | Flat, typed parameters that generators turn into named arguments | One parameter per filterable field, so a resource with forty fields has forty parameters on its list call | Clients 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.
Related how-tos
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
-
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 asDateSent%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 -
Three sort conventions disagree about where the direction goes. JSON:API puts a minus in front of the field. AIP-132 puts the word
descafter it, separated by a space, and adds that redundant spaces are insignificant. GitHub puts the direction in a second parameter whose default isdesc, so a GitHub list arrives newest first by default and the other two arrive ascending. ↩︎ Back to text -
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]andfilterwith 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 -
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
ANDas&&and=as==. ↩︎ Back to text