# 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.

Source: https://voxgig.com/howto/consistent-filtering-and-sorting-parameters

- Audience: platform-team
- Level: intermediate
- Verified: 2026-10-21
- Published: 2026-10-18

## 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`](https://nodejs.org/api/url.html#class-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](https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues) | 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](https://google.aip.dev/160) | 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](https://jsonapi.org/format/#fetching-sorting) | 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`](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_SystemQueryOptionfilter) | 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](https://docs.stripe.com/api/charges/list) | 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](https://jsonapi.org/format/#fetching-sorting) and the
[Zalando guidelines](https://opensource.zalando.com/restful-api-guidelines/#137) both
specify.[^2]

```js title="vocabulary.mjs"
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](https://www.rfc-editor.org/rfc/rfc9457.html) problem document, so every list endpoint
also fails in one shape.

```js title="resources.mjs"
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.

```bash
node demo.mjs
```

```text output
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](https://spec.openapis.org/oas/v3.1.1.html#style-values)
as `created[gte]=...` for an object with scalar properties and leaves undefined for anything
nested.

```json title="openapi.json"
    "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.

```json title="openapi.json"
    "/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.

```bash
node check-list-params.mjs openapi.json
```

```text output
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.

```js title="check-list-params.mjs"
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`.

```bash
node check-list-params.mjs openapi-drift.json
```

```text output
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

```bash
node --test vocabulary.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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](/howto/choose-a-pagination-style-for-a-list-endpoint)
covers that choice.

## Related how-tos

- [Decide if an endpoint is an entity operation or an action](/howto/entity-operation-or-standalone-action)

- [Choose a pagination style for a list endpoint](/howto/choose-a-pagination-style-for-a-list-endpoint)

- [Describe pagination so generators can follow it](/howto/describe-pagination-in-openapi-for-generators)

- [Enforce a naming convention across 40 microservice specs](/howto/enforce-naming-conventions-across-many-specs)

- [Decide when a list filter should become a search endpoint](/howto/when-a-filter-becomes-a-search-endpoint)

## 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.

[^1]: Twilio splices the operator into the parameter name rather than into brackets. The
[Message resource](https://www.twilio.com/docs/messaging/api/message-resource#read-multiple-message-resources)
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.

[^2]: Three sort conventions disagree about where the direction goes. JSON:API
puts a minus in front of the field. [AIP-132](https://google.aip.dev/132) puts the word `desc`
after it, separated by a space, and adds that redundant spaces are insignificant.
[GitHub](https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues)
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.

[^3]: [JSON:API](https://jsonapi.org/format/#fetching-sorting) 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.

[^4]: [AIP-160](https://google.aip.dev/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](https://github.com/google/cel-spec/blob/master/doc/langdef.md),
which is a different language: its grammar writes `AND` as `&&` and `=` as `==`.