APIs & Devices NotesRohit’s interview study guide
Chapter 03

Documenting APIs with OpenAPI

One machine-readable contract for a device API: what OpenAPI 3.1 is, a real spec for a room device, enforcing it on every request and response, generating types and docs from it, design-first vs code-first, and catching breaking changes before a fleet of devices does.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#OpenAPI 3.1 in an interview

Added

OpenAPI is a standard way to describe an HTTP API in a YAML or JSON file: every path, every operation on it, the parameters and bodies it takes, the responses it returns, and how callers authenticate. It started life as the Swagger Specification; Swagger 2.0 was donated to the OpenAPI Initiative at the end of 2015, and the line since then is OpenAPI 3.0.0 (July 2017), 3.1.0 (February 2021), the 3.1.1 and 3.1.2 patches (October 2024 and September 2025), 3.2.0 (September 2025) and its 3.2.1 patch (September 2026). Those dates are from the revision history in the specifications at spec.openapis.org, checked 2026-10-27. "Swagger" today is the name of SmartBear's tools (Swagger UI, Swagger Editor), not of the format.

The mental model: an OpenAPI document is the contract drawing for an API. An architect's drawing is what the builders, the inspector, the electrician and the buyer all work from; nobody pulls bricks out of the wall to find where the door is. A device API has the same crowd: the team writing the on-device server, the web UI team, partners integrating with the device, the people writing SDKs and tests, and the support engineer reading a customer's log. When they all read one file, they agree. When each reads the code, or a PDF someone last updated two releases ago, they don't.

What makes it more than documentation is that the file is data. Tools read it to render reference docs, to generate TypeScript types and client code, to validate requests and responses while the server runs, to start a mock server the UI team can build against, and to diff two versions and say which changes break existing clients. Each of those is a topic in this chapter.

Document root openapi: 3.1.1 info servers security paths webhooks components /audio get patch each operation has: operationId, parameters requestBody → content responses → "200", "400"… media type → schema, examples deviceStatusChanged a request the device sends to a server you registered components schemas AudioSettings, Problem… responses BadRequest, NotFound… parameters, headers If-Match, ETag securitySchemes bearerAuth, sessionCookie pathItems, examples… $ref $ref paths and webhooks stay thin; the shapes live once, in components
An OpenAPI 3.1 document. A valid document needs openapi, info and at least one of paths, components or webhooks.

What changed from 3.0 to 3.1

Interviewers who know OpenAPI often ask this, because 3.0 specs are still everywhere and the differences bite in tooling. The OpenAPI Initiative's own upgrade guide (learn.openapis.org, "Upgrading from 3.0 to 3.1", checked 2026-10-27) says the breaking changes are limited to the Schema Object; the rest are additions.

AreaOpenAPI 3.0OpenAPI 3.1
Schemas"an extended subset" of an old JSON Schema draft (Wright-00)a superset of JSON Schema Draft 2020-12; $schema and the root jsonSchemaDialect can name the dialect
Nullnullable: true next to typenullable is gone: type: [string, "null"]
Exclusive boundsminimum: 0 + exclusiveMinimum: true (a boolean)exclusiveMinimum: 0 (a number)
Schema examplesexample: … (one value)examples: [ … ] (a JSON Schema array); example is deprecated
File uploadstype: string, format: binarycontentMediaType / contentEncoding, or an empty schema for raw bytes
Webhooksnot available (only per-operation callbacks)root webhooks map
Required root fieldsopenapi, info, pathsopenapi, info, and at least one of paths, components, webhooks
info and licencetitle, version, license.name and urladds info.summary and license.identifier (an SPDX expression)
Reuseno components.pathItemscomponents.pathItems; a Reference Object can carry its own summary and description
Security schemesapiKey, http, oauth2, openIdConnectadds mutualTLS (client certificates), useful for device-to-cloud links

The smallest useful document

Here is the smallest useful document for a device: one operation, one webhook, two shared schemas and an auth scheme. It's valid OpenAPI 3.1 (swagger-parser accepts it); the full spec in the next topic adds what a linter would ask for.

openapi.yaml (skeleton)
openapi: 3.1.1
info:
  title: Room Device API
  version: 1.4.0
servers:
  - url: https://{deviceAddress}/api/v1
    variables:
      deviceAddress: { default: 192.168.1.20 }
security:
  - bearerAuth: []
paths:
  /audio:
    get:
      operationId: getAudioSettings
      summary: Read the audio settings.
      responses:
        "200":
          description: Current audio settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AudioSettings" }
webhooks:
  deviceStatusChanged:
    post:
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceEvent" }
      responses:
        "204": { description: Event received. }
components:
  schemas:
    AudioSettings:
      type: object
      required: [volume, muted]
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
    DeviceEvent:
      type: object
      required: [sequence, type]
      properties:
        sequence: { type: integer, minimum: 1 }
        type: { type: string }
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer }
What’s happening
  1. openapi: 3.1.1 is the version of the OpenAPI Specification the file follows, which decides how tools read everything below it. info.version: 1.4.0 is the version of your API. Mixing the two up is a classic interview slip: bumping your API never changes the openapi line.
  2. servers gives the base URL, and the {deviceAddress} variable says the host is different for every device. A cloud API has one fixed server; a device API is served by thousands of devices, each at its own address, so the variable (with a default for the docs' "try it" button) is the honest description.
  3. security at the root applies to every operation unless one overrides it. bearerAuth is defined once under components.securitySchemes as type: http, scheme: bearer; the requirement - bearerAuth: [] just names it.
  4. Under paths, /audio has a get operation whose 200 response has a content map keyed by media type. application/json points at a schema, here with $ref: "#/components/schemas/AudioSettings", a JSON Pointer into the same file. Every path that returns audio settings points at that one schema, so the shape is defined once.
  5. webhooks is new in 3.1: requests the device sends to a server that registered with it, described like operations. The specification describes them as requests "initiated other than by an API call". 3.0 could only describe callbacks tied to a specific operation (subscribe here, we call you back at the URL you gave).
  6. components.schemas are JSON Schema. In 3.1 the Schema Object is "a superset of" JSON Schema Draft 2020-12 (the spec's wording), so type, required, minimum and maximum mean exactly what they mean in any JSON Schema validator. That alignment is the headline change in 3.1, and the reason Ajv, the validator used later in this chapter, can check data against these schemas with no translation.
AdvancedThe same 3.0 keyword, read by three tools

The table says nullable is gone in 3.1. What actually happens when a 3.0 habit ends up in a 3.1 file is that tools disagree, which is worse than all of them rejecting it. This model puts the same field in a 3.1 document twice, once the 3.0 way and once the 3.1 way, and asks a validator, a type generator and a linter:

nullable-across-tools.mjs
// One 3.0-style schema inside a 3.1 document, read by three tools.
import Ajv2020 from "ajv/dist/2020.js";
import openapiTS, { astToString } from "openapi-typescript";
import { lintFromString, createConfig } from "@redocly/openapi-core";
import { stringify } from "yaml";

const doc = (lastError) => ({
  openapi: "3.1.1",
  info: { title: "Nullable test", version: "1.0.0", license: { name: "MIT", identifier: "MIT" } },
  servers: [{ url: "https://device.local/api/v1" }],
  paths: {},
  components: { schemas: { Status: { type: "object", required: ["lastError"], properties: { lastError } } } },
});
const styles = {
  "3.0 style  { type: string, nullable: true }": { type: "string", nullable: true },
  "3.1 style  { type: [string, \"null\"] }": { type: ["string", "null"] },
};
const config = await createConfig({ extends: ["recommended"] });

for (const [label, lastError] of Object.entries(styles)) {
  console.log(label);
  const ajv = new Ajv2020();
  const valid = ajv.compile(doc(lastError).components.schemas.Status);
  console.log(`  Ajv accepts { lastError: null }:  ${valid({ lastError: null })}`);
  const ts = astToString(await openapiTS(doc(lastError)));
  console.log(`  openapi-typescript:               ${ts.match(/lastError: [^;]+/)[0]}`);
  const problems = await lintFromString({ source: stringify(doc(lastError)), absoluteRef: "openapi.yaml", config });
  const errors = problems.filter((p) => p.severity === "error").map((p) => p.message);
  console.log(`  Redocly lint:                     ${errors.join("; ") || "no errors"}`);
}
Result
3.0 style  { type: string, nullable: true }
  Ajv accepts { lastError: null }:  true
  openapi-typescript:               lastError: string | null
  Redocly lint:                     Property `nullable` is not expected here.
3.1 style  { type: [string, "null"] }
  Ajv accepts { lastError: null }:  true
  openapi-typescript:               lastError: string | null
  Redocly lint:                     no errors
What’s happening
  1. Both documents say openapi: "3.1.1"; only the lastError schema differs. The question each tool answers is "may lastError be null?"
  2. Ajv says yes to both. It isn't following 2020-12 here: Ajv has nullable built into its type handling (lib/compile/validate/dataType.ts adds "null" to the allowed types when nullable: true) as a convenience for OpenAPI 3.0 users. A validator that follows JSON Schema 2020-12 to the letter would treat the unknown keyword as an annotation (the core spec: "Unknown keywords SHOULD be treated as annotations") and reject null, because type: string doesn't allow it.
  3. openapi-typescript also says yes to both, generating string | null. Being lenient here means a half-migrated spec still produces sensible types.
  4. Redocly's linter rejects the 3.0 form with "Property nullable is not expected here." It checks the document's structure against what 3.1 allows.
  5. So one spec can give you a type that says null is fine, a validator in one language that agrees, and a validator in another language (a Kotlin or Python service, a partner's gateway) that rejects every response where lastError is null. The 3.1 form, type: [string, "null"], means the same thing to all of them. That's the practical case for linting specs in CI (the next topic shows what linting catches).
AdvancedWhere OpenAPI 3.2 fits

OpenAPI 3.2.0 was published on 19 September 2025, and the 3.2.1 patch on 10 September 2026 (revision history at spec.openapis.org, checked 2026-10-27). From the specification text: a query operation for the HTTP QUERY method, additionalOperations for other methods, a $self field giving the document its own URI, a querystring parameter location, components.mediaTypes, and new sections on streaming and sequential media types, including Server-Sent Events, which matters for device event streams (see chapter 7).

The reason this chapter writes 3.1 is the tool chain, as installed here in October 2026:

  • @apidevtools/swagger-parser 13.1.0 accepts 3.1.0, 3.1.1, 3.1.2 and 3.0.0–3.0.4 and rejects anything else (supported31Versions in its lib/index.js; the next topic shows the error).
  • openapi-typescript 7.13 documents support for "OpenAPI 3.0 and 3.1" (openapi-ts.dev, checked 2026-10-27).
  • @redocly/openapi-core 1.34.22 reads a 3.2 document with its 3.1 rules; its source says // temporary support for 3.2.

So in an interview: "3.1 is what the tooling supports end to end today; 3.2 adds things we'd want for event streams, and I'd move when our validator, generator and docs tool all support it." Check the versions again when you start a project, because this moves.

AdvancedGotcha: Swagger 2.0 files are not OpenAPI 3
Moving a 3.0 schema into a 3.1 spec

A 3.0 spec describes a retry delay that must be greater than zero as { type: integer, minimum: 0, exclusiveMinimum: true }. You change the file's first line to openapi: 3.1.1 and validate data with Ajv in JSON Schema 2020-12 mode. What happens, and what should the schema say?

Show answer

Ajv refuses to compile the schema: in 2020-12, exclusiveMinimum must be a number. The 3.1 form is { type: integer, exclusiveMinimum: 0 }.

exclusive-minimum.mjs
import Ajv2020 from "ajv/dist/2020.js";

const fromOpenApi30 = { type: "integer", minimum: 0, exclusiveMinimum: true }; // "greater than 0" in 3.0
const for31 = { type: "integer", exclusiveMinimum: 0 }; // "greater than 0" in 3.1 / JSON Schema 2020-12

for (const [label, schema] of [["3.0 form", fromOpenApi30], ["3.1 form", for31]]) {
  try {
    const validate = new Ajv2020().compile(schema);
    console.log(`${label}: 0 → ${validate(0)}, 1 → ${validate(1)}`);
  } catch (e) {
    console.log(`${label}: ${e.message}`);
  }
}
Result
3.0 form: schema is invalid: data/exclusiveMinimum must be number
3.1 form: 0 → false, 1 → true
What’s happening
  1. In OpenAPI 3.0 (based on an old JSON Schema draft), exclusiveMinimum was a boolean modifier of minimum: minimum: 0 plus exclusiveMinimum: true meant "greater than 0".
  2. JSON Schema changed the keyword to a number in later drafts, and 3.1 follows 2020-12, so exclusiveMinimum: 0 alone means "greater than 0", with no minimum needed.
  3. Ajv validates the schema itself against the 2020-12 meta-schema before compiling it. true isn't a number, so compilation fails with "schema is invalid: data/exclusiveMinimum must be number". That's the good outcome: a loud failure at startup.
  4. The 3.1 form rejects 0 and accepts 1. The OpenAPI upgrade guide's checklist says exactly this: switch to the numeric form and remove the sibling minimum.
  5. The quieter failure is nullable (above), which some tools accept and others ignore. Changing the openapi line is never the whole migration; lint the file with 3.1 rules afterwards.

#Writing a spec for a device API

Added

Writing a spec is API design written down precisely. The decisions are the ones from chapter 2: resources, methods, status codes, error format, partial updates, long-running operations. The spec makes each one explicit enough that a machine can check it, and that's when vague decisions show up: "returns the settings" becomes "returns an object with these three required fields, volume an integer from 0 to 100".

The mental model: components are the parts catalogue, paths are the assembly instructions. A good spec defines each shape once (AudioSettings, Problem, the If-Match header, the 404 response) and every operation refers to them. Paths stay short and readable, and when a shape changes it changes everywhere at once. A spec where every operation spells out its own copy of the error body has drifted within a year: one copy gains a code field, the others don't.

A spec has three audiences, and each wants something different. People read the rendered docs: they want summary, description and examples. Machines read the structure: validators, generators and diff tools want precise schemas, operationIds and media types. The next version reads the diff: it wants stable names, because renaming a schema or an operationId breaks generated code even when the wire format didn't change.

Here's a complete spec for a small room-device API, written for this chapter (it isn't any vendor's API, though the SpeakerSelection values are borrowed from Poly's REST guide). It covers status, audio settings with JSON Merge Patch and ETags, a reboot as a long-running operation, and a webhook to a management server. It passes swagger-parser and Redocly's recommended lint rules with no problems (both run 2026-10-27). The runnable models later in the chapter each run on their own, so each carries a trimmed copy of the part of this spec it needs (descriptions, unused fields and some constraints left out; the audio schemas are otherwise identical).

openapi.yaml
openapi: 3.1.1
info:
  title: Room Device API
  summary: Local REST API of a video-conferencing room device.
  version: 1.4.0
  license:
    name: Apache 2.0
    identifier: Apache-2.0
servers:
  - url: https://{deviceAddress}/api/v1
    description: The device itself, on the local network.
    variables:
      deviceAddress:
        default: 192.168.1.20
security:
  - sessionCookie: []
  - bearerAuth: []
tags:
  - name: system
    description: Identity, status and power.
  - name: audio
    description: Microphone and speaker settings.
  - name: operations
    description: Long-running jobs such as reboots and updates.
paths:
  /system/status:
    get:
      tags: [system]
      operationId: getSystemStatus
      summary: Read the device's current status.
      responses:
        "200":
          description: Current status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeviceStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /system/reboot:
    post:
      tags: [system]
      operationId: rebootDevice
      summary: Start a reboot and return an operation to poll.
      responses:
        "202":
          description: Reboot accepted; poll the operation in Location.
          headers:
            Location:
              description: URL of the operation resource.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Operation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
  /audio:
    get:
      tags: [audio]
      operationId: getAudioSettings
      summary: Read the audio settings.
      responses:
        "200":
          description: Current audio settings.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AudioSettings" }
              examples:
                meetingRoom:
                  summary: A meeting room at normal volume
                  value: { volume: 62, muted: false, speaker: SPEAKER_BOTH }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [audio]
      operationId: updateAudioSettings
      summary: Change some audio settings (JSON Merge Patch).
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: "#/components/schemas/AudioSettingsPatch" }
            examples:
              mute:
                summary: Mute the room
                value: { muted: true }
      responses:
        "200":
          description: The settings after the change.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AudioSettings" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
  /operations/{operationId}:
    parameters:
      - name: operationId
        in: path
        required: true
        schema: { type: string, pattern: "^op_[A-Za-z0-9]+$" }
    get:
      tags: [operations]
      operationId: getOperation
      summary: Read the state of a long-running operation.
      responses:
        "200":
          description: The operation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Operation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
webhooks:
  deviceStatusChanged:
    post:
      operationId: onDeviceStatusChanged
      summary: Sent to the registered management server when the status changes.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceEvent" }
      responses:
        "204":
          description: Event received.
        "400":
          description: The receiver could not parse the event.
components:
  schemas:
    DeviceStatus:
      type: object
      required: [serialNumber, model, firmwareVersion, uptimeSeconds, inCall, lastError]
      properties:
        serialNumber: { type: string, examples: ["SN8L2134AB56"] }
        model: { type: string, examples: ["RB-50"] }
        firmwareVersion: { type: string, pattern: '^\d+\.\d+\.\d+$', examples: ["5.0.2"] }
        uptimeSeconds: { type: integer, minimum: 0 }
        inCall: { type: boolean }
        lastError:
          description: The last fault reported, or null if there is none.
          type: [string, "null"]
    AudioSettings:
      type: object
      required: [volume, muted, speaker]
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
    SpeakerSelection:
      type: string
      enum: [SPEAKER_LEFT, SPEAKER_RIGHT, SPEAKER_BOTH]
    AudioSettingsPatch:
      description: Fields to change; fields you leave out keep their value.
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
    Operation:
      type: object
      required: [id, kind, status]
      properties:
        id: { type: string, pattern: "^op_[A-Za-z0-9]+$" }
        kind: { type: string, enum: [reboot, firmwareUpdate] }
        status: { type: string, enum: [queued, running, succeeded, failed] }
        progress: { type: integer, minimum: 0, maximum: 100 }
        error:
          oneOf:
            - $ref: "#/components/schemas/Problem"
            - type: "null"
    DeviceEvent:
      type: object
      required: [sequence, type, occurredAt, status]
      properties:
        sequence: { type: integer, minimum: 1 }
        type: { const: deviceStatusChanged }
        occurredAt: { type: string, format: date-time }
        status: { $ref: "#/components/schemas/DeviceStatus" }
    Problem:
      description: Problem details (RFC 9457) plus a stable error code.
      type: object
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri-reference }
        title: { type: string }
        status: { type: integer, minimum: 400, maximum: 599 }
        detail: { type: string }
        code: { type: string, examples: ["AUDIO_VOLUME_OUT_OF_RANGE"] }
        errors:
          type: array
          items:
            type: object
            required: [pointer, detail]
            properties:
              pointer:
                description: JSON Pointer (RFC 6901) to the bad field, as a URI fragment such as "#/volume".
                type: string
                examples: ["#/volume"]
              detail: { type: string, examples: ["must be <= 100"] }
  parameters:
    IfMatch:
      name: If-Match
      in: header
      required: false
      description: ETag from your last read; the change is refused with 412 if the settings changed since.
      schema: { type: string }
  headers:
    ETag:
      description: Version of this representation, for If-Match.
      schema: { type: string }
  responses:
    BadRequest:
      description: The body isn't well-formed JSON, so it couldn't be parsed.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: No valid session or token.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: No such resource.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Conflict:
      description: Another reboot or update is already running.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    PreconditionFailed:
      description: The settings changed since your If-Match ETag; read them again.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    UnsupportedMediaType:
      description: The body isn't sent as application/merge-patch+json.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ValidationFailed:
      description: The body is well-formed JSON but breaks the schema (a value out of range, an unknown field, no fields); errors lists each one.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: session
      description: Session cookie the web UI gets when an admin signs in.
    bearerAuth:
      type: http
      scheme: bearer
      description: API token for scripts and management servers.
