caduh

OpenAPI Done Right — contracts, not just docs

7 min read

Practical patterns for rock‑solid APIs: spec styleguide, reusable components, Problem Details errors, auth & versioning, pagination, idempotency, testing, and CI gates. Includes a copy‑paste 3.1 template.

TL;DR

  • Treat OpenAPI as a contract, not a brochure. Ship 3.1 specs with JSON Schema 2020‑12.
  • Standardize errors with RFC 7807 Problem Details (application/problem+json).
  • Pick a pagination pattern (cursor/keyset recommended) and make it consistent.
  • Require operationId, stable tag buckets, and reusable components (schemas/responses/parameters).
  • Add examples for every operation. Lint with Spectral, diff with oasdiff, test with Prism/Schemathesis/Dredd in CI.
  • Version deliberately (/v1 + Sunset/Deprecation headers), design for idempotency, and document rate limits & webhooks.

1) Opinionated styleguide (bits that matter)

  • OpenAPI 3.1.0, JSON Schema dialect 2020‑12 (jsonSchemaDialect at root).
  • Resource‑oriented paths: /users, /users/{user_id}, /users/{user_id}/keys. Path params are snake_case and singular.
  • Nouns in paths, verbs in methods. No verbs in path segments.
  • Always set: operationId, summary, tags, requestBody (for POST/PUT/PATCH), responses.
  • Reuse components: schemas, parameters, responses, headers, securitySchemes.
  • Document auth front‑and‑center; prefer OAuth2 or HTTP bearer; allow API keys only when necessary.
  • Choose pagination (cursor) and filtering conventions and stick to them.
  • Return Problem Details on 4xx/5xx, with a machine‑readable type and stable fields.
  • Provide examples and example cURL blocks for every endpoint.
  • Mark deprecations with deprecated: true + Sunset + Deprecation headers; give timelines.

2) Copy‑paste template (OpenAPI 3.1)

openapi: 3.1.0
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
info:
  title: Example API
  version: 1.0.0
  description: >
    Contract-first, JSON Schema 2020-12. Errors use Problem Details.
servers:
  - url: https://api.example.com/v1
tags:
  - name: users
    description: User accounts
  - name: payments
    description: Payment intents & charges
  - name: webhooks
    description: Incoming events you must handle
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
  headers:
    RateLimit-Limit:
      description: Requests available in the current window
      schema: { type: string }
    RateLimit-Remaining:
      description: Remaining requests in the current window
      schema: { type: string }
    RateLimit-Reset:
      description: Seconds until reset
      schema: { type: string }
    Sunset:
      description: RFC 8594 Sunset header
      schema: { type: string, format: date-time }
  parameters:
    CursorParam:
      name: cursor
      in: query
      description: Opaque pagination cursor
      required: false
      schema: { type: string }
    LimitParam:
      name: limit
      in: query
      description: Max items to return (1..100)
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    UserIdPath:
      name: user_id
      in: path
      required: true
      description: User identifier
      schema: { type: string, pattern: "^[a-z0-9_\-]{1,64}$" }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Unique key to safely retry write requests
      schema: { type: string, maxLength: 80 }
  responses:
    Problem:
      description: Error (RFC 7807)
      headers:
        RateLimit-Limit: { $ref: "#/components/headers/RateLimit-Limit" }
        RateLimit-Remaining: { $ref: "#/components/headers/RateLimit-Remaining" }
        RateLimit-Reset: { $ref: "#/components/headers/RateLimit-Reset" }
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          examples:
            not_found:
              value:
                type: "https://docs.example.com/problems/not-found"
                title: "Resource not found"
                status: 404
                detail: "User 'u_123' was not found"
                instance: "/v1/users/u_123"
    OkList:
      description: OK
      headers:
        RateLimit-Limit: { $ref: "#/components/headers/RateLimit-Limit" }
        RateLimit-Remaining: { $ref: "#/components/headers/RateLimit-Remaining" }
        RateLimit-Reset: { $ref: "#/components/headers/RateLimit-Reset" }
      content:
        application/json:
          schema:
            type: object
            required: [items, next_cursor, has_more]
            properties:
              items:
                type: array
                items: { $ref: "#/components/schemas/User" }
              next_cursor:
                type: [ "string", "null" ]
              has_more:
                type: boolean
  schemas:
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer, minimum: 100, maximum: 599 }
        detail: { type: string }
        instance: { type: string }
        errors:
          description: Optional field errors
          type: array
          items:
            type: object
            required: [field, message]
            properties:
              field: { type: string }
              message: { type: string }
    User:
      type: object
      additionalProperties: false
      required: [id, email, created_at]
      properties:
        id: { type: string, example: "u_123" }
        email: { type: string, format: email }
        name: { type: [ "string", "null" ] }
        created_at: { type: string, format: date-time }
    PaymentIntent:
      type: object
      additionalProperties: false
      required: [id, amount, currency, status, client_secret]
      properties:
        id: { type: string, example: "pi_123" }
        amount: { type: integer, minimum: 1, example: 1999 }
        currency: { type: string, pattern: "^[a-z]{3}$", example: "usd" }
        status: { type: string, enum: [requires_payment_method, processing, succeeded, canceled] }
        client_secret: { type: string }
