# How to add field selection to resource responses

> Add a fields parameter that returns a subset on request and the full object otherwise, refuse unknown names with an RFC 9457 problem, and check the mock agrees.

Source: https://voxgig.com/howto/add-field-selection-to-responses

- Audience: api-producer
- Level: intermediate
- Verified: 2026-10-21
- Published: 2026-10-18

## 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](https://www.rfc-editor.org/rfc/rfc9457.html).

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Google AIP-157 read masks](https://google.aip.dev/157) | 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](https://spec.graphql.org/October2021/#sec-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](https://jsonapi.org/format/#fetching-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`](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_SystemQueryOptionselect) | 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](https://docs.stripe.com/api/expanding_objects) | 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](https://opensource.zalando.com/restful-api-guidelines/#157) | 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.

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

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

```bash
node mock.mjs openapi-no-400.json "?fields=id,nope"
```

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

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

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

```bash
node demo.mjs
```

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

```bash
node mock.mjs
```

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

```bash
node --test select.test.mjs
```

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

```bash
node pitfall.mjs
```

```text output
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](https://www.rfc-editor.org/rfc/rfc9111.html#section-4) 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

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

- [Serve your OpenAPI examples as mock responses](/howto/serve-openapi-examples-as-mock-responses)

- [Validate responses against the OpenAPI document at runtime](/howto/validate-responses-against-the-openapi-document)

- [Build cache keys that include the caller and Vary headers](/howto/cache-keys-that-include-the-caller)

- [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, 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.

[^1]: Stripe's [expand](https://docs.stripe.com/api/expanding_objects) 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.

[^2]: The [JSON:API specification](https://jsonapi.org/format/#fetching-sparse-fieldsets)
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.

[^3]: [AIP-157](https://google.aip.dev/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_mask` in request messages, which is where the field had
lived.

[^4]: Prism's generated error explains its own existence. The `detail` reads 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 the `type` points at `stoplight.io/prism/errors#UNPROCESSABLE_ENTITY`. The
[request validation](https://docs.stoplight.io/docs/prism/9528b5a8272c0-request-validation)
page documents the checks; the status code is Prism's choice, not RFC 9457's, which assigns none.