What’s happening
  1. Root: info carries the API's own version (1.4.0), a one-line summary and license.identifier (both new in 3.1). servers uses a variable for the device's address because every device serves its own copy of the API. security lists two alternatives: the specification says "only one of the entries in the list needs to be satisfied", so a request may use the web UI's session cookie or a bearer token. Putting both inside one entry (- { sessionCookie: [], bearerAuth: [] }) would mean both are required.
  2. Paths are thin. Each operation has a tags entry (groups it in the docs), a unique operationId (the specification says it "MUST be unique among all operations"; generators turn it into method names such as getAudioSettings()), a summary, and responses that mostly point into components.responses. Every operation declares its error responses, not just the happy path, so clients know what to handle.
  3. PATCH /audio is a JSON Merge Patch (RFC 7396): the request media type is application/merge-patch+json, not application/json, and its schema AudioSettingsPatch has no required fields, minProperties: 1 and additionalProperties: false, so a typo like volme is rejected instead of silently ignored. Its error responses follow the split from chapter 2: 400 only when the body can't be parsed as JSON, 415 for any media type other than merge patch, and 422 with a problem body for well-formed JSON that breaks the schema, such as volume: 150 or volme. The If-Match parameter and ETag headers describe optimistic concurrency; 412 is the "someone else changed it" answer (see Optimistic concurrency with ETags).
  4. POST /system/reboot returns 202 with a Location header and an Operation body, and GET /operations/{operationId} reads it: the long-running-operation pattern from chapter 2, now precise enough to generate a client that polls it. The path parameter is declared once at the path level, with a pattern, so every operation on that path shares it.
  5. Schemas use 3.1 forms: lastError: type: [string, "null"] (required, but may be null, which is different from "may be missing"), type: { const: deviceStatusChanged } for the event discriminator, and examples: [...] arrays on properties. Media types use a different examples: a map of named Example Objects (meetingRoom, mute) that docs tools show as selectable samples. Same word, two keywords.
  6. One error shape everywhere. Problem is RFC 9457 problem details (type, title, status, detail) plus a stable machine-readable code and an errors list of JSON Pointers, and every error response is application/problem+json with that schema (see Errors with problem details). Partners code against code, which never changes wording, rather than title, which might be translated.
  7. The webhook has security: [], which the specification defines as removing the top-level security for that operation: the device calls out to the management server, so the device's own session and token schemes don't apply. A real deployment would describe how the receiver authenticates the device, for example with mutualTLS or a signed header; the spec should say which.
AdvancedGotcha: commas in flow-style YAML
AdvancedFrom a PDF reference to a spec

Device vendors often publish their API as a reference guide rather than an OpenAPI file. Poly's public Poly VideoOS REST API Reference Guide (HP document P55757-001A, last updated August 2025, for VideoOS 4.4.0 on the Studio G62, G7500 and Studio X systems; read 2026-10-27 from HP's public PDF) documents each command as a table: description, protocol and method, path, access level, input, a sample output body and the applicable return codes. It says a session ID from /rest/session is needed before other commands, bodies are JSON, and the API "cannot accept more than 10 MB of data". I didn't find a machine-readable OpenAPI file published with it; whether one exists internally, the public docs don't say.

Rewriting one of its commands, GET /rest/audio, as OpenAPI is a good exercise, because a linter immediately asks the questions a table leaves open. This is my sketch from the guide's table, not an HP artefact:

guide-to-spec.mjs
// One command from a PDF reference guide, rewritten as OpenAPI, then linted.
import { lintFromString, createConfig } from "@redocly/openapi-core";

const sketch = `
openapi: 3.1.1
info:
  title: Poly VideoOS REST API (one command, sketched from the public guide)
  version: 4.4.0
servers:
  - url: https://{system}/rest
    variables:
      system: { default: 192.168.1.20 }
paths:
  /audio:
    get:
      operationId: getAudio
      summary: Details about the system's audio.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  muteLocked: { type: boolean }
                  muted: { type: boolean }
                  numOfMicsConnected: { type: integer }
                  volume: { type: integer }
              examples:
                fromTheGuide:
                  value: { muteLocked: false, muted: true, numOfMicsConnected: 1, volume: 62 }
        "500":
          description: Server Error
`;

const config = await createConfig({ extends: ["recommended"] });
const problems = await lintFromString({ source: sketch, absoluteRef: "videoos-audio.yaml", config });
for (const p of problems) console.log(`${p.severity.padEnd(5)} ${p.ruleId}: ${p.message}`);
Result
warn  info-license: Info object should contain `license` field.
warn  operation-4xx-response: Operation must have at least one `4XX` response.
error security-defined: Every operation should have security defined on it or on the root level.
What’s happening
  1. The guide's table becomes one operation: method and path from "Protocol, Method, and Path", the four fields of the sample output become a schema, the sample itself becomes a named example, and the return codes (200 OK, 500 Server Error) become responses.
  2. The error is security-defined: the guide says administrator credentials and a session ID are required, but the sketch can't say how that session travels on later requests, because the guide's table for /rest/session shows how to create one, and nowhere in its 86 pages does it say whether the session is then sent as a cookie or a header. (HP's volunteer-run community FAQ, which isn't official documentation, describes a session cookie and, from VideoOS 4.6.2, an XSRF token; chapter 1 quotes it.) The spec format forces someone to write that down.
  3. The warnings are the other gaps: no licence in info, and no 4XX response. The guide's /rest/audio GET lists only 200 and 500, though a missing or expired session must produce something; a spec makes the team decide what.
  4. The same exercise across a whole guide surfaces inconsistencies a reviewer can't hold in their head. In this one, for example, sample bodies spell a field sessionID in one table (/rest/cameras/near/all) and sessionId in another (/rest/cameras/near/presets/all), and the error-code table lists 415 for "payload invalid or missing one or more required fields", where HTTP (RFC 9110) defines 415 as Unsupported Media Type. Whether the devices actually differ, the guide doesn't say. Shared schemas and a linter turn those into review comments instead of support tickets.
