The mobile app lists two hundred orders and reads three fields of each, so every screen pulls
forty kilobytes of line items it throws away. The team asks for a way to request less. The first
proposal returns id, status, and total unless a client asks for more, which breaks every
client that never asked for anything. The second is a GraphQL layer in front of the REST API.
What you get
You will end up with an endpoint that honors fields, refuses unknown names with a problem
document, and returns the full object when nothing is asked. This is for you if you add partial
responses to an API and test clients against a Prism mock.
Short answer
Declare one fields query parameter, a comma-separated array whose items are an enum of the
resource’s field names, with no default. A request without it gets the full object; a request
with it gets exactly the named fields, in schema order. An unknown name is a 400 with an
application/problem+json body that lists the real fields. Document that 400 in the OpenAPI
description, because a Prism mock answers 422 with its own problem type when you do not.
You will need
Node 22 or later, curl, and an OpenAPI 3.1 description of the resource. Verified 2026-10-21
against Node 22.22.0, curl 8.5.0, and @stoplight/prism-cli 5.14.2, the last Prism release
whose engines field admits Node 22. The sample runs the real handler on node:http and the
mock on Prism, each on a free local port, and drives both with curl, so the transcripts are the
bytes on the wire. The problem document follows
RFC 9457.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Google AIP-157 read masks | A platform where a view enum covers most cases and a mask is the escape hatch | A FieldMask grammar to parse, a mask carried outside the request as a system parameter, and a BASIC view that is itself a default subset | A REST API with no proto tooling, where a comma-separated list is the whole need |
| GraphQL selection sets | Selection is the whole model and every client already writes a query | A second protocol with a schema, a caching story, and a server of its own, bought to trim fields | You have a REST API and intend to keep it |
| JSON:API sparse fieldsets | A JSON:API document, where fields[orders]=id,status fits beside include | Brackets to percent-encode, per-type keys to validate, and a specification that lets a server send a subset when nothing was asked | Your resources are not JSON:API documents |
OData $select | A data service where $select rides with $expand and $filter and the clients already speak OData | The rest of the OData query language, and expanded navigation properties that come back whether selected or not | A product API with a handful of known views |
| Stripe expand with no field selection | Full objects by default and expand[] to pull related objects in, up to four levels deep | Every response carries every field, so a list of two hundred orders carries two hundred line-item arrays | Payload size on a list is the problem you were asked to solve |
Zalando fields parameter | One parameter with a nested grammar, fields=(name,friends(name)), across every resource | A grammar with parentheses to parse and document, and a schema in which every property turns optional | The resources are flat, where a plain comma-separated list does the same job |
Stripe’s position is the one to argue with first, because it is the cheapest: no selection, and
expand[] when a caller wants more rather than less.1 JSON:API and
Zalando both hand the caller a list of names, and they differ on nesting and on whether a server
may trim a response nobody asked to trim.2 AIP-157 steers you to a view enum first, two or
three documented shapes, and reaches for a mask only after that.3 GraphQL makes selection
mandatory, which is a different protocol rather than a parameter.
Declare the parameter and the 400 in the document
The parameter is an array with style: form and explode: false, so it arrives as
fields=id,status. Its items are an enum of the field names, which gives a validator and a
generator the vocabulary without a sentence of prose. minItems refuses an empty list,
uniqueItems refuses a repeat, and there is no default, because the absence of the parameter
is the only default the resource has.
"parameters": {
"fields": {
"name": "fields",
"in": "query",
"description": "Comma-separated names of the fields to return, in any order. Omit the parameter to receive every field; there is no default subset. A name that is not a field of the resource is a 400.",
"style": "form",
"explode": false,
"schema": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "enum": ["id", "status", "customer", "total", "currency", "created", "items"] }
}
}
},
The 400 is declared with the problem media type and a worked example. The example is what a mock serves when a request fails validation, so it is written as the real handler would write it.
"responses": {
"UnknownField": {
"description": "A name in fields that is not a field of the resource, an empty fields parameter, or fields given more than once",
"content": {
"application/problem+json": {
"schema": { "$ref": "#/components/schemas/Problem" },
"example": {
"type": "https://api.example.com/problems/unknown-field",
"title": "A requested field does not exist",
"status": 400,
"detail": "fields names \"nope\", which is not a field of an order; the fields are id, status, customer, total, currency, created, items",
"instance": "/orders/ord_7001?fields=id,nope"
}
}
}
}
}
openapi-no-400.json is the same document without that response. Prism still rejects the
unknown name, with a status and a problem type of its own.
node mock.mjs openapi-no-400.json "?fields=id,nope"
prism 5.14.2 mocking openapi-no-400.json
curl -s "http://127.0.0.1:45055/orders/ord_7001?fields=id,nope"
{"type":"https://stoplight.io/prism/errors#UNPROCESSABLE_ENTITY","title":"Invalid request","status":422,"detail":"Your request is not valid and no HTTP validation response was found in the spec, so Prism is generating this error for you.","validation":[{"location":["query","fields","1"],"severity":"Error","code":"enum","message":"Request query parameter fields.1 must be equal to one of the allowed values: id, status, customer, total, currency, created, items"}]}
422 application/problem+json
A client tested against that mock learns to expect 422 and a Stoplight problem type, and the real API sends 400 with yours.4 The declared response is what makes the mock and the server agree.
Return exactly what was asked, and everything when nothing was
The selector takes the full object and the raw parameter value: null when the request carried
none, an array when it carried several. It returns either the body to send or the problem to send
as a 400.
export function selectFields(object, fields, allowed = ORDER_FIELDS) {
// No parameter: the full object. The absence of fields is the only default there is.
if (fields === null || fields === undefined) return { ok: true, body: object }
// fields=id&fields=status: frameworks keep the first value, the last, or all of them.
if (Array.isArray(fields)) return { ok: false, problem: problem(`fields is given ${fields.length} times; send one comma-separated list`) }
if (fields === '') return { ok: false, problem: problem('fields is empty; omit the parameter to receive every field') }
const names = fields.split(',')
const unknown = names.filter((n) => !allowed.includes(n))
if (unknown.length) {
return { ok: false, problem: problem(`fields names ${unknown.map((n) => `"${n}"`).join(', ')}, which is not a field of an order; the fields are ${allowed.join(', ')}`) }
}
const twice = names.find((n, i) => names.indexOf(n) !== i)
if (twice) return { ok: false, problem: problem(`fields names "${twice}" twice`) }
// Walk the schema's order, not the request's, so two clients asking for the same fields in a
// different order get byte-identical bodies.
const body = {}
for (const name of allowed) if (names.includes(name)) body[name] = object[name]
return { ok: true, body }
}
The body is built in the schema’s order rather than the request’s, so fields=total,id and
fields=id,total produce the same bytes. A cache still stores them under two keys, because the
query strings differ. An empty value is refused rather than read as no fields or as all fields,
because both readings have been chosen by some API and a caller cannot tell which. A second
fields is refused for the same reason. The handler adds the request as the problem’s
instance and sets the media type.
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1')
const match = /^\/orders\/([^/]+)$/.exec(url.pathname)
if (req.method !== 'GET' || !match || match[1] !== ORDER.id) {
return send(res, 404, 'application/problem+json', { type: 'about:blank', title: 'Not Found', status: 404 })
}
const given = url.searchParams.getAll('fields')
const result = select(ORDER, given.length > 1 ? given : given[0] ?? null)
if (!result.ok) {
return send(res, 400, 'application/problem+json', { ...result.problem, instance: url.pathname + url.search })
}
send(res, 200, 'application/json', result.body)
})
Three requests through curl: no parameter, two fields, and a field that does not exist.
node demo.mjs
curl -s -i "http://127.0.0.1:37349/orders/ord_7001"
HTTP/1.1 200 OK
content-type: application/json
content-length: 244
connection: close
{
"id": "ord_7001",
"status": "paid",
"customer": "cus_9",
"total": 12050,
"currency": "EUR",
"created": "2026-09-14T09:30:00Z",
"items": [
{
"sku": "TEE-M-BLK",
"quantity": 2,
"unit_price": 6025
}
]
}
curl -s -i "http://127.0.0.1:37349/orders/ord_7001?fields=id,status"
HTTP/1.1 200 OK
content-type: application/json
content-length: 43
connection: close
{
"id": "ord_7001",
"status": "paid"
}
curl -s -i "http://127.0.0.1:37349/orders/ord_7001?fields=id,nope"
HTTP/1.1 400 Bad Request
content-type: application/problem+json
content-length: 317
connection: close
{
"type": "https://api.example.com/problems/unknown-field",
"title": "A requested field does not exist",
"status": 400,
"detail": "fields names \"nope\", which is not a field of an order; the fields are id, status, customer, total, currency, created, items",
"instance": "/orders/ord_7001?fields=id,nope"
}
The full object is 244 bytes and the two-field selection is 43, which is the saving the mobile
team asked for, on one order. The 400 carries the misspelled name, the list of real names, and
the request that caused it, so the developer who typed nope can fix it without opening the
documentation.
Hold the mock to the same contract
Run the same requests, plus an empty fields, against Prism mocking the document with the 400.
node mock.mjs
prism 5.14.2 mocking openapi.json
curl -s "http://127.0.0.1:41611/orders/ord_7001"
{"id":"ord_7001","status":"paid","customer":"cus_9","total":12050,"currency":"EUR","created":"2026-09-14T09:30:00Z","items":[{"sku":"TEE-M-BLK","quantity":2,"unit_price":6025}]}
200 application/json
curl -s "http://127.0.0.1:41611/orders/ord_7001?fields=id,status"
{"id":"ord_7001","status":"paid","customer":"cus_9","total":12050,"currency":"EUR","created":"2026-09-14T09:30:00Z","items":[{"sku":"TEE-M-BLK","quantity":2,"unit_price":6025}]}
200 application/json
curl -s "http://127.0.0.1:41611/orders/ord_7001?fields=id,nope"
{"type":"https://api.example.com/problems/unknown-field","title":"A requested field does not exist","status":400,"detail":"fields names \"nope\", which is not a field of an order; the fields are id, status, customer, total, currency, created, items","instance":"/orders/ord_7001?fields=id,nope"}
400 application/problem+json
curl -s "http://127.0.0.1:41611/orders/ord_7001?fields="
{"id":"ord_7001","status":"paid","customer":"cus_9","total":12050,"currency":"EUR","created":"2026-09-14T09:30:00Z","items":[{"sku":"TEE-M-BLK","quantity":2,"unit_price":6025}]}
200 application/json
The unknown field gets the documented 400 and the documented body, so a client built against the
mock meets the same error in production. The valid selection gets the full example, because a
mock answers from the document and the document has one example. The mock proves only that the parameter is well formed; selection is a behavior
the example cannot show. The empty fields passes the mock and fails the handler, which is the
request to test against the real thing.
Check it worked
node --test select.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1609.283677
The line to read is # pass 8. One test reads openapi.json and asserts that the fields enum
and the Order properties both equal ORDER_FIELDS in the handler, and that Order lists no
required properties. Another starts Prism and asserts those three behaviors, so a Prism release
that changes any of them fails here first.
When it goes wrong
Every client that never sent fields loses four properties overnight. Someone gave the parameter
a default subset, on the grounds that most callers only want the summary, while the document went
on promising the full object. pitfall.mjs runs a client built from the document against that
server and against the handler as written.
node pitfall.mjs
server with a default subset
GET /orders/ord_7001 with no fields -> 200, keys: id, status, total
documented properties: id, status, customer, total, currency, created, items
client validation fails: missing customer, currency, created, items; order.customer reads as undefined
server with no default
GET /orders/ord_7001 with no fields -> 200, keys: id, status, customer, total, currency, created, items
documented properties: id, status, customer, total, currency, created, items
client validation passes: every documented property is present
The client did nothing wrong. It read the schema, asked for nothing, and expected everything,
and order.customer came back undefined in code that had worked for a year. If a summary shape
is wanted, give it a name as a second schema or a view, as AIP-157 does, and leave the bare
request alone.
A cache hands one client another client’s subset. The handler keys nothing itself, but a CDN
configured to drop the query string from its key stores ?fields=id,status under the same key as
the full object. RFC 9111 lets a cache
reuse a stored response only when the target URI matches, and the query string is part of that
URI. The fix is to stop the CDN rewriting the key, or to mark selected responses
Cache-Control: private.
The generated client turns every field optional. With fields in play, Order can list no
required properties, so the SDK’s type for a full fetch reads customer?: string and every call
site grows a guard. Where the typed client matters more than the flexibility, publish two
schemas, a summary and the full object, and let each endpoint return exactly one of them.
When not to do this
Do not give fields a default subset. AIP-157 says a partial response as the default of a
standard method can degrade the effectiveness of declarative clients, and the pitfall on this
page is the imperative version of that sentence. The absence of the parameter means the whole resource,
or the parameter is not field selection.
Do not add selection to a resource with six scalar properties and no nested arrays. The saving is a few bytes per object, and the price is a schema with no required properties and a parameter every client has to learn.
A mock cannot prove selection. Prism validates the parameter against the enum and answers from the example. A test that reads the trimmed body back has to run against the handler, or against a proxy in front of it.
Do not adopt GraphQL to trim fields from a REST API. Selection is one thing GraphQL does, and it
arrives with a schema language, a resolver layer, and a caching model that the REST API did not
have. Stripe’s answer, every field on every object and expand[] for more, is the right one when
payload size was never the complaint.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, curl 8.5.0, and @stoplight/prism-cli 5.14.2. Every
output block is what the command preceding it printed, run in the page’s code directory after
npm ci. The handler and the mock ran on local ports without any call leaving the machine. The
JSON:API, Zalando, AIP-157, OData, GraphQL, and Stripe conventions were read from the
documentation this page links and were not exercised against a running service.
Footnotes
-
Stripe’s expand reaches related objects through dotted paths, to a maximum depth of four levels. The API publishes a depth limit on the mechanism it offers, which asks for more. It offers no mechanism for asking for less, so the trimming is left to the client’s JSON parser. ↩︎ Back to text
-
The JSON:API specification permits the pitfall on this page. When a client does not specify the set of fields for a type, the server MAY send all fields, a subset of fields, or no fields. The MUST NOT runs the other way: once a client restricts the fields, the server may not add any. ↩︎ Back to text
-
AIP-157 moved the mask out of the request. Field masks should not be specified in the request, it says, and Google Cloud APIs specify them as a system parameter, in a query parameter, a header, or gRPC metadata. The section that records the move is titled as a deprecation of
read_maskin request messages, which is where the field had lived. ↩︎ Back to text -
Prism’s generated error explains its own existence. The
detailreads that the request is not valid and no HTTP validation response was found in the spec, so Prism is generating this error for you, and thetypepoints atstoplight.io/prism/errors#UNPROCESSABLE_ENTITY. The request validation page documents the checks; the status code is Prism’s choice, not RFC 9457’s, which assigns none. ↩︎ Back to text