OpenAPI 3.1 Specification Deep Dive
An OpenAPI document is the machine-readable contract for a synchronous HTTP API: it states every path, the shape of every request and response, and the rules a client must obey. Writing one that survives real-world scale means understanding the object model precisely — not just paths, but how components and $ref eliminate duplication, how allOf/oneOf/anyOf express composition, and how security schemes and codegen flow from the same source. This guide extends API Contract Fundamentals & Tool Selection and assumes you already know what a contract is; here we go object by object through OpenAPI 3.1.
OpenAPI 3.1 matters specifically because it aligns the spec with JSON Schema Draft 2020-12. That single change makes a 3.1 document a valid JSON Schema dialect, so the same schemas you write for the contract can drive runtime validation, code generation, and mocking without translation. If your team is weighing this against message-driven contracts, read how to choose between OpenAPI and AsyncAPI for microservices before committing to a protocol boundary.
When to Use This Approach
Reach for a hand-authored OpenAPI 3.1 document when:
- You have synchronous request/response HTTP traffic (REST or RPC-over-HTTP). Event streams belong in AsyncAPI instead.
- You want a design review gate — the contract is approved before implementation begins.
- Multiple teams or external partners consume the API and need a stable, versioned reference.
- You intend to generate clients, server stubs, mocks, or docs from one source of truth.
- You need machine-enforceable governance (naming, security, status-code coverage) in CI.
If your API is a single internal service consumed only by its own authors and changes daily, a lighter code-first workflow may fit better — but you lose the upfront review gate.
Prerequisites
Pin tool versions so examples stay reproducible. This guide uses OpenAPI 3.1.1, Spectral 6.11 for linting, and redocly CLI 1.25 for bundling and preview.
# Spectral 6.11 — governance linting
npm install --save-dev @stoplight/spectral-cli@6.11
# redocly CLI 1.25 — bundle, lint, preview, split
npm install --save-dev @redocly/cli@1.25
# openapi-typescript 7 — type generation (used in the codegen step)
npm install --save-dev openapi-typescript@7
# Confirm versions
npx spectral --version # 6.11.x
npx redocly --version # 1.25.x
The structure of the document you will build looks like this — a single root branching into the eight objects that matter most.
Step 1: Define info and the document skeleton
Every document opens with three things: the openapi version string, the info block, and a servers list. The info.version is the version of your API, not the OpenAPI spec — bump it with semantic versioning so consumers and diff tooling can reason about change.
openapi: 3.1.1 # spec version — gates JSON Schema 2020-12 features
info:
title: Payment Processing API
version: 2.1.0 # YOUR api version (semver) — drives breaking-change diffs
description: Synchronous payment operations.
contact:
name: Payments Platform
email: payments@example.com
license:
name: Apache-2.0
identifier: Apache-2.0 # 3.1 SPDX identifier (replaces license.url)
servers:
- url: https://api.example.com/v2
description: Production
- url: https://sandbox.example.com/v2
description: Sandbox
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema # explicit dialect
jsonSchemaDialect is new in 3.1 and makes the schema semantics explicit rather than implied — useful when downstream validators need to know exactly which keywords are in play.
Step 2: Model paths and operations
A path item maps a URL template to HTTP methods (operations). Give every operation a stable operationId: it is the name your generated SDK methods inherit, so renaming it is a breaking change for client code even when the HTTP surface is unchanged.
paths:
/transactions/{id}:
parameters: # path-level params apply to every operation here
- $ref: '#/components/parameters/TransactionId'
get:
operationId: getTransaction # becomes client.getTransaction() in SDKs
summary: Fetch a transaction
tags: [transactions] # groups operations in docs and SDKs
responses:
'200':
description: The transaction
content:
application/json:
schema:
$ref: '#/components/schemas/Transaction'
'404':
$ref: '#/components/responses/NotFound'
post:
operationId: createTransaction
summary: Create a transaction
requestBody:
required: true # the body is mandatory; 400 if absent
content:
application/json:
schema:
$ref: '#/components/schemas/TransactionRequest'
responses:
'201':
description: Created
headers:
Location: # advertise the created resource URL
schema: { type: string, format: uri }
content:
application/json:
schema:
$ref: '#/components/schemas/Transaction'
'422':
$ref: '#/components/responses/UnprocessableEntity'
Note the path-level parameters array: declaring {id} once at the path item avoids repeating it on get, delete, and any future method. Everything else points at components with $ref, which keeps the operations readable and the schemas authoritative.
Step 3: Extract reusable components with $ref
components is the document’s library. Nothing under it is active until referenced — it is purely a pool of named, reusable objects (schemas, parameters, responses, requestBodies, headers, securitySchemes, and more). A $ref is a JSON Reference: a pointer to a fragment, in this document or another file.
components:
parameters:
TransactionId:
name: id
in: path
required: true # path params MUST be required
schema: { type: string, format: uuid }
responses:
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error' # responses can ref schemas
UnprocessableEntity:
description: Validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
Error:
type: object
required: [code, message]
properties:
code: { type: string }
message: { type: string }
Two refs above resolve to the same Error schema — change it once and both 404 and 422 responses update. This is the core mechanism that keeps a large spec consistent; the deeper patterns (external files, bundling, avoiding circular references) are covered in reusing schemas with OpenAPI components and $ref. When refs span multiple files, design carefully to avoid the circular-reference traps that also surface during compile-time type generation from OpenAPI.
Step 4: Compose schemas with allOf, oneOf, and anyOf
Composition is where OpenAPI schemas earn their keep. The three keywords are not interchangeable.
allOf — intersection (extend a base). The value must satisfy every subschema. Use it to add fields to a shared base without copy-paste.
TransactionRequest:
allOf:
- $ref: '#/components/schemas/MonetaryBase' # amount + currency
- type: object
additionalProperties: false # reject undocumented keys
required: [method]
properties:
method:
type: string
enum: [card, ach, wire]
MonetaryBase:
type: object
required: [amount, currency]
properties:
amount: { type: number, format: double, exclusiveMinimum: 0 }
currency: { type: string, pattern: '^[A-Z]{3}$' } # ISO 4217
oneOf — exactly one (polymorphism). The value must match exactly one branch. Pair it with a discriminator so validators and SDKs route deterministically instead of trial-and-error matching.
PaymentInstrument:
oneOf:
- $ref: '#/components/schemas/Card'
- $ref: '#/components/schemas/BankAccount'
discriminator:
propertyName: kind # the field that selects the branch
mapping:
card: '#/components/schemas/Card'
bank: '#/components/schemas/BankAccount'
anyOf — one or more. The value must satisfy at least one branch; overlaps are allowed. Use it for “either of these is acceptable” cases where exclusivity does not matter, such as accepting a value that may be a string or a structured object.
A subtle 3.1 win: nullable is gone. Express “string or null” as a JSON Schema type array.
memo:
type: [string, "null"] # 3.1 way to say nullable (was nullable: true)
maxLength: 280
Step 5: Parameters, request bodies, and responses in depth
Parameters carry data outside the body. The in field selects the location — path, query, header, or cookie — and style/explode control serialization of arrays and objects.
parameters:
StatusFilter:
name: status
in: query
required: false
schema:
type: array
items: { type: string, enum: [pending, settled, failed] }
style: form # ?status=pending&status=settled (form + explode default)
explode: true
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: { type: string, format: uuid }
For responses, cover the full status surface, not just the happy path. A response object names a description (required), optional headers, and content keyed by media type. Lean on components/responses so the same Error envelope appears everywhere — consistent error contracts are a design discipline in their own right.
| Status | Meaning in this contract | Body schema |
|---|---|---|
201 |
Resource created | Transaction + Location header |
400 |
Malformed request | Error |
401 |
Missing/invalid credentials | Error |
404 |
Unknown resource | Error |
422 |
Semantically invalid payload | Error |
Step 6: Declare and apply security schemes
Security schemes live under components.securitySchemes and are applied via the security array — either globally (document root) or per operation (overriding the global default).
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
oauthClient:
type: oauth2
flows:
clientCredentials: # service-to-service
tokenUrl: https://auth.example.com/oauth/token
scopes:
payments:write: Create payments
payments:read: Read payments
security:
- bearerAuth: [] # global default: every operation needs a bearer token
paths:
/health:
get:
operationId: healthCheck
security: [] # explicitly public — overrides the global requirement
responses:
'200': { description: OK }
/transactions:
post:
operationId: createTransaction
security:
- oauthClient: [payments:write] # this op requires the write scope
responses:
'201': { description: Created }
An empty security: [] on an operation is the explicit “this endpoint is public” marker. Make it intentional — a Spectral rule should require every operation to declare security so nothing is accidentally unauthenticated.
Step 7: Lint with Spectral and govern in CI
Spectral 6.11 enforces both baseline OpenAPI validity and your house rules. Extend spectral:oas and layer organizational rules on top.
# .spectral.yaml
extends: ["spectral:oas"]
rules:
operation-operationId: error # every operation has a stable SDK name
operation-security-defined: error # no accidentally public endpoints
operation-singular-tag: warn
no-ambiguous-paths: error
property-naming-convention:
description: Enforce camelCase for JSON properties
given: "$.components.schemas[*].properties[*]~"
severity: error
then:
function: pattern
functionOptions:
match: "^[a-z]+([A-Z][a-z0-9]*)*$"
# Lint locally and in CI — non-zero exit fails the pipeline
npx spectral lint openapi.yaml --ruleset .spectral.yaml
Linting catches shape problems; it does not catch breaking changes against the previously published version. Pair Spectral with a diff gate — the full strategy, including custom rules and per-PR diffing, is in breaking change detection.
# .github/workflows/contract.yml
name: OpenAPI Contract
on:
pull_request:
paths: ['openapi.yaml', 'schemas/**']
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # need history for the diff
- run: npm ci
- name: Lint (Spectral 6.11)
run: npx spectral lint openapi.yaml --ruleset .spectral.yaml
- name: Bundle (redocly 1.25)
run: npx redocly bundle openapi.yaml -o dist/openapi.bundled.yaml
- name: Breaking-change diff
run: |
git show origin/main:openapi.yaml > /tmp/base.yaml
npx redocly diff /tmp/base.yaml openapi.yaml --format text
Step 8: Bundle and generate SDKs
A multi-file spec is pleasant to author but awkward to distribute. redocly bundle resolves every external $ref into one self-contained document — the artifact you ship to consumers and feed to codegen.
# Produce one distributable document
npx redocly bundle openapi.yaml -o dist/openapi.bundled.yaml
# Generate TypeScript types from the bundle (openapi-typescript 7)
npx openapi-typescript dist/openapi.bundled.yaml -o src/api-types.ts
// src/client.ts — types stay in lockstep with the contract
import type { paths } from './api-types';
type CreateTxn = paths['/transactions']['post'];
type TxnRequestBody =
CreateTxn['requestBody']['content']['application/json'];
type TxnResponse =
CreateTxn['responses']['201']['content']['application/json'];
// Now request/response bodies are compile-time checked against the spec.
Because the generated types derive from the bundled spec, any contract change that would break a client surfaces as a TypeScript error at build time. The same bundled document also feeds mock servers — generating a faithful fake API from the spec is covered in mock server strategies, and the type-generation pipeline is explored further in compile-time type generation from OpenAPI.
Spec/Schema Reference
| Keyword / field | Where it lives | Default | Effect |
|---|---|---|---|
openapi |
root | — (required) | Declares spec version; 3.1.x enables JSON Schema 2020-12 |
info.version |
info |
— (required) | Your API’s semantic version; drives diff tooling |
jsonSchemaDialect |
root | 2020-12 | Sets the JSON Schema dialect for all schemas |
operationId |
operation | none | Unique op name; becomes the generated SDK method |
$ref |
anywhere | — | JSON Reference to a fragment, local or external file |
additionalProperties |
schema | true |
false rejects undocumented keys (strict payloads) |
required |
schema | [] |
Lists mandatory properties |
allOf |
schema | — | Value must satisfy all subschemas (extend a base) |
oneOf |
schema | — | Value must satisfy exactly one subschema |
anyOf |
schema | — | Value must satisfy one or more subschemas |
discriminator |
schema | none | Selects the oneOf branch by a property value |
type: [..., "null"] |
schema | — | 3.1 replacement for nullable: true |
in |
parameter | — (required) | path | query | header | cookie |
style / explode |
parameter | form/true |
Controls array/object serialization |
security |
root or operation | global | [] marks an operation public |
Verification
A clean run produces no Spectral findings, a bundled artifact, and a diff report with no breaking changes:
$ npx spectral lint openapi.yaml --ruleset .spectral.yaml
No results with a severity of 'error' or higher found!
$ npx redocly bundle openapi.yaml -o dist/openapi.bundled.yaml
bundled successfully in: dist/openapi.bundled.yaml (1 document)
$ npx redocly diff /tmp/base.yaml openapi.yaml --format text
No changes were detected between the two files.
In CI, the job exits 0 only when all three pass. A non-zero exit from any step blocks the merge — that is the gate doing its job.
Troubleshooting
Circular $ref detected during bundle or codegen
Root cause: Two schemas reference each other directly with no intervening object boundary, or a schema refs itself in a way the resolver cannot terminate. Fix: Self-references are legal JSON Schema (a tree node containing children of its own type) — keep the recursive $ref but ensure the referencing property is inside properties/items, not a top-level allOf cycle. For true mutual recursion that codegen cannot handle, break it by introducing an explicit intermediate type.
Spectral reports nullable is not expected in a 3.1 document
Root cause: A schema copied from a 3.0 spec still uses nullable: true, which is not a Draft 2020-12 keyword. Fix: Replace nullable: true with a type array: type: [string, "null"]. Remove every standalone nullable key; 3.1 validators ignore or reject it.
operation-security-defined fails on a legitimately public endpoint
Root cause: The rule requires every operation to reference a defined security scheme, but a health-check has no auth. Fix: Add security: [] to that operation. The empty array is an explicit, lintable declaration that the endpoint is intentionally public, satisfying the rule without weakening it elsewhere.
oneOf validation accepts a payload that matches two branches
Root cause: The branches overlap, so a value satisfies more than one and oneOf (exactly one) fails — or worse, anyOf silently accepts the ambiguous payload. Fix: Add a discriminator with an explicit mapping, and ensure each branch carries the discriminator property as a const or enum so exactly one branch can match.
Generated SDK method names change unexpectedly after a refactor
Root cause: An operationId was renamed (or omitted, so the generator synthesized a name from the path + method). Fix: Set an explicit, stable operationId on every operation and treat changes to it as breaking. Add the operation-operationId Spectral rule to make missing IDs a hard error.
Frequently Asked Questions
What changed between OpenAPI 3.0 and 3.1?
OpenAPI 3.1 is a full superset of JSON Schema Draft 2020-12, so nullable is replaced by type arrays like type: [string, "null"], and you gain const, if/then/else, and unevaluatedProperties. The webhooks top-level object was also added, and the JSON Schema dialect is now configurable per document.
Should I write OpenAPI by hand or generate it from code?
Schema-first hand-authoring gives you a design review gate before any code exists and keeps the contract tool-agnostic. Code-first generation reduces drift between implementation and spec but couples the contract to a framework. Most teams hand-author the document of record and use generation only for verification.
When should I use allOf versus oneOf versus anyOf?
Use allOf to merge constraints, typically a base schema plus extra fields (composition by intersection). Use oneOf when a value must match exactly one branch, usually paired with a discriminator. Use anyOf when a value may satisfy one or more branches and you do not need exclusivity.
Does $ref work across multiple files?
Yes. A $ref can point to a fragment in the same document (#/components/schemas/User) or to an external file (./schemas/user.yaml#/User). Bundling with redocly CLI resolves external refs into a single self-contained document for distribution and codegen.
Which security scheme should I use for service-to-service calls?
Use an OAuth2 clientCredentials flow or a mutual-TLS arrangement documented as a custom scheme. Bearer JWT (type: http, scheme: bearer, bearerFormat: JWT) is the most portable choice for token-based service auth and is widely supported by generated SDKs.
Can I generate type-safe SDKs directly from the spec?
Yes. openapi-typescript 7 produces TypeScript types from the document, and openapi-generator or the redocly ecosystem produce full clients in many languages. Treat the bundled spec as the build input so generated code always tracks the contract of record.