AdvancedWhat swagger-parser checks, and what it doesn't

"The spec validates" can mean very different things. swagger-parser (@apidevtools/swagger-parser) parses the file, resolves every $ref, and checks the structure against the official OpenAPI JSON Schema. A linter such as Redocly (@redocly/openapi-core 1.34.22, installed here directly; openapi-typescript uses it too) or Spectral applies rules on top: semantic checks the JSON Schema can't express, plus style rules. The model changes a small spec in nine ways and runs both:

what-checkers-catch.mjs
// The same small spec, broken in different ways, run through two checkers.
import SwaggerParser from "@apidevtools/swagger-parser";
import { lintFromString, createConfig } from "@redocly/openapi-core";
import { parse, stringify } from "yaml";

const base = parse(`
openapi: 3.1.1
info: { title: Room Device API, version: 1.4.0, license: { name: Apache 2.0, identifier: Apache-2.0 } }
servers: [{ url: "https://{deviceAddress}/api/v1", variables: { deviceAddress: { default: 192.168.1.20 } } }]
security: [{ bearerAuth: [] }]
paths:
  /audio:
    get:
      operationId: getAudioSettings
      summary: Read the audio settings.
      responses:
        "200":
          description: Current audio settings.
          content: { application/json: { schema: { $ref: "#/components/schemas/AudioSettings" } } }
        "401": { description: No valid session or token. }
components:
  schemas:
    AudioSettings:
      type: object
      required: [volume, muted]
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer }
`);
const getOp = { operationId: "getAudioSettings", summary: "Read an operation.", responses: base.paths["/audio"].get.responses };
const cases = {
  "the spec as written": () => {},
  "$ref to a missing schema": (d) => (d.paths["/audio"].get.responses["200"].content["application/json"].schema.$ref = "#/components/schemas/Audio"),
  "webhooks only, no paths": (d) => { d.webhooks = { statusChanged: { post: getOp } }; delete d.paths; },
  "components only, no paths": (d) => delete d.paths,
  'openapi: "3.2.0"': (d) => (d.openapi = "3.2.0"),
  "server variable with a description": (d) => (d.servers[0].variables.deviceAddress.description = "Device IP"),
  "duplicate operationId": (d) => (d.paths["/operations"] = { get: getOp }),
  "path parameter never declared": (d) => (d.paths["/operations/{operationId}"] = { get: { ...getOp, operationId: "getOperation" } }),
  "typo: type: integr": (d) => (d.components.schemas.AudioSettings.properties.volume.type = "integr"),
  "3.0 keyword: nullable: true": (d) => (d.components.schemas.AudioSettings.properties.muted.nullable = true),
};
const config = await createConfig({ extends: ["recommended"] });

for (const [name, breakIt] of Object.entries(cases)) {
  const doc = structuredClone(base);
  breakIt(doc);
  let parser;
  try {
    await SwaggerParser.validate(structuredClone(doc));
    parser = "valid";
  } catch (e) {
    parser = e.message.split("\n").filter(Boolean).slice(0, 2).join(" ").replace(/\s+/g, " ");
  }
  const problems = await lintFromString({ source: stringify(doc), absoluteRef: "openapi.yaml", config });
  const errors = [...new Set(problems.filter((p) => p.severity === "error").map((p) => p.ruleId))];
  console.log(`${name}\n  swagger-parser: ${parser}\n  Redocly lint:   ${errors.join(", ") || "no errors"}`);
}
Result
the spec as written
  swagger-parser: valid
  Redocly lint:   no errors
$ref to a missing schema
  swagger-parser: Missing $ref pointer "#/components/schemas/Audio". Token "Audio" does not exist.
  Redocly lint:   no-unresolved-refs
webhooks only, no paths
  swagger-parser: valid
  Redocly lint:   no errors
components only, no paths
  swagger-parser: [object Object] is not a valid Openapi API definition
  Redocly lint:   no errors
openapi: "3.2.0"
  swagger-parser: Unsupported OpenAPI version: 3.2.0. Swagger Parser only supports versions 3.1.0, 3.1.1, 3.1.2, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4
  Redocly lint:   no errors
server variable with a description
  swagger-parser: Swagger schema validation failed. #/servers/0/variables/deviceAddress must NOT have unevaluated properties
  Redocly lint:   no errors
duplicate operationId
  swagger-parser: valid
  Redocly lint:   operation-operationId-unique
path parameter never declared
  swagger-parser: valid
  Redocly lint:   path-parameters-defined
typo: type: integr
  swagger-parser: valid
  Redocly lint:   struct
3.0 keyword: nullable: true
  swagger-parser: valid
  Redocly lint:   struct
What’s happening
  1. A broken $ref is caught by both. swagger-parser can't resolve #/components/schemas/Audio and says which token is missing. Resolving references is its core job, which is why it's the right tool for "bundle this multi-file spec into one" and "dereference it for my validator".
  2. Webhooks-only is valid in 3.1, and swagger-parser 13.1 knows it. Components-only is also valid in 3.1 (a file of shared schemas is a legitimate document), but swagger-parser 13.1 rejects it with the odd message "[object Object] is not a valid Openapi API definition": its parser accepts webhooks in place of paths, and nothing else. openapi: "3.2.0" is rejected by swagger-parser with the list of versions it supports, while Redocly 1.34 lints it with 3.1 rules.
  3. The server variable with a description is a validator bug, not a spec bug. The 3.1 specification allows description on a Server Variable Object, but swagger-parser 13.1 validates against the OpenAPI 3.1 schema iteration dated 2021-04-15 (bundled in @apidevtools/openapi-schemas 2.1.0), which misspells the property as descriptions. The official schema at spec.openapis.org has the typo in its 2021-04-15, 2021-05-20 and 2021-09-28 iterations and the fix from 2022-02-27 on (2022-02-27, 2022-10-07 and 2025-09-15 checked; all fetched 2026-10-27). That's why the full spec above has no description on deviceAddress. Validators are software with versions; pin them and know their bugs.
  4. Duplicate operationId and an undeclared path parameter pass swagger-parser. For OpenAPI 3 documents its extra semantic checks don't run at all: lib/validators/spec.js starts with if (api.openapi) { // We don't (yet) support validating against the OpenAPI spec; return; }. Those checks only exist for Swagger 2.0. Redocly catches both (operation-operationId-unique, path-parameters-defined).
  5. type: integr and nullable: true also pass swagger-parser, because to work around an Ajv limitation with $dynamicRef it validates every schema inside components only as "an object or a boolean" (lib/validators/schema.js replaces the schema definition with that). Redocly's struct rule catches both. A mistyped type matters at runtime: Ajv checks schemas against the JSON Schema meta-schema and refuses to compile one with type: integr ("data/type must be equal to one of the allowed values"), so the server fails at startup; a tool that doesn't check schemas may simply ignore the bad keyword.
  6. The takeaway: run both in CI. swagger-parser (or the bundler of your docs tool) proves the file resolves; a linter with the recommended rules, plus a few rules of your own (every operation has a 4XX response, error responses use application/problem+json, operationIds are camelCase), proves it makes sense.
AdvancedExamples are tested too

Examples are the part of the docs people copy. They are also the part nobody checks: a schema changes, the example doesn't, and partners paste an example the API rejects. Because examples and schemas sit side by side in the spec, a few lines of code can validate every example against its own schema:

check-examples.mjs
// Every example in the spec must validate against its own schema, or the docs are lying.
import SwaggerParser from "@apidevtools/swagger-parser";
import Ajv2020 from "ajv/dist/2020.js";
import { parse } from "yaml";

const spec = parse(`
openapi: 3.1.1
info: { title: Room Device API, version: 1.4.0 }
paths:
  /audio:
    get:
      responses:
        "200":
          description: Current audio settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AudioSettings" }
              examples:
                meetingRoom: { value: { volume: 62, muted: false, speaker: SPEAKER_BOTH } }
                copiedFromOldGuide: { value: { volume: "62", muted: false, speaker: BOTH } }
    patch:
      requestBody:
        content:
          application/merge-patch+json:
            schema: { $ref: "#/components/schemas/AudioSettingsPatch" }
            examples:
              mute: { value: { muted: true } }
              louder: { value: { volume: 80, balance: 0 } }
      responses: { "200": { description: The settings after the change. } }
components:
  schemas:
    AudioSettings:
      type: object
      required: [volume, muted, speaker]
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
    SpeakerSelection: { type: string, enum: [SPEAKER_LEFT, SPEAKER_RIGHT, SPEAKER_BOTH] }
    AudioSettingsPatch:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
`);

const api = await SwaggerParser.dereference(spec); // every $ref replaced by the object it points to
const ajv = new Ajv2020({ allErrors: true });
const describe = (e) => `${e.instancePath || "/"} ${e.message}${e.params.additionalProperty ? ` (${e.params.additionalProperty})` : ""}`;
let bad = 0;
for (const [path, item] of Object.entries(api.paths)) {
  for (const [method, op] of Object.entries(item)) {
    const bodies = [["request", op.requestBody], ...Object.entries(op.responses).map(([code, r]) => [code, r])];
    for (const [where, holder] of bodies) {
      for (const [type, media] of Object.entries(holder?.content ?? {})) {
        const validate = ajv.compile(media.schema);
        for (const [name, example] of Object.entries(media.examples ?? {})) {
          const ok = validate(example.value);
          if (!ok) bad++;
          const why = ok ? "" : "  " + validate.errors.map(describe).join("; ");
          console.log(`${ok ? "ok " : "BAD"} ${method.toUpperCase()} ${path} ${where} ${type} "${name}"${why}`);
        }
      }
    }
  }
}
console.log(`${bad} example(s) don't match their schema`);
Result
ok  GET /audio 200 application/json "meetingRoom"
BAD GET /audio 200 application/json "copiedFromOldGuide"  /volume must be integer; /speaker must be equal to one of the allowed values
ok  PATCH /audio request application/merge-patch+json "mute"
BAD PATCH /audio request application/merge-patch+json "louder"  / must NOT have additional properties (balance)
2 example(s) don't match their schema
What’s happening
  1. SwaggerParser.dereference replaces every $ref with the object it points to, so each media type's schema is a complete schema Ajv can compile directly. Note that it changes the object you pass in, in place; pass a copy if you still need the original.
  2. The loop visits each operation's request body and every response, and for each media type compiles its schema once and validates each named example's value.
  3. meetingRoom and mute pass. copiedFromOldGuide fails twice: "62" is a string where the schema says integer, and BOTH isn't one of the SpeakerSelection values. That's what happens when an example is copied from an older document instead of produced by the API.
  4. louder fails because balance isn't a property of AudioSettingsPatch, which has additionalProperties: false. An example that sends an unknown field teaches partners that unknown fields are fine; the server's 422 then surprises them.
  5. In CI, end the script with process.exitCode = bad ? 1 : 0 so a wrong example fails the build. Redocly's recommended rules include a similar check (no-invalid-media-type-examples); the point is that something runs it on every change.
AdvancedPractical notes for a device spec
  • Keep the spec in the repository of the code that serves it, reviewed in the same pull request as the change. A spec in a wiki or a separate repo drifts within a release.
  • Serve it from the device. GET /api/v1/openapi.json on the device tells a client exactly which version and operations this firmware has, which helps support engineers and partners with mixed fleets (more in Capability discovery). Keep the served file small: JSON, no embedded docs UI.
  • Split large specs into files (paths/audio.yaml, schemas/Problem.yaml) with relative $refs, and bundle them into one file in the build. swagger-parser's bundle() and Redocly's bundle command both do this.
  • Name things for the long run. Schema names and operationIds become type and method names in generated code; renaming them is a breaking change for SDK users even when the JSON on the wire is identical.
  • Describe the device's real limits in the spec: maxLength on strings, maxItems on lists, the request size limit in info.description. Constrained devices reject large inputs anyway; the spec should say so before a partner finds out.

#Validating requests and responses against the spec

Added

A spec only helps if the running API matches it. Runtime validation makes the spec executable: at startup the server compiles the schemas from the spec into validator functions, every incoming request is checked before a handler sees it, and every outgoing response is checked before it leaves (always in tests and staging; in production, usually logged rather than blocked). The spec stops being a description of what the code is supposed to do and becomes a guard on what it does.

The mental model is a customs desk at both borders. Inbound, every request is compared with the manifest: wrong shape, wrong type, unknown field, wrong media type, and it's turned back at the border with a clear reason, so handlers only ever see valid data. Outbound, every response is compared with the same manifest, which catches the bugs clients would otherwise find for you: a field that's 0/1 instead of false/true, a property that went missing in a refactor, a null where the spec promised a string.