paths:
  /users:
    get:
      operationId: listUsers
      summary: List users
      tags: [users]
      security: [ { bearerAuth: [] } ]
      parameters:
        - $ref: "#/components/parameters/CursorParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          $ref: "#/components/responses/OkList"
        default:
          $ref: "#/components/responses/Problem"
    post:
      operationId: createUser
      summary: Create a user
      tags: [users]
      security: [ { bearerAuth: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                name: { type: string, maxLength: 120 }
            examples:
              minimal: { value: { email: "[email protected]" } }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        default:
          $ref: "#/components/responses/Problem"
  /users/{user_id}:
    parameters:
      - $ref: "#/components/parameters/UserIdPath"
    get:
      operationId: getUser
      summary: Retrieve a user
      tags: [users]
      security: [ { bearerAuth: [] } ]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        default:
          $ref: "#/components/responses/Problem"
    patch:
      operationId: updateUser
      summary: Update a user
      tags: [users]
      security: [ { bearerAuth: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name: { type: [ "string", "null" ], maxLength: 120 }
            examples:
              rename: { value: { name: "Alice A." } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        default:
          $ref: "#/components/responses/Problem"
  /payments/intents:
    post:
      operationId: createPaymentIntent
      summary: Create a payment intent (idempotent)
      tags: [payments]
      security: [ { bearerAuth: [] } ]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, currency]
              properties:
                amount: { type: integer, minimum: 1 }
                currency: { type: string, pattern: "^[a-z]{3}$" }
      responses:
        "201":
          description: Created
          headers:
            RateLimit-Limit: { $ref: "#/components/headers/RateLimit-Limit" }
            RateLimit-Remaining: { $ref: "#/components/headers/RateLimit-Remaining" }
            RateLimit-Reset: { $ref: "#/components/headers/RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntent" }
        default:
          $ref: "#/components/responses/Problem"
webhooks:
  payment.succeeded:
    post:
      operationId: webhookPaymentSucceeded
      summary: Receive payment succeeded events
      tags: [webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                type: { const: "payment.succeeded" }
                data:
                  type: object
                  properties:
                    payment_intent: { $ref: "#/components/schemas/PaymentIntent" }
      responses:
        "200":
          description: Acknowledge receipt
        default:
          $ref: "#/components/responses/Problem"

Use $ref everywhere to avoid drift. Keep schemas closed with additionalProperties: false unless you intentionally allow extension fields.


3) Errors that don’t surprise clients (Problem Details)

  • Content type: application/problem+json.
  • Always include: type (URL to docs), title, status, detail, instance (optional), and a stable errors[] array for field issues.
  • Never return plain strings for errors. Clients should always be able to parse a structured body.

4) Pagination, filtering, sorting

  • Prefer cursor style: ?limit=20&cursor=abc... and return { items, next_cursor, has_more }.
  • For sort/filter, document whitelisted fields. Avoid arbitrary sort_by if you can’t guarantee stable order.
  • Document RateLimit-* headers and Retry-After behavior on 429.

5) Auth, permissions, and idempotency

  • Document securitySchemes (OAuth2, bearer, API keys), required scopes, and which endpoints need which.
  • For writes, support Idempotency-Key on POST/PUT/PATCH to make retries safe. Specify retention window (e.g., 24h).
  • For webhooks, document signature verification and replay protection (timestamps + tolerance).

6) Versioning & deprecation

  • Put the major version in the server URL (/v1). Minor/patch changes are additive only (new fields, optional params).
  • Breaking changes → /v2. Publish migration guides and add Sunset + Deprecation headers with clear dates.
  • For schema evolution: only add fields; don’t change types; don’t remove required fields; don’t repurpose enums (add new values).

7) Tooling that pays off (CI gates)

  • Lint: Stoplight Spectral with your style rules (operationId pattern, tag presence, no anonymous schemas).
  • Diff: oasdiff to fail PRs on breaking changes (response schema tightening, removed fields, status code removals).
  • Test: Prism (mock server), Dredd (request/response contract), Schemathesis (property‑based fuzzing, edge cases).
  • Docs: Redoc/Swagger UI with auth controls, server selector, and code samples.
  • SDKs: OpenAPI Generator only after the spec is clean; fork templates to enforce your naming conventions.

