Skip to main content

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.

OpenAPI 3.1 document structure tree A tree rooted at the OpenAPI root object branching into info, servers, paths with operations, components with reusable schemas and security schemes, and the security and tags objects. OpenAPI root info + servers paths components security + tags operation parameters schemas securitySchemes responses $ref

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.