For a device this matters twice. The device API is the one thing every client depends on (the web UI, partner integrations, the management cloud, SDKs), and the device often sits on a slow release train: a contract bug that ships stays in the field for months. And device handlers are frequently thin layers over native code (a system service, a vendor HAL, a C library), where types are looser than the JSON contract promises.

Client web UI, SDK Body parser Request validator media type, body Handler native layer Response validator per status code openapi.yaml → compiled once at startup valid response → 200 with the body rejected → 400/415/422 problem+json bad response log; 500 in tests handlers only ever see requests that match the spec
Validation on both sides of the handler, with validators compiled from the spec once, at startup.

Here's the pattern in Express 5 with Ajv, using the audio part of the spec. A small enforce(path, method) function builds the checks for one operation from the spec; the "device" has a realistic bug in its native layer:

validate-at-runtime.mjs
// Express 5 enforcing the spec: requests are checked before the handler, responses after it.
import express from "express";
import request from "supertest";
import SwaggerParser from "@apidevtools/swagger-parser";
import Ajv2020 from "ajv/dist/2020.js";
import { parse } from "yaml";

const spec = parse(`
openapi: 3.1.1
info: { title: Room Device API, version: 1.4.0 }
paths:
  /audio:
    get:
      operationId: getAudioSettings
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AudioSettings" } } } }
    patch:
      operationId: updateAudioSettings
      requestBody:
        required: true
        content:
          application/merge-patch+json: { schema: { $ref: "#/components/schemas/AudioSettingsPatch" } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AudioSettings" } } } }
        "400": { description: Malformed JSON, content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
        "422": { description: Invalid values, content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
components:
  schemas:
    AudioSettings:
      type: object
      required: [volume, muted, speaker]
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
    SpeakerSelection: { type: string, enum: [SPEAKER_LEFT, SPEAKER_RIGHT, SPEAKER_BOTH] }
    AudioSettingsPatch:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
    Problem:
      type: object
      required: [type, title, status, code]
      properties:
        type: { type: string }
        title: { type: string }
        status: { type: integer }
        code: { type: string }
        errors: { type: array, items: { type: object, properties: { pointer: { type: string }, detail: { type: string } } } }
`);

const api = await SwaggerParser.dereference(spec);
const ajv = new Ajv2020({ allErrors: true });
const problem = (res, status, code, title, errors) =>
  res.status(status).type("application/problem+json").json({ type: `/problems/${code}`, title, status, code, errors });

// Built once at startup from the spec: one request check and one response check per operation.
function enforce(path, method) {
  const op = api.paths[path][method];
  const bodies = op.requestBody?.content ?? {};
  const checkBody = Object.fromEntries(Object.entries(bodies).map(([type, m]) => [type, ajv.compile(m.schema)]));
  const checkResponse = Object.fromEntries(Object.entries(op.responses).map(([code, r]) => {
    const media = Object.values(r.content ?? {})[0];
    return [code, media && ajv.compile(media.schema)];
  }));
  return (req, res, next) => {
    // Wrap res.json first, so every body this operation sends, error bodies included, is checked.
    const json = res.json.bind(res);
    res.json = (body) => {
      const validate = checkResponse[res.statusCode];
      if (validate && !validate(body)) {
        console.log(`  [contract] ${op.operationId} ${res.statusCode}: ${ajv.errorsText(validate.errors)}`);
        res.json = json;
        return problem(res, 500, "RESPONSE_CONTRACT_VIOLATION", "The device produced an invalid response");
      }
      return json(body);
    };
    if (op.requestBody) {
      const type = req.get("content-type")?.split(";")[0];
      const validate = checkBody[type];
      if (!validate) return problem(res, 415, "UNSUPPORTED_MEDIA_TYPE", `Send ${Object.keys(bodies).join(" or ")}`);
      if (!validate(req.body)) {
        const errors = validate.errors.map((e) => ({
          pointer: `#${e.instancePath}${e.params.additionalProperty ? `/${e.params.additionalProperty}` : ""}`,
          detail: e.message,
        }));
        return problem(res, 422, "VALIDATION_FAILED", "The request body doesn't match the API contract", errors);
      }
    }
    next();
  };
}

// The "device": the native audio layer reports muted as 0/1, and the GET handler forgets to convert it.
const nativeAudio = { volume: 62, muted: 0, speaker: "SPEAKER_BOTH" };
const app = express();
app.use(express.json({ type: ["application/json", "application/merge-patch+json"] }));
app.get("/api/v1/audio", enforce("/audio", "get"), (req, res) => res.json(nativeAudio));
app.patch("/api/v1/audio", enforce("/audio", "patch"), (req, res) => {
  Object.assign(nativeAudio, req.body, "muted" in req.body ? { muted: req.body.muted ? 1 : 0 } : {});
  res.json({ ...nativeAudio, muted: nativeAudio.muted === 1 });
});
// express.json() rejects a body it can't parse before enforce runs; answer that with problem details too.
app.use((err, req, res, next) =>
  err.type === "entity.parse.failed" ? problem(res, 400, "MALFORMED_JSON", "The request body isn't valid JSON") : next(err));

const patch = (body, type = "application/merge-patch+json") =>
  request(app).patch("/api/v1/audio").set("Content-Type", type).send(typeof body === "string" ? body : JSON.stringify(body));
const show = (label, { status, body }) => {
  const detail = body.code ? [body.code, ...(body.errors ?? []).map((e) => `${e.pointer} ${e.detail}`)].join("  ") : JSON.stringify(body);
  console.log(`${label.padEnd(26)} → ${status} ${detail}`);
};

show("PATCH { volume: 70 }", await patch({ volume: 70 }));
show("PATCH { volume: 150 }", await patch({ volume: 150 }));
show("PATCH { volme: 70 }", await patch({ volme: 70 }));
show("PATCH {}", await patch({}));
show(`PATCH '{"volume": 70'`, await patch('{"volume": 70'));
show("PATCH as application/json", await patch({ muted: true }, "application/json"));
show("GET /audio", await request(app).get("/api/v1/audio"));
Result
PATCH { volume: 70 }       → 200 {"volume":70,"muted":false,"speaker":"SPEAKER_BOTH"}
PATCH { volume: 150 }      → 422 VALIDATION_FAILED  #/volume must be <= 100
PATCH { volme: 70 }        → 422 VALIDATION_FAILED  #/volme must NOT have additional properties
PATCH {}                   → 422 VALIDATION_FAILED  # must NOT have fewer than 1 properties
PATCH '{"volume": 70'      → 400 MALFORMED_JSON
PATCH as application/json  → 415 UNSUPPORTED_MEDIA_TYPE
  [contract] getAudioSettings 200: data/muted must be boolean
GET /audio                 → 500 RESPONSE_CONTRACT_VIOLATION
What’s happening
  1. Startup: SwaggerParser.dereference inlines the $refs, and enforce("/audio", "patch") compiles one Ajv validator per request media type and one per response status code. Compiling is the slow part (Ajv generates a JavaScript function from each schema), so it happens once, not per request. Ajv2020 is the JSON Schema 2020-12 build of Ajv (ajv/dist/2020.js), matching what OpenAPI 3.1 schemas are.
  2. PATCH { volume: 70 } has the right media type (application/merge-patch+json, which express.json was told to parse), passes AudioSettingsPatch, reaches the handler, and the response passes AudioSettings: 200 with the merged settings. The handler converts muted back to a boolean on this path, so its response is valid.
  3. The three 422s never reach the handler. Each body is well-formed JSON that breaks the schema, which is what 422 Unprocessable Content means (the same split as in Methods, status codes and idempotency for device APIs): volume: 150 breaks maximum: 100; volme breaks additionalProperties: false, and the middleware turns Ajv's params.additionalProperty into the pointer #/volme so the client sees which field is wrong; {} breaks minProperties: 1, and its pointer is #, the whole document. With allErrors: true Ajv reports every failure, not just the first, and each becomes an entry in the problem's errors list: pointer and detail, the member names RFC 9457 uses in its own example, with the pointer written as a URI fragment (#/volume) as in Errors with problem details. The 422 body itself is checked against the spec's Problem schema on the way out, because 422 is a declared response and enforce wraps res.json before it validates the request. (Making detail required in the trimmed Problem schema turns each of these into a logged contract violation and a 500, so the check is real.)
  4. A body that isn't JSON at all gets 400. '{"volume": 70' is cut off, so express.json() fails to parse it (error type entity.parse.failed) before enforce runs; without the error handler at the end, Express would answer with its default 400 HTML page. The handler turns it into problem details with MALFORMED_JSON. Because enforce never ran for this request, the 400 body isn't checked against the spec here.
  5. application/json gets 415. The spec only lists application/merge-patch+json for this operation, so the request is refused even though the JSON inside is fine. That's deliberate: merge-patch semantics (null deletes a field) differ from "here's some JSON", and the media type is how the client says which it means. If you want to be lenient, list both media types in the spec; don't accept undeclared ones in code. (This 415 isn't checked on the way out because the trimmed spec in the model doesn't declare it; the full spec does.)
  6. GET /audio is the outbound catch. The native layer reports muted: 0, and the GET handler passes it straight through. The response validator for 200 fails ("data/muted must be boolean"), logs a contract violation with the operationId, and sends 500 instead of a body that would break every typed client. In tests that's exactly what you want: the first test that reads /audio fails with the reason. In production most teams log and still send the response (or sample a percentage), because a contract bug that has shipped is better reported than turned into an outage.
  7. What this demonstrates: the spec is enforced from one place, generically. Adding an operation to the spec and wrapping its route in enforce gives it the same checks, and the handler code contains no validation at all.
AdvancedQuery and path parameters arrive as strings

Bodies are JSON, so 70 arrives as a number. Query and path parameters arrive as strings: ?limit=50 gives "50", which fails type: integer. Validators handle this with coercion: Ajv's coerceTypes: true converts "50" to 50 when the schema says integer, and useDefaults: true fills in a parameter's default. Both change the data in place, which runs into an Express 5 detail:

query-params.mjs
// Query parameters arrive as strings. Coerce them with the spec's schemas, and keep the result.
import express from "express";
import request from "supertest";
import Ajv2020 from "ajv/dist/2020.js";

// The parameters of a GET /system/logs operation, as they'd appear in the spec.
const parameters = [
  { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 500, default: 100 } },
  { name: "level", in: "query", schema: { type: "string", enum: ["debug", "info", "warn", "error"] } },
];
const querySchema = {
  type: "object",
  properties: Object.fromEntries(parameters.map((p) => [p.name, p.schema])),
  required: parameters.filter((p) => p.required).map((p) => p.name),
  additionalProperties: false,
};
const ajv = new Ajv2020({ allErrors: true, coerceTypes: true, useDefaults: true });
const validateQuery = ajv.compile(querySchema);

const app = express();
app.get("/system/logs", (req, res, next) => {
  const query = { ...req.query }; // Express 5 re-parses req.query on every read, so work on a copy
  if (!validateQuery(query)) {
    return res.status(400).json({ errors: validateQuery.errors.map((e) => `${e.instancePath || "/"} ${e.message}`) });
  }
  res.locals.query = query; // the coerced, defaulted values live here
  next();
}, (req, res) => {
  res.json({ validated: res.locals.query, raw: req.query });
});

for (const qs of ["?limit=50&level=warn", "", "?limit=abc", "?limit=10&limit=20", "?level=verbose", "?lmit=5"]) {
  const { status, body } = await request(app).get(`/system/logs${qs}`);
  console.log(`${(qs || "(no query)").padEnd(22)} → ${status} ${JSON.stringify(body)}`);
}
Result
?limit=50&level=warn   → 200 {"validated":{"limit":50,"level":"warn"},"raw":{"limit":"50","level":"warn"}}
(no query)             → 200 {"validated":{"limit":100},"raw":{}}
?limit=abc             → 400 {"errors":["/limit must be integer"]}
?limit=10&limit=20     → 400 {"errors":["/limit must be integer"]}
?level=verbose         → 400 {"errors":["/level must be equal to one of the allowed values"]}
?lmit=5                → 400 {"errors":["/ must NOT have additional properties"]}
What’s happening
  1. The operation's parameters (as they'd appear in the spec, for a GET /system/logs that isn't in this chapter's spec) are turned into one object schema: each parameter becomes a property, required parameters become required. That lets one Ajv validator check the whole query at once.
  2. ?limit=50&level=warn: Ajv coerces limit from "50" to 50 and the request passes. The handler reads the coerced copy in res.locals.query (limit: 50, a number) while req.query.limit is still the string "50".
  3. That's why the middleware copies first. In Express 5, req.query is a getter that re-parses the URL on every read (defineGetter(req, 'query', …) in lib/request.js) and can't be assigned: running req.query = {…} in an ES module throws "Cannot set property query of #<IncomingMessage> which has only a getter". Coercing req.query in place, as many Express 4 tutorials and middlewares do, silently does nothing in Express 5: the next read gets fresh strings.
  4. With no query, useDefaults adds limit: 100 from the schema's default. ?limit=abc and ?level=verbose fail as you'd expect, with 400 rather than the 422 a bad body gets: RFC 9110 defines 422 in terms of the request content, and a bad query parameter is part of the request target, not the content. (A real device would send these as problem details too; the model keeps the body short.) ?limit=10&limit=20 fails too: Express 5's default "simple" query parser (Node's querystring) turns a repeated key into an array, ["10", "20"], which isn't an integer. If a parameter is meant to repeat, the spec should say type: array (with style and explode), and the validator will accept the array.
  5. ?lmit=5 fails because the query schema has additionalProperties: false. That catches typos, but it's a compatibility decision: a newer client that sends a parameter an older firmware doesn't know will be refused by the old device instead of having the parameter ignored. For a fleet on mixed firmware, ignoring unknown query parameters (and saying so in the docs) is often the better default; see Rules for compatible change.