8) Examples everywhere (clients copy what they see)

  • Include request/response examples for success and failure.
  • Show cURL, JavaScript/TypeScript, Python, Go snippets.
  • Keep example IDs realistic (u_123, pi_123), timestamps in RFC 3339, and uuid where applicable.

9) Common pitfalls & fast fixes

| Pitfall | Why it hurts | Fix | |---|---|---| | No operationId | Bad SDK names, breaking clients | Require unique, verb‑noun operationId | | Inconsistent pagination | Client spaghetti | Standardize on cursor contract across resources | | Free‑form errors | Unparseable | Use Problem Details everywhere | | Leaky enums | New values break clients | Advise clients to treat enums as open; document new values | | Missing examples | Poor DX, copy‑paste bugs | Add examples for every 2xx/4xx/5xx | | Nested anonymous schemas | Duplication & drift | Ref reusable components/schemas | | Changing response shapes | Breaks SDKs | Only additive changes on v1; bump major for breaking | | One giant YAML | Merge hell | Use multi‑file + bundle in CI (e.g., redocly build) |


Quick checklist

  • [ ] OpenAPI 3.1, JSON Schema 2020‑12, lint with Spectral.
  • [ ] Every op has operationId, summary, tags, examples.
  • [ ] Problem Details for 4xx/5xx; rate‑limit headers documented.
  • [ ] Cursor pagination (+ filters/sorts), consistent across endpoints.
  • [ ] Idempotency‑Key for writes; webhook signature docs.
  • [ ] /v1 in URL; Sunset/Deprecation headers for old endpoints.
  • [ ] CI: oasdiff for breaking changes, Prism/Dredd/Schemathesis tests.
  • [ ] Redoc/Swagger UI with auth + code samples.
  • [ ] Bundle & publish spec with every release; version the spec file too.

One‑minute adoption plan

  1. Convert your spec to 3.1 and set jsonSchemaDialect. Add Problem Details response and RateLimit headers in components.
  2. Pick cursor pagination and refactor list endpoints to { items, next_cursor, has_more }.
  3. Add operationId, tags, and examples to all ops; move inline schemas to components.
  4. Wire Spectral + oasdiff in CI; fail on breaking changes.
  5. Stand up Redoc with a “Try it” console and publish it automatically from main.