0043 - API standards for reading resources
| ID: | ADR-0043 |
|---|---|
| Status: | PROPOSED |
| Published: | 2026-10-05 |
Notation
This ADR uses RFC 2119 keywords (MUST, MUST NOT,
SHOULD, SHOULD NOT, MAY) deliberately.
Context and problem statement
ADR-0036 requires service-to-service APIs to conform to a documented set of standards, and ADR-0039 builds those standards on IETF RFCs. This ADR sets how resources are read: reading one and reading many, filtering, multi-value parameters and ranges, sorting, sparse fieldsets, paging, and advanced queries. Payload shape and errors are set by ADR-0040.
No RFC covers these topics, so the answers are ours. Each service declares exactly which fields can be queried, because every query it supports has to be indexed and authorized.
Considered options
- Filtering:
filter[{field}]query parameters (chosen); plainfield=valueparameters, which collide with other parameters; OData$filter; RQL or FIQL. - Multi-value parameters: a comma-delimited list with backslash escaping (chosen); repeated
parameters (
?status=A&status=B); bracket arrays (status[]=A); pipe-delimited values; percent-encoding instead of escaping. - Ranges: bracket notation within one parameter (chosen); separate minimum and maximum
parameters; operator suffixes such as
filter[age][gte]. - Sorting: one
sortparameter with a hyphen for descending (chosen); separatesortByandorderparameters. - Sparse fieldsets: one
fieldsparameter (chosen); JSON:API's per-typefields[TYPE]; field masks. - Paging: offset or cursor paging with
page[...]parameters and metadata inmeta(chosen);offsetandlimit; cursor paging only; a continuation token; RFC 8288Linkheaders; OData$topand$skip. - Advanced queries: a
:searchoperation with a JSON Logic body (chosen); OData$filter; Google AIP-160's filter grammar; MongoDB-style query objects; a search body defined by each API; no advanced queries.
Decision outcome
Chosen options are marked above. The parameter families filter, page, sort, and fields are
credited to JSON:API, the :search syntax to
Google AIP-136, and the query notation to
JSON Logic.
Reading one and reading many
- The method
MUSTbeGET. - The path
SHOULDbe/api/v1/{resource plural}to read many, and/api/v1/{resource plural}/{id}to read one. - On success, APIs
MUSTreturn200with the resource, or the array of resources, indata.
Filtering
- Read-many APIs that accept search criteria
MUSTtake them asfilter[{name}]query parameters, wherenameSHOULDbe a resource attribute. - APIs
MAYsupport wildcards. Those that doMUSTsupport*for "starts with", "ends with", and "contains". - APIs
MAYfilter on a nested attribute using dot notation, such asaddress.city.
Multi-value parameters and ranges
- A parameter that accepts several values
MUSTtake them as a comma-delimited list, andMUSTapply "or" semantics. A single-value parameter can therefore gain multi-value support without changing its contract. - On a parameter that accepts several values or a range, a comma that is part of a value
MUSTbe escaped with a backslash, sofilter[displayName]=Smith\, Jris the single valueSmith, Jr. An asteriskMUSTbe escaped likewise wherever wildcards are supported, and a backslash that is part of a valueMUSTthen be escaped as\\. A single-value parameter is never split, so it needs no escaping. - A range
MUSTbe two comma-separated values, with[and]for inclusive bounds and(and)for exclusive bounds.*MAYstand for no limit. Ranges express "less than", "greater than", and their inclusive forms.
When a single-value parameter gains multi-value support, a caller sending an unescaped comma starts sending two values. Confirm that the field's values cannot contain commas before making the change.
| Example | Meaning |
|---|---|
filter[lastName]=Smith | Last name is "Smith". |
filter[lastName]=S* | Last name begins with "S". |
filter[lastName]=*mi* | Last name contains "mi". |
filter[lastName]=Smith,Jones | Last name is "Smith" or "Jones". |
filter[failedLoginCount]=[3,*) | Three or more failed logins. |
filter[birthDate]=[2000-01-01T00:00:00Z,2001-01-01T00:00:00Z) | Born in the year 2000. |
Sorting
APIs that support sorting MUST take one sort parameter holding a comma-delimited list of fields.
A field preceded by a hyphen MUST be sorted descending. For example, sort=lastName,-createdAt.
Sparse fieldsets
APIs MAY let callers choose which fields to return. Those that do MUST take one fields
parameter holding a comma-delimited list, such as fields=firstName,lastName. id and type are
always returned.
Paging
APIs SHOULD page, and those that do MUST support offset paging, cursor paging, or both.
- Offset paging:
page[number](one-based) andpage[size]. The responsemetaMUSTcarrypageCount,pageNumber,pageSize, andtotalCount. - Cursor paging:
page[size]andpage[after]. The responsemetaMUSTcarrypageSize,hasMore, andcursor, the value to pass aspage[after]for the next page. The final pageMUSTreturnhasMoreasfalseandMUSTomitcursor. - A cursor
MUSTbe treated as opaque. CallersMUST NOTconstruct, parse, or modify one, and a serviceMAYchange its encoding without a new version.
GET /api/v1/users?page[number]=1&page[size]=2
{
"data": [
{
"attributes": { "firstName": "Bob", "lastName": "Smith" },
"id": "62bed180-1f78-45d4-8a56-c996936a2947",
"type": "user"
},
{
"attributes": { "firstName": "Alice", "lastName": "Jones" },
"id": "cacba8c1-29fa-4018-8950-acd400ec76b7",
"type": "user"
}
],
"meta": {
"pageCount": 5,
"pageNumber": 1,
"pageSize": 2,
"totalCount": 9
}
}
Advanced queries
filter parameters cannot express arbitrary combinations of "and" and "or", and long expressions
exceed practical URL lengths. APIs that need richer queries MUST offer a search operation that
carries the expression in the body.
- The method
MUSTbePOST, and the pathMUSTbe/api/v1/{resource plural}:search. - The expression
MUSTbe written in JSON Logic, in a top-levelfilterfield. Only these operators are supported:var,==,!=,>,>=,<,<=,in(membership or substring),andandor(two or more terms each), and!. - A request using any other operator, or referencing a field the API does not support,
MUSTbe rejected with422. - On success, APIs
MUSTreturn200, and the responseMUSThave the same shape as the equivalent read-many response, including paging and sorting.
POST /api/v1/users:search?page[size]=50&sort=-createdAt
Content-Type: application/json
{
"filter": {
"and": [
{ "<": [{ "var": "kdfIterations" }, 600000] },
{ "in": [{ "var": "status" }, ["ACTIVE", "INVITED"]] }
]
}
}
JSON Logic is adopted for its notation only. Its full operator set includes control flow, iteration,
and arithmetic, none of which belong in a query. It is preferred to a string grammar such as OData's
$filter because a JSON object is built and parsed without string handling, and composing a filter
by concatenating strings is how injection bugs happen.
Positive consequences
- Every collection is filtered, sorted, paged, and searched the same way.
- Each service declares exactly what can be queried, and rejects anything else rather than silently ignoring it.
- Advanced queries share their notation with bulk operations (see ADR-0046).
Negative consequences
- The escaping rules for commas, asterisks, and backslashes are ours, and callers have to learn them.
- Offset paging over data that changes between requests can skip or repeat resources. Cursor paging avoids this, at the cost of no page numbers or totals.
- JSON Logic is unfamiliar to most engineers, and only a subset of it is allowed.