AdvancedFormats are not checked unless you add them

format: date-time, uri, email and friends look like constraints, but the OpenAPI 3.1 specification says format is treated as an annotation rather than validated directly, unless the tool chooses to do more. Ajv's core doesn't include format checks; they come from the separate ajv-formats package (not installed in this sandbox), and without it Ajv's default strict mode refuses to compile a schema that uses an unknown format:

formats.mjs
import Ajv2020 from "ajv/dist/2020.js";

const event = { type: "object", properties: { occurredAt: { type: "string", format: "date-time" } } };

try {
  new Ajv2020().compile(event);
} catch (e) {
  console.log(`default (strict) Ajv: ${e.message}`);
}

const lenient = new Ajv2020({ validateFormats: false }).compile(event);
console.log(`validateFormats: false → "yesterday" accepted: ${lenient({ occurredAt: "yesterday" })}`);

const ajv = new Ajv2020();
ajv.addFormat("date-time", (s) => /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|[+-]\d\d:\d\d)$/.test(s) && !isNaN(Date.parse(s)));
const strict = ajv.compile(event);
for (const value of ["2026-10-27T09:30:00Z", "2026-10-27 09:30", "yesterday"]) {
  console.log(`addFormat("date-time") → ${JSON.stringify(value)} accepted: ${strict({ occurredAt: value })}`);
}
Result
default (strict) Ajv: unknown format "date-time" ignored in schema at path "#/properties/occurredAt"
validateFormats: false → "yesterday" accepted: true
addFormat("date-time") → "2026-10-27T09:30:00Z" accepted: true
addFormat("date-time") → "2026-10-27 09:30" accepted: false
addFormat("date-time") → "yesterday" accepted: false
What’s happening
  1. Strict mode (the default) throws at compile time: "unknown format "date-time" ignored in schema at path "#/properties/occurredAt"". That's Ajv protecting you from believing a format is checked when it isn't. The DeviceEvent schema in this chapter's spec has occurredAt: { format: date-time }, so compiling it needs one of the next two options.
  2. validateFormats: false makes Ajv ignore formats, which is what the specification's "annotation" reading means: "yesterday" is accepted as a date-time. Fine for docs-driven tools; a poor guard on a device API.
  3. addFormat registers a check. The one here is a short regular expression for RFC 3339 timestamps plus Date.parse; in a real project use ajv-formats, which implements the common formats properly. Now "2026-10-27 09:30" (no T, no offset) and "yesterday" are rejected.
  4. The lesson for a spec author: if a value's format matters (timestamps in events, URLs a device will fetch, IP addresses in network settings), make sure the validator in the server actually checks it, and say in the spec's description what's enforced. Partners will assume format is enforced; often it isn't.
AdvancedWhere validation belongs, and what it costs
  • Validate on the device, always. The web UI validating its forms is a convenience for the user; the device validating requests is the security and integrity boundary. Anyone with a token can call the API with curl. The admin UI can use the same schemas for its forms (see Forms generated from a settings schema).
  • Compile once, validate many. Compiled Ajv validators are plain functions; checking a small settings object is cheap compared with the request around it. The expensive mistake is calling ajv.compile inside a handler, which compiles on every request.
  • Response validation in production is a judgement call: it costs CPU on a constrained device and turns contract bugs into errors. Common choices are "on in tests and staging, log-only in production" or "validate a sample".
  • Libraries wrap this pattern. express-openapi-validator and similar middleware read the spec and register request and response validation for every route; check that the one you choose supports OpenAPI 3.1 and the Express version you run before adopting it. The hand-written enforce above is about 40 lines and shows everything those libraries do at their core.
  • Don't let validation leak internals. Ajv's messages ("must be <= 100") are fine for clients; schema paths and stack traces aren't. Map errors to a stable code and JSON Pointers, as the model does.
Why does a valid body get 415?

The web UI sends PATCH /api/v1/audio with body {"muted": true} and header Content-Type: application/json. The body matches AudioSettingsPatch exactly, yet the device replies 415. Why, and what's the right fix?

Show answer

The operation's requestBody.content only lists application/merge-patch+json, so application/json is an unsupported media type for this operation, whatever the body contains. The fix is in the client (send the declared media type), or in the spec (declare application/json too, with its own schema), never in an undeclared exception in the server code.

What’s happening
  1. The middleware looks up the validator by the request's media type: checkBody["application/json"] is undefined, because the spec's content map for this operation has one key, application/merge-patch+json.
  2. With no validator for that media type, the request is refused with 415 Unsupported Media Type before the body is even looked at. In the model above that's the "PATCH as application/json → 415 UNSUPPORTED_MEDIA_TYPE" line.
  3. The media type matters for PATCH: with application/merge-patch+json (RFC 7396), a null value means "remove this field"; with JSON Patch (application/json-patch+json, RFC 6902) the body is a list of operations. Plain application/json doesn't say which semantics the client intends (see Partial updates).
  4. If the team decides plain JSON should mean "merge patch" for convenience, the change goes in the spec: add application/json to content with the same schema. Then validation, docs, generated types and diffs all agree, and a partner reading the spec knows it's allowed.

#Generating types, clients and docs

Added

Once the spec is the source of truth, everything else that describes the API can be generated from it: TypeScript types for the web UI, typed client functions, reference docs, mock servers and test fixtures. Generation is what makes design-first pay for itself: one edit to the spec updates every artefact, and a field renamed in the spec becomes a compile error in every caller that still uses the old name.

