How-to › Test and mock integrations

How to validate responses against the OpenAPI document at runtime#

Build a validator from the response schema in your OpenAPI document and run it over real responses, so a service that stops matching its description fails.

Audience
API producer
Level
intermediate
Topic
Run contract and spec tests
Languages
TypeScript and JavaScript
Verified

Your OpenAPI document says amount_cents is an integer. A migration changed the column to numeric, the driver now returns it as a string, and the document still says integer. Generated SDKs in four languages deserialize it differently, two of them silently, and the document that everything was generated from is the only artifact nobody checked.

What you get

You will end up with a validator built from your own document, running over real responses and naming the property that drifted. This is for you if your document and your service can disagree without anything noticing.

Short answer

Read the response schema out of the OpenAPI document, build a validator from it once at startup, and run every response through it in test and staging. Allow properties the document does not describe, because JSON Schema does. Log a failure with the property path rather than throwing, so a drifting field is reported without taking the response away from the caller.

You will need

Node 22 or later, and an OpenAPI 3 document whose responses carry schemas. A document with no response schemas has nothing to validate against, and adding them is the first step rather than a detail.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
AjvThe document is JSON Schema and you want the whole vocabulary supportedIt compiles schemas at runtime, and its error objects need mapping before a person can read themYou want the validator to double as a hand-written schema
express-openapi-validatorAn Express service that wants request and response checking as middlewareIt ties validation to one framework, so a worker or a queue handler is left outThe service is not Express, or checks run outside the request path
shapeYou want one validator style for the document and for hand-written checksA smaller ecosystem, and the JSON Schema subset you support is yours to growThe document uses JSON Schema features beyond the common subset
ZodTypeScript services that want the response type inferred from the validatorConverting a document into its builder syntax is code generation rather than a walkYou want to read the document at startup rather than generate from it

Voxgig maintains shape. It is one of four options here, not the recommendation.

Ajv and the middleware are the ready-made answers, and the walk below is worth writing when you want the same validator over responses that never pass through a web framework. The subset it handles is smaller than Ajv’s on purpose: an unsupported keyword throws when the validator is built rather than passing unnoticed at request time.

Walk the document into a validator

The recursion is short because the schema shapes an API response uses are few.

export function shapeFromSchema(schema, schemas = {}) {
  if (schema.$ref) {
    const name = schema.$ref.replace('#/components/schemas/', '')
    return shapeFromSchema(schemas[name], schemas)
  }
  if (schema.type === 'array') return [shapeFromSchema(schema.items, schemas)]
  if (schema.type === 'object') {
    const required = new Set(schema.required ?? [])
    const props = Object.fromEntries(
      Object.entries(schema.properties ?? {}).map(([key, sub]) => {
        const built = shapeFromSchema(sub, schemas)
        return [key, required.has(key) ? built : Optional(built)]
      }),
    )
    // JSON Schema allows properties it does not describe unless the document
    // says otherwise, so the validator has to allow them too. Closing by
    // default would report every field the API adds as a breaking change.
    return schema.additionalProperties === false ? props : Open(props)
  }
  const leaf = LEAF[schema.type]
  if (!leaf) throw new Error(`unsupported schema type: ${JSON.stringify(schema.type)}`)
  return leaf
}

The comment marks the decision most implementations get backwards. A validator that rejects undescribed properties reports every additive change as a failure, so the team turns it off within a week. Follow the document: open by default, closed when it says additionalProperties: false.

The throw on an unknown type is the other deliberate choice. A validator that ignores keywords it does not understand passes responses it never checked, and reports a green run that means nothing.

Build the validator once rather than per request. Reading the document, resolving its references and constructing the checker are startup work, and doing them inside a handler turns a documentation check into a per-response cost nobody budgeted for.

Reference resolution is the part that grows. The preceding walk handles a local $ref into components.schemas, which covers most single-file documents. A document split across files, or one using dynamic references in JSON Schema, needs a resolver. That is the point at which a maintained parser costs less than extending the walk yourself, and it is a well-defined job to hand off. Resolve first, validate second, and the validator stays as small as this one.

Check it worked

Four responses against the document, then the same added field under a strict reading of it.

node demo.mjs
as documented              valid
amount sent as a string    invalid: amount_cents is not number
paid field removed         invalid: paid is missing
an added field             valid
an added field, strictly   invalid: carries a property the document does not describe

Line two is the drift from the opening paragraph, named by property. Lines four and five are the same response read two ways, and the difference between them is one keyword in the document rather than anything in the validator.

Run this over the responses your existing test suite already produces and the first pass is usually noisy. Some of that noise is real drift. Some of it is a document written once, left behind, and never compared with the service since. Both are findings, and separating them is the work the first run buys you. Fix the document where it is wrong, and the service where it is, and the noise stops.

node --test validate.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 146.133239

When it goes wrong

Validation runs in production and starts costing more than it finds. Every response pays for a walk of its own body, and on a large list endpoint that is real time. Sample instead, validating one response in a hundred. Or run the check only in staging and in the test suite, where the drift shows up just as clearly and nobody is waiting.

The second failure is a validator that throws. A response that fails validation is usually still useful to the caller, and turning a documentation drift into a 500 makes the observability change into an outage. Log the failure with its path and let the response through, and let the log volume decide how urgent the fix is.

When not to do this

Do not use shape, or any validator, as the way you learn what your API returns. The document is the thing under test here, and a validator built from a document nobody maintains only proves that two stale artifacts agree with each other.

Do not validate responses in the client and call it a contract test. A client checking its own copy of the document tests the copy, and the interesting question is what the service actually sent.

Do not extend the walk until it covers all of JSON Schema. At that point you have written Ajv without its test suite. The reason to stop is that a homemade validator fails by staying silent rather than by reporting.

Do not let the document drift because validation is passing. Open validation passes a response with a field the document never mentions, which is how an undocumented field ends up holding somebody’s integration together.

Last verified

Verified 2026-09-06 against Node 22.22.2 and shape 11.4.1. Both output blocks are what the preceding command printed. The Ajv, Zod and middleware rows describe documented behavior and were not run.

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.