The mental model: the spec is the master recording, generated files are pressings. You can press as many copies as you like, in different formats, but you never edit a pressing. If the generated api.d.ts is wrong, you fix the spec (or the generator's options) and press again. A hand-edited generated file is drift with extra steps.

Three kinds of output, with different trade-offs:

  • Types only (openapi-typescript): zero runtime code, the types describe paths, operations and schemas, and you write calls with fetch or a tiny typed wrapper. Small and transparent; good for a web UI.
  • Generated clients (OpenAPI Generator and similar tools, in many languages): a class per tag with a method per operation. Convenient for partners in Java, C# or Python; more code to review and a generator's style to live with.
  • Docs (Redoc, Swagger UI, Scalar and others): an HTML reference rendered from the spec, with examples and often a "try it" console.

Here's openapi-typescript 7.13 on the audio part of the spec (the same engine as npx openapi-typescript openapi.yaml -o api.d.ts), printing exactly what it generates:

generate-types.mjs
// openapi-typescript turns the spec into TypeScript types: no runtime code, just shapes.
import openapiTS, { astToString } from "openapi-typescript";
import { parse } from "yaml";

const spec = parse(`
openapi: 3.1.1
info: { title: Room Device API, version: 1.4.0 }
paths:
  /audio:
    patch:
      operationId: updateAudioSettings
      requestBody:
        required: true
        content:
          application/merge-patch+json: { schema: { $ref: "#/components/schemas/AudioSettingsPatch" } }
      responses:
        "200":
          description: The settings after the change.
          content: { application/json: { schema: { $ref: "#/components/schemas/AudioSettings" } } }
components:
  schemas:
    AudioSettings:
      type: object
      required: [volume, muted, speaker]
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
    SpeakerSelection: { type: string, enum: [SPEAKER_LEFT, SPEAKER_RIGHT, SPEAKER_BOTH] }
    AudioSettingsPatch:
      description: Fields to change; fields you leave out keep their value.
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
    DeviceStatus:
      type: object
      required: [firmwareVersion, lastError]
      properties:
        firmwareVersion: { type: string, pattern: '^\\d+\\.\\d+\\.\\d+$' }
        lastError: { description: "The last fault reported, or null if there is none.", type: [string, "null"] }
`);

const ast = await openapiTS(spec); // same engine as: npx openapi-typescript openapi.yaml -o api.d.ts
console.log(astToString(ast));
Result
export interface paths {
    "/audio": {
        parameters: {
            query?: never;
            header?: never;
            path?: never;
            cookie?: never;
        };
        get?: never;
        put?: never;
        post?: never;
        delete?: never;
        options?: never;
        head?: never;
        patch: operations["updateAudioSettings"];
        trace?: never;
    };
}
export type webhooks = Record<string, never>;
export interface components {
    schemas: {
        AudioSettings: {
            volume: number;
            muted: boolean;
            speaker: components["schemas"]["SpeakerSelection"];
        };
        /** @enum {string} */
        SpeakerSelection: "SPEAKER_LEFT" | "SPEAKER_RIGHT" | "SPEAKER_BOTH";
        /** @description Fields to change; fields you leave out keep their value. */
        AudioSettingsPatch: {
            volume?: number;
            muted?: boolean;
            speaker?: components["schemas"]["SpeakerSelection"];
        };
        DeviceStatus: {
            firmwareVersion: string;
            /** @description The last fault reported, or null if there is none. */
            lastError: string | null;
        };
    };
    responses: never;
    parameters: never;
    requestBodies: never;
    headers: never;
    pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
    updateAudioSettings: {
        parameters: {
            query?: never;
            header?: never;
            path?: never;
            cookie?: never;
        };
        requestBody: {
            content: {
                "application/merge-patch+json": components["schemas"]["AudioSettingsPatch"];
            };
        };
        responses: {
            /** @description The settings after the change. */
            200: {
                headers: {
                    [name: string]: unknown;
                };
                content: {
                    "application/json": components["schemas"]["AudioSettings"];
                };
            };
        };
    };
}
What’s happening
  1. paths has one entry per path, with every HTTP method listed: patch points at operations["updateAudioSettings"] and the others are ?: never, so code that tries paths["/audio"]["delete"] gets never. The operationId from the spec became the key in operations, which is why stable operationIds matter.
  2. components.schemas mirrors the spec's schemas. required decides optionality: AudioSettings has volume: number (required) while AudioSettingsPatch has volume?: number. SpeakerSelection's enum becomes a union of string literals, and the speaker property refers to it by name rather than repeating it.
  3. type: [string, "null"] became string | null, and the property is still required: the type says "always present, possibly null", which is what the spec says. The descriptions became JSDoc comments, so they show up in editor hovers.
  4. What's lost: type: integer became number (TypeScript has no integer type), and minimum, maximum, pattern, minProperties and additionalProperties: false have no TypeScript equivalent, so none of them appear. Types check shapes, not values. That's why the server still validates at runtime, and why the UI needs the same constraints for form validation from somewhere other than the types.
  5. operations.updateAudioSettings spells out the whole operation: the request body type per media type, and each response by status code with its headers and content. A typed client can use this to type client.PATCH("/audio", { body }) so the body must be an AudioSettingsPatch and the result is an AudioSettings.
AdvancedWhat the generated types catch in UI code

Types are only useful if the compiler checks real calls against them. This model generates the types, writes a small piece of web UI code that calls the PATCH, and runs the TypeScript compiler on it in memory: TypeScript 5.9.3, installed in this site's sandbox as openapi-typescript's peer dependency (ts.version there is 5.9.3). Each line of the calling code is printed with what the compiler said; long type names in the messages are shortened to { … } by the model:

typecheck-ui.mjs
// Generate the types, then type-check web UI code against them with the TypeScript compiler.
import openapiTS, { astToString } from "openapi-typescript";
import ts from "typescript";
import { parse } from "yaml";

const spec = parse(`
openapi: 3.1.1
info: { title: Room Device API, version: 1.4.0 }
paths:
  /audio:
    patch:
      operationId: updateAudioSettings
      requestBody:
        required: true
        content:
          application/merge-patch+json: { schema: { $ref: "#/components/schemas/AudioSettingsPatch" } }
      responses:
        "200":
          description: The settings after the change.
          content: { application/json: { schema: { $ref: "#/components/schemas/AudioSettings" } } }
components:
  schemas:
    AudioSettings:
      type: object
      required: [volume, muted, speaker]
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
    SpeakerSelection: { type: string, enum: [SPEAKER_LEFT, SPEAKER_RIGHT, SPEAKER_BOTH] }
    AudioSettingsPatch:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
`);

const ui = `import type { paths, components } from "./api";
type AudioSettings = components["schemas"]["AudioSettings"];
type AudioPatch = paths["/audio"]["patch"]["requestBody"]["content"]["application/merge-patch+json"];

async function updateAudio(patch: AudioPatch): Promise<AudioSettings> {
  const res = await fetch("/api/v1/audio", {
    method: "PATCH",
    headers: { "Content-Type": "application/merge-patch+json" },
    body: JSON.stringify(patch),
  });
  return (await res.json()) as AudioSettings;
}

await updateAudio({ volume: 70 });
await updateAudio({ volume: "70" });
await updateAudio({ speaker: "BOTH" });
await updateAudio({ balance: 0 });
await updateAudio({});
await updateAudio({ volume: 150 });
const after = await updateAudio({ muted: true });
console.log(after.speaker, after.lastError);
`;

const files = { "/api.d.ts": astToString(await openapiTS(spec)), "/ui.ts": ui };
const options = { strict: true, noEmit: true, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext,
  moduleResolution: ts.ModuleResolutionKind.Bundler, lib: ["lib.es2022.d.ts", "lib.dom.d.ts"], types: [] };
const host = ts.createCompilerHost(options);
const readDisk = host.getSourceFile;
host.getSourceFile = (name, version) => (name in files ? ts.createSourceFile(name, files[name], version) : readDisk(name, version));
host.fileExists = (name) => name in files || ts.sys.fileExists(name);
host.readFile = (name) => files[name] ?? ts.sys.readFile(name);

const program = ts.createProgram(["/ui.ts"], options, host);
const lines = ui.split("\n");
const errors = new Map(ts.getPreEmitDiagnostics(program).map((d) => [d.file.getLineAndCharacterOfPosition(d.start).line, d]));
for (let i = 13; i < 21; i++) {
  const d = errors.get(i);
  console.log(`${lines[i].padEnd(50)} ${d ? `TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, " ").replace(/ (in|on) type '.*$/, " $1 type '{ … }'")}` : "compiles"}`);
}
Result
await updateAudio({ volume: 70 });                 compiles
await updateAudio({ volume: "70" });               TS2322: Type 'string' is not assignable to type 'number'.
await updateAudio({ speaker: "BOTH" });            TS2322: Type '"BOTH"' is not assignable to type '"SPEAKER_LEFT" | "SPEAKER_RIGHT" | "SPEAKER_BOTH" | undefined'.
await updateAudio({ balance: 0 });                 TS2353: Object literal may only specify known properties, and 'balance' does not exist in type '{ … }'
await updateAudio({});                             compiles
await updateAudio({ volume: 150 });                compiles
const after = await updateAudio({ muted: true });  compiles
console.log(after.speaker, after.lastError);       TS2339: Property 'lastError' does not exist on type '{ … }'
What’s happening
  1. The UI code derives its types from the generated file rather than declaring its own: AudioPatch is paths["/audio"]["patch"]["requestBody"]["content"]["application/merge-patch+json"], the exact request body type. If the spec changes, these types change with it.
  2. Caught at compile time: volume: "70" (TS2322, string isn't number), speaker: "BOTH" (TS2322, not one of the three literals; the message lists them), balance: 0 (TS2353, excess property check on an object literal), and reading after.lastError, a field AudioSettings doesn't have (TS2339). Each of these would otherwise be a 422 from the device or an undefined in the UI.
  3. Not caught: {} and { volume: 150 } compile, because minProperties: 1 and maximum: 100 aren't expressible in the types. The device's runtime validation returns 422 for both, which is why both layers exist.
  4. The excess property check only applies to object literals. A patch built elsewhere and passed as a variable with extra fields compiles; the device rejects it at runtime. Another reason the contract is enforced on the server, not only in the type system.
  5. In a project this runs as npx openapi-typescript openapi.yaml -o src/api.d.ts in the build, followed by tsc --noEmit. If the generated file is committed, CI regenerates it and fails when git diff --exit-code shows a difference, so nobody hand-edits a pressing.
AdvancedTyped clients: thin wrappers or generated SDKs

The openapi-typescript project also publishes openapi-fetch, a client that uses the generated paths type and nothing else. Its docs (openapi-ts.dev, checked 2026-10-27) describe it as weighing 6 kB with "virtually zero runtime". A call looks like this; it isn't run here because openapi-fetch isn't installed in this sandbox:

audio-client.ts (openapi-fetch, not run here)
import createClient from "openapi-fetch";
import type { paths } from "./api"; // generated by openapi-typescript

const client = createClient<paths>({ baseUrl: "https://192.168.1.20/api/v1/" });

const { data, error } = await client.PATCH("/audio", {
  body: { muted: true }, // typed as AudioSettingsPatch
  headers: { "Content-Type": "application/merge-patch+json", "If-Match": etag },
});
if (error) showProblem(error); // typed from the 4XX/5XX responses in the spec
else render(data); // typed as AudioSettings

Per its docs, data is set only for 2XX responses and error only for 4XX and 5XX, which maps neatly onto problem details. For the web UI of a single device API, this "types plus a thin client" approach is usually enough. For partners in other languages, generated SDKs (OpenAPI Generator and similar) save them from writing HTTP code, at the cost of generated code you must version and support. Chapter 10 covers building an SDK on top of the generated types, with retries, timeouts and auth.

AdvancedDocs: what a docs generator does

Redoc and Swagger UI look like large applications, but at their core they walk the spec and print each operation with its parameters, bodies, examples and responses. This model does that walk in plain text for two operations. The layout is close to the per-command tables in printed device API guides like the one discussed in the previous topic, which is the point: a reference guide is a rendering of a spec, and rendering it from the spec means it can't disagree with the API.

generate-docs.mjs
// A tiny reference-docs generator: the same walk Redoc and Swagger UI do, printed as text.
import SwaggerParser from "@apidevtools/swagger-parser";
import { parse } from "yaml";

const spec = parse(`
openapi: 3.1.1
info: { title: Room Device API, version: 1.4.0 }
security: [{ sessionCookie: [] }, { bearerAuth: [] }]
paths:
  /audio:
    patch:
      operationId: updateAudioSettings
      summary: Change some audio settings (JSON Merge Patch).
      parameters: [{ $ref: "#/components/parameters/IfMatch" }]
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: "#/components/schemas/AudioSettingsPatch" }
            examples: { mute: { summary: Mute the room, value: { muted: true } } }
      responses:
        "200": { description: The settings after the change. }
        "400": { description: "The body isn't well-formed JSON, so it couldn't be parsed." }
        "412": { description: The settings changed since your If-Match ETag; read them again. }
        "422": { description: The body is well-formed JSON but breaks the schema; errors lists each one. }
  /system/reboot:
    post:
      operationId: rebootDevice
      summary: Start a reboot and return an operation to poll.
      responses:
        "202": { description: Reboot accepted; poll the operation in Location. }
        "409": { description: Another reboot or update is already running. }
components:
  parameters:
    IfMatch: { name: If-Match, in: header, required: false, schema: { type: string } }
  schemas:
    AudioSettingsPatch:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { type: string, enum: [SPEAKER_LEFT, SPEAKER_RIGHT, SPEAKER_BOTH] }
  securitySchemes:
    sessionCookie: { type: apiKey, in: cookie, name: session }
    bearerAuth: { type: http, scheme: bearer }
`);

const api = await SwaggerParser.dereference(spec);
const describe = (s) => s.enum ? s.enum.join(" | ") : [s.type, s.minimum !== undefined && `${s.minimum}..${s.maximum}`].filter(Boolean).join(" ");
const auth = (api.security ?? []).map((r) => Object.keys(r)[0]).join(" or ");
for (const [path, item] of Object.entries(api.paths)) {
  for (const [method, op] of Object.entries(item)) {
    console.log(`${op.operationId}: ${op.summary}`);
    console.log(`  ${method.toUpperCase()} ${path}    auth: ${auth}`);
    for (const p of op.parameters ?? []) console.log(`  ${p.in} ${p.name}${p.required ? "" : " (optional)"}: ${describe(p.schema)}`);
    for (const [type, media] of Object.entries(op.requestBody?.content ?? {})) {
      console.log(`  body ${type}:`);
      for (const [name, s] of Object.entries(media.schema.properties)) console.log(`    ${name}: ${describe(s)}`);
      for (const ex of Object.values(media.examples ?? {})) console.log(`  example (${ex.summary}): ${JSON.stringify(ex.value)}`);
    }
    for (const [code, r] of Object.entries(op.responses)) console.log(`  ${code}  ${r.description}`);
  }
}
Result
updateAudioSettings: Change some audio settings (JSON Merge Patch).
  PATCH /audio    auth: sessionCookie or bearerAuth
  header If-Match (optional): string
  body application/merge-patch+json:
    volume: integer 0..100
    muted: boolean
    speaker: SPEAKER_LEFT | SPEAKER_RIGHT | SPEAKER_BOTH
  example (Mute the room): {"muted":true}
  200  The settings after the change.
  400  The body isn't well-formed JSON, so it couldn't be parsed.
  412  The settings changed since your If-Match ETag; read them again.
  422  The body is well-formed JSON but breaks the schema; errors lists each one.
rebootDevice: Start a reboot and return an operation to poll.
  POST /system/reboot    auth: sessionCookie or bearerAuth
  202  Reboot accepted; poll the operation in Location.
  409  Another reboot or update is already running.
What’s happening
  1. dereference inlines the If-Match parameter and the patch schema, so the walker can read them directly.
  2. For each operation it prints the operationId and summary, the method and path, and the authentication from the root security (two alternatives, so "sessionCookie or bearerAuth"). The optional If-Match header comes from the shared parameter.
  3. The body section prints each property with its type and range or enum, then the named example with its summary. The volume: integer 0..100 line is something a hand-written table often leaves out.
  4. Responses are listed by status code with their descriptions. rebootDevice has no body, so only its responses appear.
  5. Real tools add navigation, search, schema expansion and code samples. Redoc is built with npx @redocly/cli build-docs openapi.yaml, which writes a single redoc-static.html; its README (checked 2026-10-27) lists support for OpenAPI 3.2, 3.1, 3.0 and Swagger 2.0. Swagger UI adds a "try it out" console; Swagger UI added OpenAPI 3.1.0 support in version 5.0.0 (its release notes, 12 June 2023), and SmartBear announced 3.1 support across its open-source Swagger tooling the next day, 13 June 2023.
AdvancedPractical notes: where generated artefacts live
  • Docs belong on a developer portal, not on the device. A docs UI is megabytes of JavaScript; a constrained device should serve its spec as JSON (for discovery and for tools) and leave rendering to a portal or the developer's machine.
  • Generate in the build, from one pinned generator version. Different generator versions produce different output; pin it in package.json like any other dependency.
  • Mock servers generated from the spec (Prism and others) let the web UI team build against the API before the device implements it, which is the main scheduling benefit of design-first. Mocks return the spec's examples, which is one more reason examples are tested.
  • Commit generated SDKs that partners download (they need a stable, reviewable artefact with a changelog); don't commit generated types used only inside one repository (regenerate them in the build).

#Design-first vs code-first

Added

Every team with an OpenAPI spec has to answer one question: which is the source of truth, the spec file or the code? In design-first (also called spec-first or API-first), people write and review the spec before implementing, and the code is checked against it (runtime validation, contract tests). In code-first, the code is written first, with annotations, decorators or schema objects (Zod, class-validator, framework route metadata), and the spec is generated from it in the build.

The mental model: a blueprint versus an as-built drawing. A blueprint is agreed before anyone pours concrete; the electrician, the plumber and the buyer can all plan from it in parallel, and a change means amending the drawing first. An as-built drawing is produced from what was actually built; it's always accurate about what exists, but nobody could plan from it in advance, and if the builder made a questionable choice, the drawing faithfully records it.

Neither is "right"; they fail differently. Design-first fails by drift (the code quietly stops matching the agreed spec) unless the spec is enforced at runtime and in tests, which is why the previous topics matter. Code-first fails by accident (a refactor renames a field, the generated spec changes, and nobody noticed it was a public contract) unless the generated spec is diffed and reviewed like any other API change.

Design-firstCode-first
Source of truththe spec file, reviewed in PRsthe code; the spec is a build output
Parallel workUI, SDK and partners start from the spec and mocks on day oneothers wait for an implementation
ReviewAPI design reviewed on its own, before codeAPI changes hidden inside code diffs
Drift riskcode drifts from spec unless validatedspec always matches the code, including its mistakes
Expressivenesseverything OpenAPI can sayonly what the generator can emit (refinements, examples and descriptions often lost)
Day-to-day costwrite YAML; tooling to enforce itannotate code; lower friction for small internal APIs

For a team split across time zones (for example, US-based owners and developers in India), design-first is usually the better default for anything other teams or partners consume. The spec PR is an asynchronous design review: a developer in India can propose PATCH /network/wifi as a spec change at the end of their day, the API owner in the US reviews the shape while they sleep, and implementation starts the next morning with no meeting. The UI team can build against a mock generated from the spec while the device team implements it. Lint rules in CI settle style questions mechanically, so review comments are about design.

Code-first can still produce a good spec. Here is the code-first route with Zod 4 (installed here as 4.6): the schemas the handler already validates with are exported as components.schemas with z.toJSONSchema, assembled into a document, and compared with the hand-written design-first schemas from this chapter's spec:

code-first-zod.mjs
// Code-first: the Zod schemas the handler already uses become the spec's components.
import { z } from "zod";
import SwaggerParser from "@apidevtools/swagger-parser";

const SpeakerSelection = z.enum(["SPEAKER_LEFT", "SPEAKER_RIGHT", "SPEAKER_BOTH"]).meta({ id: "SpeakerSelection" });
const AudioSettings = z
  .object({ volume: z.int().min(0).max(100), muted: z.boolean(), speaker: SpeakerSelection })
  .meta({ id: "AudioSettings" });
const AudioSettingsPatch = AudioSettings.partial()
  .strict()
  .refine((patch) => Object.keys(patch).length > 0, "Send at least one field")
  .meta({ id: "AudioSettingsPatch", description: "Fields to change; fields you leave out keep their value." });

// 1. At runtime the handler validates with Zod directly.
const result = AudioSettingsPatch.safeParse({});
console.log(`Zod on {}: ${result.success ? "accepted" : result.error.issues[0].message}`);

// 2. At build time the same schemas are exported as components.schemas.
const { schemas } = z.toJSONSchema(z.globalRegistry, { uri: (id) => `#/components/schemas/${id}` });
for (const schema of Object.values(schemas)) {
  delete schema.$schema; // implied by OpenAPI 3.1
  delete schema.$id; // "#/components/..." is a fragment, not a valid $id
}
const ref = (id) => ({ $ref: `#/components/schemas/${id}` });
const doc = {
  openapi: "3.1.1",
  info: { title: "Room Device API", version: "1.4.0" },
  paths: {
    "/audio": {
      patch: {
        operationId: "updateAudioSettings",
        requestBody: { required: true, content: { "application/merge-patch+json": { schema: ref("AudioSettingsPatch") } } },
        responses: { 200: { description: "The settings after the change.", content: { "application/json": { schema: ref("AudioSettings") } } } },
      },
    },
  },
  components: { schemas },
};
await SwaggerParser.validate(structuredClone(doc));
console.log("Generated document: valid OpenAPI 3.1.1");

// 3. Compare with the hand-written (design-first) schemas from the spec.
const properties = {
  volume: { type: "integer", minimum: 0, maximum: 100 },
  muted: { type: "boolean" },
  speaker: ref("SpeakerSelection"),
};
const designFirst = {
  SpeakerSelection: { type: "string", enum: ["SPEAKER_LEFT", "SPEAKER_RIGHT", "SPEAKER_BOTH"] },
  AudioSettings: { type: "object", required: ["volume", "muted", "speaker"], properties },
  AudioSettingsPatch: { description: "Fields to change; fields you leave out keep their value.", type: "object",
    minProperties: 1, additionalProperties: false, properties },
};
for (const [id, written] of Object.entries(designFirst)) {
  const generated = schemas[id];
  for (const key of new Set([...Object.keys(written), ...Object.keys(generated)])) {
    const [a, b] = [JSON.stringify(written[key]), JSON.stringify(generated[key])];
    if (a !== b) console.log(`${id}.${key}: spec says ${a ?? "(nothing)"}, Zod generated ${b ?? "(nothing)"}`);
  }
}
Result
Zod on {}: Send at least one field
Generated document: valid OpenAPI 3.1.1
AudioSettings.additionalProperties: spec says (nothing), Zod generated false
AudioSettingsPatch.minProperties: spec says 1, Zod generated (nothing)
What’s happening
  1. Each Zod schema gets .meta({ id }), which registers it in Zod's global registry under that name. AudioSettingsPatch is built from AudioSettings with .partial() (every field optional) and .strict() (unknown keys rejected), plus a .refine for "at least one field".
  2. At runtime the handler uses Zod directly: safeParse({}) fails with the refinement's message, "Send at least one field". The validation and the spec come from the same code, so they can't disagree about anything the generator can express.
  3. At build time z.toJSONSchema(z.globalRegistry, { uri }) emits every registered schema, with $refs between them written as #/components/schemas/<id> by the uri option. Each emitted schema also carries a $schema and an $id built from that same URI; the model deletes both, because OpenAPI 3.1 implies the dialect and an $id of #/components/… (a bare fragment) isn't a valid $id in JSON Schema 2020-12. The assembled document passes swagger-parser.
  4. The comparison finds two real differences. Zod's output schema for AudioSettings has additionalProperties: false (by default toJSONSchema describes what a parse outputs, and z.object strips unknown keys, so its output never has extras), while the design-first spec deliberately leaves response objects open so fields can be added later. And the .refine vanished: arbitrary code can't be turned into JSON Schema, so minProperties: 1 is missing from the generated patch schema, even though the handler enforces it.
  5. Both differences matter to clients. A strict client validating responses against the generated additionalProperties: false will break the day the device adds a field, and the generated docs under-describe what the device accepts. Code-first works when someone reviews the generated spec as carefully as a hand-written one, and fixes these with options (io: "input", explicit .meta() overrides) rather than accepting the defaults.
AdvancedA design-first workflow for a distributed team

A workflow that works across time zones, step by step:

  1. Propose: a developer opens a PR that changes only openapi.yaml (and its examples), with a short description of the use case: which client needs it, which firmware version will ship it.
  2. Automated checks run on the PR: bundle and resolve, lint (recommended plus team rules), validate examples, and diff against the last released spec for breaking changes (the next topic). The PR shows "2 additive changes, 0 breaking" before a human looks.
  3. Async review: the API owner and one consumer (usually the web UI lead) review the shape. A review checklist keeps it consistent: resource naming, error responses declared, patch semantics, pagination, limits stated, compatibility with older firmware.
  4. Merge and generate: types, mocks and docs regenerate. The UI team builds against the mock; the device team implements against the spec with runtime validation on.
  5. Contract tests run the implementation against the spec in CI. The feature is done when the implementation passes, not when the code compiles.

The value for a lead is that the spec PR is the cheapest place to change your mind: renaming a field there costs one comment; after the UI, the device and a partner have shipped against it, it costs a deprecation cycle.

AdvancedWhen code-first is the right call
  • Internal APIs with one consumer that ships in lockstep (the device's own web UI, built from the same repository and released in the same firmware image): the contract can't drift between versions that never meet, and the friction of maintaining YAML may not pay off. Generate the spec anyway, for docs and for diffing.
  • Existing code with no spec: generating a first spec from the code is the fastest way to get one. Then decide whether to switch to design-first from that baseline.
  • Strong schema libraries in the stack (Zod in a TypeScript server): one schema for validation, types and spec is attractive, as long as the generated spec is reviewed and diffed.

A common hybrid: design-first for the public and partner surface, code-first for internal endpoints, with both kinds of spec checked by the same lint and diff steps.

#Contract testing

Added

A contract test checks that two sides of an interface agree, without running the whole system end to end. For a device platform the sides are many: the on-device API (the provider) and its consumers, which are the web UI, the management cloud, partner integrations and the SDK. End-to-end tests across all of them need real devices, real firmware and every partner's code in one lab; contract tests replace most of that with checks each side can run alone, against the spec.

The mental model: the spec is a treaty. Both sides signed the same text. The provider proves it keeps its side (its responses match the spec: the runtime validation from earlier, run in tests). Each consumer proves it only relies on what the treaty says (its code compiles against generated types; its recorded expectations hold). And any amendment goes through review with a diff that says which clauses changed and whether anyone who signed the old text is now in breach.

There are three kinds of contract check, and a device platform wants all three:

  • Provider conformance: run the API's tests with response validation on, so every response the tests produce is checked against the spec (or use a tool that generates requests from the spec and checks the responses).
  • Breaking-change detection: diff the proposed spec against the last released one (and, for devices, against every firmware line still supported) and fail the build on breaking changes.
  • Consumer-driven contracts: each consumer records what it actually uses, and the provider verifies every consumer's list before it ships (Pact is the best-known tool for this).
openapi.yaml 1.4.0, in the repo the contract Diff gate in CI vs last release, each firmware Device API provider runtime validation Web admin UI consumer types, recorded calls Partner integration consumer recorded calls, examples Management cloud receives webhooks webhook schemas SDK generated types every side is checked against the same file; none needs the others running
The spec in the middle, and the check that keeps each side of the contract honest.

The breaking-change gate is the check with the highest value per line of code, so here it is from scratch. The model takes this chapter's API at 1.4.0, applies a set of plausible edits for a "2.0.0", and classifies each change from the point of view of clients that already exist:

breaking-changes.mjs
// Compare two versions of the spec and classify every change for the clients that already exist.
import SwaggerParser from "@apidevtools/swagger-parser";
import { parse } from "yaml";

const v1 = parse(`
openapi: 3.1.1
info: { title: Room Device API, version: 1.4.0 }
paths:
  /system/status:
    get: { responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/DeviceStatus" } } } } } }
  /system/reboot:
    post:
      requestBody: { content: { application/json: { schema: { $ref: "#/components/schemas/RebootRequest" } } } }
      responses: { "202": { description: Accepted } }
  /audio:
    get: { responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AudioSettings" } } } } } }
    patch:
      requestBody: { content: { application/merge-patch+json: { schema: { $ref: "#/components/schemas/AudioSettingsPatch" } } } }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AudioSettings" } } } } }
  /operations/{operationId}:
    get: { responses: { "200": { description: OK } } }
components:
  schemas:
    DeviceStatus:
      type: object
      required: [firmwareVersion, uptimeSeconds]
      properties: { firmwareVersion: { type: string }, uptimeSeconds: { type: integer, minimum: 0 } }
    RebootRequest:
      type: object
      properties: { delaySeconds: { type: integer, minimum: 0, maximum: 300 } }
    SpeakerSelection: { type: string, enum: [SPEAKER_LEFT, SPEAKER_RIGHT, SPEAKER_BOTH] }
    AudioSettings:
      type: object
      required: [volume, muted, speaker]
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
    AudioSettingsPatch:
      type: object
      additionalProperties: false
      properties:
        volume: { type: integer, minimum: 0, maximum: 100 }
        muted: { type: boolean }
        speaker: { $ref: "#/components/schemas/SpeakerSelection" }
`);

// The proposed 2.0.0, written as edits to 1.4.0.
const v2 = structuredClone(v1);
const s = v2.components.schemas;
v2.info.version = "2.0.0";
delete v2.paths["/operations/{operationId}"];
v2.paths["/system/logs"] = { get: { responses: { 200: { description: "OK" } } } };
s.DeviceStatus.properties.temperatureC = { type: "number" };
s.DeviceStatus.properties.uptimeSeconds = { type: "string" };
s.DeviceStatus.required = ["uptimeSeconds"];
s.RebootRequest.properties.reason = { type: "string" };
s.RebootRequest.required = ["reason"];
s.SpeakerSelection.enum.push("SPEAKER_SURROUND");
s.AudioSettings.properties.isMuted = s.AudioSettings.properties.muted;
delete s.AudioSettings.properties.muted;
s.AudioSettings.required = ["volume", "isMuted", "speaker"];
s.AudioSettingsPatch.properties.volume = { type: "integer", minimum: 0, maximum: 80 };

const findings = [];
const report = (level, where, what) => findings.push({ level, where, what });

function compare(a, b, dir, where) {
  if (JSON.stringify(a.type) !== JSON.stringify(b.type)) return report("BREAKING", where, `type ${a.type} → ${b.type}`);
  const [ap, bp] = [a.properties ?? {}, b.properties ?? {}];
  for (const name of Object.keys(ap)) {
    if (name in bp) compare(ap[name], bp[name], dir, `${where}.${name}`);
    else if (dir === "response") report("BREAKING", `${where}.${name}`, "removed from a response");
    else if (b.additionalProperties === false) report("BREAKING", `${where}.${name}`, "no longer accepted in a request");
  }
  for (const name of Object.keys(bp).filter((n) => !(n in ap))) {
    if (dir === "request" && b.required?.includes(name)) report("BREAKING", `${where}.${name}`, "new required request field");
    else report("safe", `${where}.${name}`, dir === "request" ? "new optional request field" : "new response field");
  }
  for (const name of Object.keys(bp).filter((n) => n in ap)) {
    const [was, now] = [a.required?.includes(name), b.required?.includes(name)];
    if (dir === "response" && was && !now) report("BREAKING", `${where}.${name}`, "may now be missing from a response");
    if (dir === "request" && !was && now) report("BREAKING", `${where}.${name}`, "now required in a request");
  }
  const added = (b.enum ?? []).filter((v) => !(a.enum ?? []).includes(v));
  const removed = (a.enum ?? []).filter((v) => !(b.enum ?? []).includes(v));
  if (added.length) report(dir === "response" ? "WARNING" : "safe", where, `enum value ${added} added to a ${dir}`);
  if (removed.length && dir === "request") report("BREAKING", where, `enum value ${removed} no longer accepted`);
  if (dir === "request" && (b.maximum < a.maximum || b.minimum > a.minimum)) report("BREAKING", where, `range narrowed to ${b.minimum}..${b.maximum}`);
}

const [old, next] = await Promise.all([SwaggerParser.dereference(v1), SwaggerParser.dereference(v2)]);
for (const [path, item] of Object.entries(old.paths)) {
  for (const [method, op] of Object.entries(item)) {
    const op2 = next.paths[path]?.[method];
    const name = `${method.toUpperCase()} ${path}`;
    if (!op2) { report("BREAKING", name, "operation removed"); continue; }
    for (const [type, media] of Object.entries(op.requestBody?.content ?? {}))
      compare(media.schema, op2.requestBody.content[type].schema, "request", `${name} request`);
    for (const [code, res] of Object.entries(op.responses))
      for (const [type, media] of Object.entries(res.content ?? {}))
        compare(media.schema, op2.responses[code].content[type].schema, "response", `${name} ${code}`);
  }
}
for (const [path, item] of Object.entries(next.paths))
  for (const method of Object.keys(item)) if (!old.paths[path]?.[method]) report("safe", `${method.toUpperCase()} ${path}`, "operation added");

for (const f of findings) console.log(`${f.level.padEnd(8)} ${f.where}: ${f.what}`);
const breaking = findings.filter((f) => f.level === "BREAKING").length;
console.log(`\n${v1.info.version} → ${v2.info.version}: ${breaking} breaking, ${findings.length - breaking} other change(s)`);
Result
BREAKING GET /system/status 200.uptimeSeconds: type integer → string
safe     GET /system/status 200.temperatureC: new response field
BREAKING GET /system/status 200.firmwareVersion: may now be missing from a response
BREAKING POST /system/reboot request.reason: new required request field
BREAKING GET /audio 200.muted: removed from a response
WARNING  GET /audio 200.speaker: enum value SPEAKER_SURROUND added to a response
safe     GET /audio 200.isMuted: new response field
BREAKING PATCH /audio request.volume: range narrowed to 0..80
safe     PATCH /audio request.speaker: enum value SPEAKER_SURROUND added to a request
BREAKING PATCH /audio 200.muted: removed from a response
WARNING  PATCH /audio 200.speaker: enum value SPEAKER_SURROUND added to a response
safe     PATCH /audio 200.isMuted: new response field
BREAKING GET /operations/{operationId}: operation removed
safe     GET /system/logs: operation added

1.4.0 → 2.0.0: 7 breaking, 7 other change(s)
What’s happening
  1. Both versions are dereferenced, so shared schemas such as SpeakerSelection appear wherever they're used. Then the walk visits each operation of the old spec: if it's missing from the new one, that's operation removed; otherwise its request schemas are compared in the request direction and its response schemas, per status code and media type, in the response direction. Operations only in the new spec are additions.
  2. Direction decides most verdicts. A new field is safe in a response (old clients ignore it, if they're tolerant readers) but a new required field in a request breaks every old client that doesn't send it: POST /system/reboot request.reason. A removed field breaks a response (muted, which clients read) but is only breaking in a request when the schema rejects unknown fields; in AudioSettingsPatch it does, so old clients sending the field would start getting 422.
  3. Renaming is remove plus add. muted → isMuted produces "removed from a response" (breaking) and "new response field" (safe) on both GET and PATCH /audio. No diff tool can tell a rename from a removal; the safe way to rename is to add the new field, keep the old one, and deprecate it (Deprecation and sunset).
  4. Enums cut both ways. SPEAKER_SURROUND is safe in the PATCH request (old clients never send it) but a WARNING in responses: a client with an exhaustive switch over the three values, or a generated enum type, may crash or show nothing when a device reports the fourth. Whether that's breaking depends on what you promised; document "clients must handle unknown values" in the spec if you want to add values freely.
  5. Type and constraint changes: uptimeSeconds from integer to string is breaking everywhere. Narrowing volume to 0..80 in a request is breaking (a client sending 90 used to succeed); the same narrowing in a response would be safe. firmwareVersion dropping out of required is breaking in a response, because clients that assumed it's always present now need a null check.
  6. The summary line, 7 breaking, is what a CI gate acts on: fail the PR, or require that the version becomes a new major with the old one still served (see Versioning strategies). This model has about a dozen rules; oasdiff's catalogue (oasdiff.com, checked 2026-10-27) lists 350 kinds of breaking change and 19 warnings.
AdvancedBreaking or not: the rules in one table
ChangeIn a requestIn a response
add an optional fieldsafesafe for tolerant readers
add a required fieldbreakingsafe
remove a fieldbreaking if unknown fields are rejectedbreaking
rename a fieldbreaking (remove + add)breaking (remove + add)
change a field's typebreakingbreaking
make an optional field requiredbreakingsafe
make a required field optionalsafebreaking (clients must now handle absence)
add an enum valuesaferisky: clients may not handle it
remove an enum valuebreakingsafe
narrow a range or patternbreakingsafe
widen a range or patternsaferisky: clients may reject new values
remove an operation or a status code clients handlebreakingbreaking
change the media type, auth or a default valueusually breakingusually breaking

Defaults deserve a note: changing a parameter's default (say limit from 100 to 50) doesn't change any schema shape, so naive diffs miss it, but every client that relied on the default now gets different behaviour.

AdvancedBreaking for whom: consumer-driven contracts

A spec diff says a change is breaking for some possible client. Consumer-driven contracts say which actual clients it breaks. Each consumer records the interactions it relies on (normally generated from its own tests and committed as a file), and the provider replays all of them against its new build before release. This model does it for the muted → isMuted rename, against a provider that is fully consistent with its own new spec:

consumer-contracts.mjs
// Consumer-driven contracts: each consumer lists what it relies on; the provider checks a new
// version against every list before it ships, so "breaking" becomes "breaking for whom".
import express from "express";
import request from "supertest";
import Ajv2020 from "ajv/dist/2020.js";

// What each consumer recorded from its own tests (normally a file each team commits).
const contracts = {
  "web admin UI": [
    { method: "GET", path: "/api/v1/audio", status: 200, reads: ["volume", "muted", "speaker"] },
    { method: "PATCH", path: "/api/v1/audio", body: { muted: true }, status: 200, reads: ["muted"] },
  ],
  "partner room panel": [{ method: "GET", path: "/api/v1/audio", status: 200, reads: ["volume"] }],
  "fleet manager": [{ method: "GET", path: "/api/v1/system/status", status: 200, reads: ["firmwareVersion"] }],
};

// The provider's new build (2.0.0 renames muted to isMuted) and its spec for the audio response.
const audioSchema = {
  type: "object",
  required: ["volume", "isMuted", "speaker"],
  properties: { volume: { type: "integer" }, isMuted: { type: "boolean" }, speaker: { type: "string" } },
};
const state = { volume: 62, isMuted: false, speaker: "SPEAKER_BOTH" };
const app = express();
app.use(express.json({ type: ["application/json", "application/merge-patch+json"] }));
app.get("/api/v1/audio", (req, res) => res.json(state));
app.patch("/api/v1/audio", (req, res) => res.json(Object.assign(state, { isMuted: req.body.muted ?? state.isMuted })));
app.get("/api/v1/system/status", (req, res) => res.json({ firmwareVersion: "5.0.2", uptimeSeconds: 3600 }));

const ajv = new Ajv2020();
const matchesSpec = ajv.compile(audioSchema);
console.log(`2.0.0 build matches its own spec: ${matchesSpec((await request(app).get("/api/v1/audio")).body)}`);
for (const [consumer, interactions] of Object.entries(contracts)) {
  const failures = [];
  for (const i of interactions) {
    let call = request(app)[i.method.toLowerCase()](i.path);
    if (i.body) call = call.set("Content-Type", "application/merge-patch+json").send(JSON.stringify(i.body));
    const res = await call;
    if (res.status !== i.status) failures.push(`${i.method} ${i.path}: status ${res.status}, expected ${i.status}`);
    const missing = i.reads.filter((field) => !(field in res.body));
    if (missing.length) failures.push(`${i.method} ${i.path}: no longer returns ${missing.join(", ")}`);
  }
  console.log(`${failures.length ? "FAIL" : "pass"}  ${consumer}${failures.map((f) => `\n      ${f}`).join("")}`);
}
Result
2.0.0 build matches its own spec: true
FAIL  web admin UI
      GET /api/v1/audio: no longer returns muted
      PATCH /api/v1/audio: no longer returns muted
pass  partner room panel
pass  fleet manager
What’s happening
  1. Each consumer lists its interactions: method, path, body, expected status, and the response fields it reads. The web UI reads muted on both the GET and the PATCH; the partner panel only shows the volume; the fleet manager only reads firmwareVersion from the status.
  2. The provider's 2.0.0 build renamed muted to isMuted, in the code and in its spec, so the first line says it matches its own spec. Provider conformance alone can't catch this: the provider kept its side of the new treaty.
  3. Replaying the contracts with supertest, the web UI's two interactions fail ("no longer returns muted"); the partner and the fleet manager pass, because they never read that field.
  4. That turns a yes/no gate into a decision with names attached: this change breaks the web UI only, which ships in the same firmware image and can be updated in the same release, so it may be acceptable; had it broken the partner panel, it would need a deprecation period and a conversation with the partner.
  5. Pact automates this pattern (consumers publish contracts to a broker, providers verify them in CI, and a "can I deploy" check compares versions). For partners who won't run your tooling, the same idea works informally: ask for the list of endpoints and fields they use, keep it as a contract file, and replay it.
AdvancedRunning the gate in CI, and the firmware matrix

oasdiff (github.com/oasdiff/oasdiff, formerly Tufin/oasdiff) is an open-source command-line tool and Go library for this job. Its documentation (docs/BREAKING-CHANGES.md, checked 2026-10-27) describes the levels ERR for definite breaking changes and WARN for potential ones that "cannot be confirmed programmatically" (plus INFO for the changelog), and a --fail-on flag for CI. It isn't installed here, so this is the documented usage, not a run:

CI step (oasdiff, not run here)
# compare the released 1.4.0 spec with the one in this pull request
oasdiff breaking specs/released/openapi-1.4.0.yaml openapi.yaml --fail-on ERR

For a device platform, "the last release" isn't one spec. Devices in the field run several firmware lines (for example, a 4.x line still supported on older hardware and the current 5.x line), and partners integrate with whichever their customers have. Keep the released spec of every supported firmware line in the repository, and run the diff against each: a change that's additive relative to 5.0 may still break an integration written against 4.6 if 5.0 already removed something 4.6 had. Testing compatibility across versions covers the version matrix in more depth, and chapter 11 puts contract tests into the wider test pyramid.

Which of these changes can ship in a minor release?

The device API is at 1.4.0. Four proposed changes for 1.5.0: (a) add an optional ?since= query parameter to GET /system/logs; (b) add "SPEAKER_SURROUND" to SpeakerSelection, used in both requests and responses; (c) make If-Match required on PATCH /audio; (d) add temperatureC to the DeviceStatus response. Which are safe for existing clients?

Show answer

(a) and (d) are safe. (b) is risky because of the responses. (c) is breaking.

What’s happening
  1. (a) A new optional query parameter is additive in a request: old clients don't send it and get the old behaviour. Safe, provided the default behaviour without it is unchanged.
  2. (d) A new field in a response is safe for tolerant readers, which is why the design-first spec leaves response objects open (no additionalProperties: false) and why the consumer guidance says "ignore fields you don't know".
  3. (b) A new enum value is safe in the request direction (nobody sends it yet) but risky in responses: a device in surround mode would report a value old clients have never seen, and generated enum types or exhaustive switches may fail. It can ship in a minor release only if the spec has always told clients to handle unknown values; otherwise gate it, for example by only reporting the new value to clients that ask for it.
  4. (c) Making If-Match required turns every existing PATCH without the header into a 428 Precondition Required (or whatever the device chooses) for clients that worked yesterday. In the rules table that's "make an optional field required" in a request: breaking. The compatible path is to keep it optional, recommend it, and warn in a response header or the docs before a future major version requires it.
  5. The breaking-changes model above only compares bodies, not parameters, but its body-level rules give the same answers: a new optional request field and a new response field are safe, an enum value added to a response is a WARNING, and newly required request input is BREAKING.

Written fresh for a device-platform full stack role. Runnable examples were run on Node 26; product facts come from public documentation, cited where used.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.