In 02-08 we wrote a fragment of openapi.yaml: a single endpoint, GET /coffees, with its parameters and two responses. Since then that file has followed us through the whole course —we linted it with Spectral in 04-01, we mentioned it when importing the collection in 05-01— but it still describes a tiny fraction of an API that today has six resources, a dozen subresources, JWT authentication, OAuth with six scopes, twenty-five error codes and headers of its own.

An incomplete contract is worse than no contract at all, because it creates unjustified confidence. Anyone who reads openapi.yaml and does not find POST /orders concludes that it does not exist, or —worse— that it exists and works the way they imagine.

This lesson finishes the job. We are going to build the complete Aroma Store specification section by section, understand the difference between writing it by hand and generating it from the code, serve Swagger UI from the project itself, validate it at two levels and use it to generate the TypeScript clients for the SPA and for Aroma Mobile. By the end, openapi.yaml will stop being documentation and become the source from which other things are produced.

Contents

  1. OpenAPI and Swagger: two things people confuse
  2. Versions: 2.0, 3.0 and 3.1
  3. The anatomy of the document
  4. openapi, info and servers
  5. tags: the organisation the reader sees
  6. paths: GET /coffees in full
  7. paths: POST /orders in full
  8. components.schemas: the Aroma Store types
  9. Reusable components.parameters and components.responses
  10. securitySchemes and security: JWT and OAuth 2.0
  11. $ref and the limits of reuse
  12. example versus examples
  13. oneOf, allOf and discriminator
  14. Documenting deprecation and rate limits
  15. Two ways of working: by hand or from the code
  16. Generating the specification with swagger-jsdoc
  17. Serving Swagger UI at /docs
  18. Rendering alternatives: Redoc, Scalar, Stoplight Elements
  19. Validating the specification: swagger-cli and Spectral
  20. Generating clients with OpenAPI Generator
  21. Keeping the contract in sync

  1. OpenAPI and Swagger: two things people confuse

The confusion is historical and deserves two minutes, because it affects how you search for tools.

  • Swagger was born in 2011 as a specification format and a set of tools, the work of Tony Tam. In 2015 SmartBear bought the project and donated the specification to the Linux Foundation.
  • The donated specification was renamed the OpenAPI Specification (OAS) and is governed by the OpenAPI Initiative. Swagger 2.0 was renamed OpenAPI 2.0; from there on, the versions are 3.0 and 3.1.
  • Swagger today is the brand for SmartBear's family of tools.
Name What it is Example of use
OpenAPI The specification: how the YAML/JSON is written The openapi: 3.1.0 in our file
Swagger UI Interactive HTML renderer for a specification We will serve it at /docs
Swagger Editor Web editor with live validation Writing the YAML with autocompletion
Swagger Codegen Client and server generator In practice superseded by OpenAPI Generator
swagger-jsdoc Generates OpenAPI from comments in the code The code-first approach in section 16
swagger-ui-express Express middleware that serves Swagger UI The /docs route

A mnemonic: the file is OpenAPI; whatever draws and processes it tends to be called Swagger. Saying "my Swagger" when you mean the file is common and everybody understands you, but knowing the difference stops you wasting time in the wrong documentation.

  1. Versions: 2.0, 3.0 and 3.1

2.0 (Swagger) 3.0 3.1
Year 2014 2017 2021
Servers host + basePath + schemes servers (a list, with variables) Same as 3.0
Request body A parameter with in: body requestBody with content per type Same as 3.0
Reusable pieces definitions, parameters, responses Everything under components Same as 3.0
JSON Schema An incompatible subset of its own An extended subset, almost compatible Full JSON Schema 2020-12
nullable Does not exist nullable: true type: [string, "null"]
Webhooks No No webhooks as a top-level section
Examples example example and examples Same, plus JSON Schema's examples
Tool support Total (legacy) Total Good, with exceptions

Which version to use. Aroma Store uses 3.1 for two concrete reasons:

  1. Complete alignment with JSON Schema 2020-12. The contract's schemas can be used as they are in AJV to validate responses in the tests (05-04) and in the server's validate(), with no translation and no surprises. In 3.0 the schemas were "almost" JSON Schema, and that "almost" costs entire afternoons.
  2. Top-level webhooks. Our architecture sends order.paid and order.shipped to SwiftShip with an HMAC signature. In 3.0 there was no way to document them as part of the API; they ended up smuggled into the prose description.

The price to pay: some older tool still cannot digest 3.1 and you have to downgrade to 3.0 for certain generators. It is a shrinking problem, and openapi.yaml can be converted automatically when needed.

  1. The anatomy of the document

An OpenAPI 3.1 document has these top-level sections:

openapi: 3.1.0        # version of the SPECIFICATION (not of your API)
info: {}              # metadata: title, your API's version, contact, licence
servers: []           # where the API lives: production, test, local
tags: []              # groupings for the documentation
security: []          # security applied by default to every operation
paths: {}             # the routes and their operations — the bulk of the file
webhooks: {}          # (3.1) outbound events: the SwiftShip ones
components: {}        # reusable pieces referenced with $ref
externalDocs: {}      # link to complementary documentation

Of these, openapi, info and one of paths/webhooks/components are mandatory. The rest is optional but, without servers or security, the specification is no use for generating anything useful.

  1. openapi, info and servers

openapi: 3.1.0

info:
  title: Aroma Store API
  summary: Catalogue, orders and reviews for speciality coffee.
  description: |
    REST API for **Aroma Store**, an online speciality coffee shop.

    ## General conventions

    - All identifiers are **opaque** and prefixed (`cof_`, `ord_`, `cus_`).
      Do not parse or build them: use them exactly as you receive them.
    - Amounts travel as **euros with two decimals** (`priceEuros`, `totalEuros`).
    - Dates are **ISO-8601 in UTC** with a `Z` suffix.
    - Collections return `{ "data": [...], "total": n }` and are
      **always paginated**: without `limit`, 20 items are applied.
    - Errors follow the format `{ "error": { "code", "message", "details" } }`.
      The `code` is stable and is what you should program against; the `message` may change.
    - An unknown query parameter produces `400`, it is not ignored.

    ## Usage limits

    600 requests per minute for authenticated clients. Going over answers
    `429` with `Retry-After`. Check the `Aroma-RateLimit-*` headers on every response.

    ## Compatibility

    We add new fields without prior notice: **ignore any you do not recognise**.
    Breaking changes arrive in a major version of the path (`/v2`), with a minimum
    of 6 months of overlap and `Deprecation` and `Sunset` headers.
  version: 1.7.0
  termsOfService: https://aromastore.example/api-terms
  contact:
    name: Aroma Store platform team
    url: https://developers.aromastore.example
    email: [email protected]
  license:
    name: Proprietary
    url: https://aromastore.example/api-licence

servers:
  - url: https://api.aromastore.example/v1
    description: Production. Real data; usage limits are enforced for real.
  - url: https://api-test.aromastore.example/v1
    description: Test (sandbox). Fictional data, reset every night.
  - url: http://localhost:3000/v1
    description: Local development.

Three warnings about this header, which looks trivial and is not:

  • info.version is your API's version, not OpenAPI's. They are different fields and people constantly confuse them. We use SemVer: 1.7.0 means there have been seven rounds of compatible additions since 1.0.0. A 2.0.0 would imply a breaking change and therefore a /v2 in the path, as per 02-07.
  • info.description is the front page of your documentation. It is the only place with room for the cross-cutting conventions —money, dates, opaque identifiers, pagination, compatibility— that belong to no particular endpoint and are nevertheless the first thing an integrator needs. It accepts Markdown and Swagger UI renders it.
  • The servers' url includes /v1. A direct consequence of our decision to version in the path: the paths keys stay as /coffees, without repeating /v1. If you put it in both places, generated clients would call /v1/v1/coffees.

info.contact.url points at the developer portal we will see in 05-06.

  1. tags: the organisation the reader sees

tags group operations. Without them, Swagger UI shows a flat list of forty endpoints and nobody finds anything.

tags:
  - name: Coffees
    description: |
      Speciality coffee catalogue. Reading is public as far as the data goes,
      but it requires authentication; writing requires the `administrator` role.
  - name: Orders
    description: |
      Order lifecycle: creation, payment, shipment, invoice, cancellation and return.
      State transitions are done with subresources, not by changing `status` with PATCH.
  - name: Customers
    description: Customer data, preferences and orders.
  - name: Reviews
    description: Coffee reviews and their moderation.
  - name: Carts
    description: Shopping cart prior to the order.
  - name: Sessions
    description: Authentication with credentials and obtaining the access token.
  - name: Operations
    description: Service health and metadata. Not part of `/v1`.

x-tagGroups:            # an extension understood by Redoc and some portals
  - name: Commerce
    tags: [Coffees, Carts, Orders]
  - name: Community
    tags: [Customers, Reviews]
  - name: Platform
    tags: [Sessions, Operations]

Two criteria: one tag per resource (resources are stable, use cases are not) and descriptions that carry the non-obvious business rule, such as the fact that state transitions are subresources. That one sentence prevents half a dozen questions in the support channel.

Any field starting with x- is an extension: the specification allows you to add them, tools ignore them if they do not understand them, and some —like Redoc with x-tagGroups— make use of them.

  1. paths: GET /coffees in full

We pick up the fragment from 02-08 and take it to its final form, now with references into components:

paths:
  /coffees:
    get:
      operationId: getCoffees            # the method name in generated clients
      summary: Lists the coffee catalogue
      description: |
        Returns the catalogue's coffees filtered, sorted and paginated.
        Pagination is mandatory: without `limit`, 20 items are applied and the
        maximum is 100. An unknown query parameter produces `400`.
      tags: [Coffees]
      parameters:
        - name: origin
          in: query
          description: Filters by country of origin. Several values separated by commas.
          required: false
          schema: { type: string }
          example: Colombia,Ethiopia
        - name: roast
          in: query
          description: Filters by roast level. Several values separated by commas.
          schema:
            type: string
            pattern: '^(light|medium|dark)(,(light|medium|dark))*$'
          example: light,medium
        - name: priceMin
          in: query
          description: Minimum price in euros, inclusive.
          schema: { type: number, minimum: 0 }
        - name: priceMax
          in: query
          description: Maximum price in euros, inclusive.
          schema: { type: number, minimum: 0 }
        - name: available
          in: query
          description: If `true`, returns only coffees with `stock` greater than zero.
          schema: { type: boolean }
        - name: q
          in: query
          description: Text search across name, origin and tasting notes.
          schema: { type: string, minLength: 2, maxLength: 100 }
        - name: sort
          in: query
          description: |
            Sort field; the `-` prefix reverses the order. Several fields separated
            by commas are accepted. The final tie-break is always `id` ascending.
          schema:
            type: string
            default: name
            example: -priceEuros,name
        - name: fields
          in: query
          description: Comma-separated list of fields to include in each item.
          schema: { type: string }
          example: id,name,priceEuros
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection of coffees matching the filter.
          headers:
            Link:
              $ref: '#/components/headers/Link'
            ETag:
              $ref: '#/components/headers/ETag'
            Aroma-RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CoffeeCollection' }
              examples:
                firstPage:
                  summary: First page of the catalogue
                  value:
                    data:
                      - id: cof_001
                        name: Ethiopia Yirgacheffe
                        origin: Ethiopia
                        roast: light
                        priceEuros: 14.50
                        stock: 120
                        tastingNotes: [citrus, floral, black tea]
                        createdAt: '2026-01-15T08:30:00Z'
                        version: 3
                      - id: cof_002
                        name: Colombia Huila
                        origin: Colombia
                        roast: medium
                        priceEuros: 12.90
                        stock: 80
                        tastingNotes: [caramel, nutty]
                        createdAt: '2026-01-16T09:10:00Z'
                        version: 1
                    total: 137
                noResults:
                  summary: Filter with no matches — 200 with an empty list, never 404
                  value: { data: [], total: 0 }
        '304':
          description: Not modified. Returned if `If-None-Match` matches the `ETag`.
        '400': { $ref: '#/components/responses/Error400' }
        '401': { $ref: '#/components/responses/Error401' }
        '429': { $ref: '#/components/responses/Error429' }
        '5XX': { $ref: '#/components/responses/Error500' }

    post:
      operationId: createCoffee
      summary: Creates a coffee in the catalogue
      description: Requires the `administrator` role.
      tags: [Coffees]
      security:
        - bearerJWT: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewCoffee' }
      responses:
        '201':
          description: Coffee created.
          headers:
            Location:
              description: URI of the created resource.
              schema: { type: string, format: uri-reference }
              example: /v1/coffees/cof_017
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Coffee' }
        '400': { $ref: '#/components/responses/Error400' }
        '401': { $ref: '#/components/responses/Error401' }
        '403': { $ref: '#/components/responses/Error403' }
        '409':
          description: A coffee with that name and origin already exists.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

    parameters: []   # (parameters common to every operation on this path)

Details that separate a useful specification from one that merely compiles:

  • operationId is mandatory in practice. It is the method name in generated clients: getCoffees produces api.getCoffees({...}). It must be unique across the whole document and stable over time: changing it breaks the code of every consumer using the generated client, even though the API has not changed at all.
  • The noResults example documents a design decision from 02-04 —a filter with no matches is 200 with an empty list, not 404— better than three paragraphs would.
  • '5XX' is the way to group the whole family of server errors without repeating yourself. The quotes are mandatory in YAML: without them, 404 is read as a number.
  • security at operation level overrides the global one. Here POST /coffees explicitly requires bearerJWT because it does not accept the third-party OAuth flow.

  1. paths: POST /orders in full

The richest case in the contract: it requires Idempotency-Key, it has OAuth scopes, and its errors are business errors.

  /orders:
    post:
      operationId: createOrder
      summary: Creates an order
      description: |
        Creates an order in `pending_payment` status and **reserves the stock** for each item.

        This operation **requires the `Idempotency-Key` header**: repeating the request with
        the same key and the same body returns the original response without creating a
        new order. Repeating it with the same key and a different body produces `409`.
        Store the key before sending and reuse it on any retry.
      tags: [Orders]
      security:
        - bearerJWT: []
        - oauth2: [orders.write]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: UUID v4 generated by the client. Kept for 24 hours.
          schema: { type: string, format: uuid }
          example: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewOrder' }
            examples:
              twoItems:
                summary: Order with two coffees
                value:
                  customerId: cus_842
                  items:
                    - { coffeeId: cof_001, quantity: 2 }
                    - { coffeeId: cof_002, quantity: 1 }
      responses:
        '201':
          description: Order created and stock reserved.
          headers:
            Location:
              description: URI of the created order.
              schema: { type: string, format: uri-reference }
              example: /v1/orders/ord_5001
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '400': { $ref: '#/components/responses/Error400' }
        '401': { $ref: '#/components/responses/Error401' }
        '403': { $ref: '#/components/responses/Error403' }
        '409':
          description: |
            Business conflict. Check `error.code` to tell them apart:
            - `insufficient_stock`: some item exceeds the available stock.
            - `idempotency_key_reused`: same `Idempotency-Key`, different body.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                insufficientStock:
                  value:
                    error:
                      code: insufficient_stock
                      message: 'There is not enough stock of "Ethiopia Yirgacheffe".'
                      details:
                        - { field: 'items[0].quantity', requested: 200, available: 120 }
                keyReused:
                  value:
                    error:
                      code: idempotency_key_reused
                      message: 'The idempotency key was already used with a different body.'
                      details: []
        '428':
          description: The `Idempotency-Key` header is missing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: idempotency_key_required
                  message: 'The Idempotency-Key header is mandatory on this operation.'
                  details: []
        '429': { $ref: '#/components/responses/Error429' }

Notice how the 409 is documented with two named examples: the same HTTP code means two different things and the consumer programs against error.code, not against the status. That is the whole point of the 02-04 error catalogue, and here it becomes visible.

  1. components.schemas: the Aroma Store types

The schemas are the most reused part of the document and the part that will feed the validations in 05-04 and the generated clients in section 20.

components:
  schemas:
    Coffee:
      type: object
      title: Coffee
      description: A coffee from the catalogue.
      required: [id, name, origin, roast, priceEuros, stock, createdAt, version]
      properties:
        id:
          type: string
          pattern: '^cof_[A-Za-z0-9]+$'
          description: Opaque identifier. Do not parse it or build it.
          examples: [cof_001]
          readOnly: true
        name: { type: string, minLength: 1, maxLength: 120, examples: [Ethiopia Yirgacheffe] }
        origin: { type: string, minLength: 2, maxLength: 60, examples: [Ethiopia] }
        roast:
          type: string
          enum: [light, medium, dark]
          description: Roast level. A closed set of values; new ones may be added in the future.
        priceEuros:
          type: number
          minimum: 0
          multipleOf: 0.01
          description: |
            Selling price in euros with two decimals. Internally it is stored as
            whole cents: do not do floating-point arithmetic with this value if you
            need exactness; multiply by 100 and work with integers.
          examples: [14.50]
        stock: { type: integer, minimum: 0, examples: [120] }
        tastingNotes:
          type: array
          maxItems: 8
          items: { type: string, maxLength: 40 }
          examples: [[citrus, floral, black tea]]
        createdAt:
          type: string
          format: date-time
          description: Creation date in ISO-8601 UTC.
          examples: ['2026-01-15T08:30:00Z']
          readOnly: true
        version:
          type: integer
          minimum: 1
          description: |
            Version for optimistic concurrency. It matches the response's `ETag`;
            send it in `If-Match` when modifying.
          readOnly: true
        _links:
          $ref: '#/components/schemas/Links'

    NewCoffee:
      type: object
      title: New coffee
      description: Body for creating a coffee. It does not include server-computed fields.
      required: [name, origin, roast, priceEuros, stock]
      additionalProperties: false      # an unknown field produces 400 (03-04)
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        origin: { type: string, minLength: 2, maxLength: 60 }
        roast: { type: string, enum: [light, medium, dark] }
        priceEuros: { type: number, minimum: 0, multipleOf: 0.01 }
        stock: { type: integer, minimum: 0, default: 0 }
        tastingNotes:
          type: array
          maxItems: 8
          items: { type: string, maxLength: 40 }

    CoffeePatch:
      type: object
      title: Coffee patch (merge-patch)
      description: |
        `PATCH` body with `Content-Type: application/merge-patch+json`.
        Every field is optional; `null` clears the field where that is permissible.
      additionalProperties: false
      minProperties: 1                 # an empty patch makes no sense: 400
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        priceEuros: { type: number, minimum: 0, multipleOf: 0.01 }
        stock: { type: integer, minimum: 0 }
        tastingNotes:
          type: [array, 'null']        # 3.1 syntax: in 3.0 it would be nullable: true
          items: { type: string, maxLength: 40 }

    CoffeeCollection:
      type: object
      title: Coffee collection
      required: [data, total]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Coffee' }
        total:
          type: integer
          minimum: 0
          description: Total items matching the filter, not items on the current page.

    OrderItem:
      type: object
      required: [coffeeId, quantity]
      properties:
        coffeeId: { type: string, pattern: '^cof_[A-Za-z0-9]+$' }
        quantity: { type: integer, minimum: 1, maximum: 99 }
        unitPriceEuros: { type: number, readOnly: true }
        subtotalEuros: { type: number, readOnly: true }

    NewOrder:
      type: object
      required: [customerId, items]
      additionalProperties: false
      properties:
        customerId: { type: string, pattern: '^cus_[A-Za-z0-9]+$' }
        items:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/OrderItem' }

    Order:
      type: object
      required: [id, customerId, items, totalEuros, status, createdAt]
      properties:
        id: { type: string, pattern: '^ord_[A-Za-z0-9]+$', readOnly: true }
        customerId: { type: string, pattern: '^cus_[A-Za-z0-9]+$' }
        items:
          type: array
          items: { $ref: '#/components/schemas/OrderItem' }
        totalEuros: { type: number, minimum: 0, readOnly: true, examples: [29.00] }
        status:
          type: string
          enum: [pending_payment, paid, shipped]
          description: |
            The status is **not modified with PATCH**: it changes by invoking the
            `/orders/{id}/payment`, `/orders/{id}/shipment` or `/orders/{id}/cancellation` subresources.
          readOnly: true
        createdAt: { type: string, format: date-time, readOnly: true }
        _links: { $ref: '#/components/schemas/Links' }

    Links:
      type: object
      description: Navigation links for the resource (HATEOAS, Richardson level 3).
      additionalProperties:
        type: object
        required: [href]
        properties:
          href: { type: string, format: uri-reference }
          method:
            type: string
            enum: [GET, POST, PUT, PATCH, DELETE]
            default: GET
      examples:
        - self: { href: /v1/orders/ord_5001 }
          payment: { href: /v1/orders/ord_5001/payment, method: POST }

    Error:
      type: object
      title: Error
      description: |
        The API's single error format. Always program against `error.code`,
        which is stable; `error.message` is meant for humans and may change
        without notice, even in language.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, details]
          properties:
            code:
              type: string
              description: Stable code from the error catalogue.
              enum:
                [coffee_not_found, customer_not_found, order_not_found,
                 review_not_found, cart_not_found, insufficient_stock,
                 order_already_paid, invalid_data, invalid_parameter, not_authenticated,
                 token_expired, insufficient_permissions, version_conflict,
                 precondition_required, operation_in_progress, idempotency_key_required,
                 idempotency_key_reused, rate_limit_exceeded, body_too_large,
                 route_not_found, method_not_allowed, unsupported_format,
                 internal_error, service_unavailable, api_version_retired]
            message: { type: string, description: Human-readable description in English. }
            details:
              type: array
              description: List of specific problems. Empty when not applicable.
              items:
                type: object
                properties:
                  field: { type: string, examples: ['items[0].quantity'] }
                  problem: { type: string }
            traceId:
              type: string
              format: uuid
              description: |
                Trace identifier. **Only present on 5xx responses.**
                Include it when you raise a ticket with support.

Four decisions that deserve justification:

  • readOnly: true marks the fields the server computes. Generators make use of it: the generated Coffee type includes them, but the creation body type omits them. That is why NewCoffee exists as a separate schema instead of reusing Coffee.
  • additionalProperties: false only on inputs. In the bodies we receive, an unknown field is a client error and we return 400 (03-04). On outputs, never: closing them would turn any new field into a breaking change for generated clients, against the rule from 02-07.
  • The complete enum of the error catalogue. High maintenance cost, high value: the generated TypeScript client gets a union type with all twenty-five codes and the compiler complains if somebody writes coffe_not_found.
  • multipleOf: 0.01 formally documents the two-decimal rule we have been carrying since 02-05.

  1. Reusable components.parameters and components.responses

  parameters:
    Limit:
      name: limit
      in: query
      description: Maximum number of items to return.
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Offset:
      name: offset
      in: query
      description: |
        Number of items to skip. Maximum 10,000; beyond that use `cursor`
        where available, because deep offsets degrade the query.
      schema: { type: integer, minimum: 0, maximum: 10000, default: 0 }
    CoffeeId:
      name: id
      in: path
      required: true
      description: Opaque identifier of the coffee.
      schema: { type: string, pattern: '^cof_[A-Za-z0-9]+$' }
      example: cof_001
    IfMatch:
      name: If-Match
      in: header
      required: true
      description: |
        `ETag` of the version you are modifying. Mandatory on `PUT`, `PATCH` and
        `DELETE`: without it you get `428`; if it does not match, `412`.
      schema: { type: string }
      example: 'W/"3"'

  headers:
    Link:
      description: Pagination links (RFC 8288) with `rel` values `next`, `prev`, `first` and `last`.
      schema: { type: string }
      example: '</v1/coffees?limit=20&offset=20>; rel="next"'
    ETag:
      description: Validator for the representation. Use it in `If-None-Match` and `If-Match`.
      schema: { type: string }
      example: 'W/"3"'
    RetryAfter:
      description: Seconds you must wait before retrying.
      schema: { type: integer }
      example: 30
    RateLimitRemaining:
      description: Requests you have left in the current window.
      schema: { type: integer }
      example: 597

  responses:
    Error400:
      description: Invalid request — body data or query parameters.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalidParameter:
              value:
                error:
                  code: invalid_parameter
                  message: "The 'limit' parameter cannot be greater than 100."
                  details: []
            invalidData:
              value:
                error:
                  code: invalid_data
                  message: 'The body contains invalid fields.'
                  details:
                    - { field: priceEuros, problem: 'must be greater than or equal to 0' }

    Error401:
      description: The token is missing, invalid or expired.
      headers:
        WWW-Authenticate:
          description: Expected scheme and reason for the rejection.
          schema: { type: string }
          example: 'Bearer realm="api", error="invalid_token"'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    Error403:
      description: Authenticated but without permission — insufficient role or OAuth scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    Error404:
      description: The resource does not exist or is not visible to you.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    Error429:
      description: The rate limit has been exceeded.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        Aroma-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: rate_limit_exceeded
              message: 'You have exceeded the limit of 600 requests per minute.'
              details: []

    Error500:
      description: |
        Internal error. Retry with exponential backoff and jitter. The body includes
        `traceId`: quote it if you raise a ticket.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

With this, each operation declares its errors in one line ('404': { $ref: '#/components/responses/Error404' }) and the day the error format changes you touch one place. Without components.responses, an API with forty operations repeats the error block two hundred times and, guaranteed, three of them end up out of date.

  1. securitySchemes and security: JWT and OAuth 2.0

  securitySchemes:
    bearerJWT:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT token obtained at `POST /v1/sessions` with email and password.
        It expires in 1 hour; renew it with `POST /v1/sessions/refresh`.
        It is the mechanism used by the SPA, the admin panel and Aroma Mobile.

    oauth2:
      type: oauth2
      description: |
        For third-party applications (such as CataBox) acting on behalf of an
        Aroma Store customer. Register your application in the developer portal
        to obtain a `client_id`. Public applications **must** use PKCE.
      flows:
        authorizationCode:
          authorizationUrl: https://auth.aromastore.example/oauth/authorize
          tokenUrl: https://auth.aromastore.example/oauth/token
          refreshUrl: https://auth.aromastore.example/oauth/token
          scopes:
            coffees.read: Read the coffee catalogue and its reviews.
            orders.read: Read the orders of the authorising customer.
            orders.write: Create and pay orders on the customer's behalf.
            reviews.write: Publish reviews on the customer's behalf.
        clientCredentials:
          tokenUrl: https://auth.aromastore.example/oauth/token
          scopes:
            shipments.write: Update the shipment status. Reserved for logistics partners.
            reviews.moderate: Approve or reject reviews. Reserved for internal tools.

# Default security for the WHOLE API: either scheme will do.
security:
  - bearerJWT: []
  - oauth2: []

How the two ways of combining are read, which is the part that confuses people most:

Written like this Means
security: [{ bearerJWT: [] }, { oauth2: [] }] JWT or OAuth: the outer list is an OR
security: [{ bearerJWT: [], apiKey: [] }] JWT and apiKey at the same time: within the same object it is an AND
security: [] on an operation That operation is public: it cancels the global security
security: [{ oauth2: [orders.write] }] OAuth with that specific scope

The exceptions to Aroma Store's global security:

  /sessions:
    post:
      operationId: login
      summary: Logs in and obtains a token
      tags: [Sessions]
      security: []          # public by definition: this is where you get the token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password, minLength: 8, writeOnly: true }
      responses:
        '200':
          description: Session started.
          content:
            application/json:
              schema:
                type: object
                required: [token, expiresIn, customer]
                properties:
                  token: { type: string, description: Access JWT. }
                  expiresIn: { type: integer, description: Seconds of validity., examples: [3600] }
                  customer: { $ref: '#/components/schemas/Customer' }
        '401':
          description: |
            Incorrect credentials. The message is **deliberately generic**:
            it does not reveal whether the email exists (04-02, user enumeration).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

writeOnly: true on password is the mirror image of readOnly: it is sent but never returned. Generators leave it out of the response types, and Swagger UI does not show it in the output examples.

  1. $ref and the limits of reuse

$ref is a JSON pointer. Its three forms:

# 1. Internal: to the document itself (the most common)
schema: { $ref: '#/components/schemas/Coffee' }

# 2. To another local file: lets you split up a large specification
schema: { $ref: './schemas/coffee.yaml' }
responses:
  '404': { $ref: './responses/common.yaml#/Error404' }

# 3. Remote: to a URL. Avoid it.
schema: { $ref: 'https://schemas.aromastore.example/coffee.yaml' }

When openapi.yaml goes past a thousand lines or so, splitting it into files and bundling them before publishing is the sensible thing to do:

# Bundles a split document into a single self-contained file
npx @redocly/cli bundle openapi.yaml -o dist/openapi.yaml

Two warnings from experience:

  • A remote $ref is a network dependency in your build process. If that host goes down or changes, your documentation stops compiling and you will not know why. If you need schemas shared between APIs, publish them as a package and bundle them at build time.
  • In OpenAPI 3.0, an object containing $ref ignores its siblings. Writing { $ref: '#/...', description: 'something else' } silently discarded the description. In 3.1 this was fixed and description and summary are respected alongside $ref, but not every tool has caught up; if you need to vary something, allOf is still the safe route.

  1. example versus examples

example examples
Where it lives Inside schema, or next to content Next to content, and in parameters
How many One Several, named
Structure The value directly Map of name: { summary, description, value }
When to use it A single field Alternative cases: success, empty, business error
# Bad: a single example loses the nuance of the different 409s
'409':
  content:
    application/json:
      schema: { $ref: '#/components/schemas/Error' }
      example: { error: { code: insufficient_stock, message: '...', details: [] } }

# Good: each case with its name; Swagger UI offers a dropdown to pick one
'409':
  content:
    application/json:
      schema: { $ref: '#/components/schemas/Error' }
      examples:
        insufficientStock:
          summary: Some item exceeds the available stock
          value: { error: { code: insufficient_stock, message: '...', details: [] } }
        keyReused:
          summary: Same Idempotency-Key with a different body
          value: { error: { code: idempotency_key_reused, message: '...', details: [] } }

A detail specific to 3.1: inside a schema the correct word is examples in the plural and as an array (it comes from JSON Schema 2020-12), whereas next to content it is a map with names. They are two different fields spelled the same way; you saw them in section 8 as examples: [cof_001].

And the rule that adds the most value: use realistic examples that are consistent with each other. If the POST /orders example mentions cof_001 with quantity: 2 at €14.50, the response example must say totalEuros: 29.00 and not 99.99. Inconsistent examples destroy trust in the entire documentation, and on top of that they feed the mocks in 05-04.

  1. oneOf, allOf and discriminator

The three combiners, with the real example of the notifications sent to SwiftShip:

Keyword Means Typical use
allOf Satisfies all the schemas Inheritance: base + extension
oneOf Satisfies exactly one Mutually exclusive variants
anyOf Satisfies at least one Uncommon; usually a sign of confused design
    BaseEvent:
      type: object
      required: [id, type, issuedAt]
      properties:
        id: { type: string, examples: [evt_9001] }
        type: { type: string }
        issuedAt: { type: string, format: date-time }

    OrderPaidEvent:
      allOf:
        - $ref: '#/components/schemas/BaseEvent'
        - type: object
          required: [data]
          properties:
            type: { const: order.paid }
            data:
              type: object
              properties:
                orderId: { type: string, examples: [ord_5001] }
                totalEuros: { type: number, examples: [29.00] }

    OrderShippedEvent:
      allOf:
        - $ref: '#/components/schemas/BaseEvent'
        - type: object
          required: [data]
          properties:
            type: { const: order.shipped }
            data:
              type: object
              properties:
                orderId: { type: string }
                tracking: { type: string, examples: [SS-4471-XA] }

    Event:
      oneOf:
        - $ref: '#/components/schemas/OrderPaidEvent'
        - $ref: '#/components/schemas/OrderShippedEvent'
      discriminator:
        propertyName: type
        mapping:
          order.paid: '#/components/schemas/OrderPaidEvent'
          order.shipped: '#/components/schemas/OrderShippedEvent'

discriminator tells the validator and the generator which field to look at to know which variant this is. Without it, a validator has to try them all and a generator produces a union type with no way to narrow it. With it, the generated TypeScript client gets a discriminated union and a switch (event.type) with exhaustiveness checking.

And, because the document is 3.1, these events are declared as top-level webhooks:

webhooks:
  orderPaid:
    post:
      operationId: receiveOrderPaid
      summary: Order paid notification
      description: |
        Aroma Store sends this request **to the URL you have registered** when an
        order is paid. Verify the signature before processing the body: the
        `Aroma-Signature` header contains the HMAC-SHA256 of the raw body computed with
        your shared secret. Answer `2xx` within 5 seconds; we retry with exponential
        backoff for 24 hours.
      parameters:
        - name: Aroma-Signature
          in: header
          required: true
          schema: { type: string, examples: ['sha256=9f2a...'] }
        - name: Aroma-Event-Id
          in: header
          required: true
          description: Unique identifier of the event. Use it to discard duplicates.
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderPaidEvent' }
      responses:
        '200': { description: Notification accepted. }

  1. Documenting deprecation and rate limits

The deprecation from 02-07 has a formal expression in OpenAPI:

  /coffees/{id}/ratings:
    get:
      operationId: getCoffeeRatings
      summary: '[Deprecated] Ratings of a coffee'
      deprecated: true
      description: |
        > **Deprecated since 1.5.0. It will be retired on 30 June 2027.**
        >
        > Use `GET /coffees/{id}/reviews`, which returns the same data with `rating`
        > and `comment` in a single resource. Migration guide:
        > https://developers.aromastore.example/migration/reviews

        The responses include the `Deprecation` and `Sunset` headers.
      tags: [Coffees]
      parameters:
        - $ref: '#/components/parameters/CoffeeId'
      responses:
        '200':
          description: Ratings of the coffee.
          headers:
            Deprecation:
              description: Date on which the operation became deprecated (RFC 9745).
              schema: { type: string }
              example: '@1767225600'
            Sunset:
              description: Date of final retirement (RFC 8594).
              schema: { type: string }
              example: 'Tue, 30 Jun 2027 23:59:59 GMT'
            Link:
              description: Link to the alternative, with rel="successor-version".
              schema: { type: string }

deprecated: true makes Swagger UI strike the operation through and makes generated clients mark the method as deprecated: in TypeScript, with @deprecated, the editor strikes it through; in Java, with @Deprecated, the compiler warns. It is the most effective way of warning people: it appears exactly where the developer is looking.

The field also exists on a schema's properties and on parameters:

        priceCents:
          type: integer
          deprecated: true
          description: 'Deprecated: use `priceEuros`. It will be removed in v2.'

Rate limits are documented in three complementary places, because none of them is enough on its own: the global info description (the general policy), the reusable Error429 response with its headers, and the description of any operation with a specific limit, such as POST /sessions with its stricter loginLimit from 04-04.

  1. Two ways of working: by hand or from the code

Specification first (by hand) Code first (annotations)
Who writes the contract The team, before implementing It is derived from code already written
Tooling YAML editor, Swagger Editor, Stoplight swagger-jsdoc, NestJS decorators, springdoc
Contract as a prior agreement Yes: it can be reviewed and mocked beforehand No: it exists when the code exists
Risk of drift High if nobody checks it Low for shape, high for meaning
Documentation quality High: descriptions and examples written on purpose Usually poor: types with no explanation
Working in parallel The front end starts on day 1 with a mock The front end waits for the API to exist
Up-front cost High Low
Maintenance cost Medium and constant Low, but deceptively so
Fits Public API, several consumers, separate teams Internal service, one team, fast iteration

Aroma Store follows the specification-first approach, and that decision has been taken since 02-01. The reason is concrete: we have five consumers —the SPA, Aroma Mobile, the admin panel, SwiftShip and CataBox— and three of them are developed by people who are not us. The contract has to exist before the code because that is what makes parallel work possible.

The important nuance, and where a lot of people fool themselves: generating the specification from the code eliminates structural drift, not semantic drift. The generator knows the endpoint returns an object with a status field of type string; it does not know that pending_payment only becomes paid through the /payment subresource, nor that the price must not be used in floating-point arithmetic. All the valuable information in our openapi.yaml has been written by a person thinking about whoever is going to integrate.

And the by-hand approach has drift of its own: nothing guarantees that the YAML describes what the server actually does. There are two remedies for that, and both are in the course: the Spectral rules from 04-01 and, above all, the contract tests in 05-04, which validate real responses against the schema.

  1. Generating the specification with swagger-jsdoc

Even though it is not our approach, it is worth knowing what it looks like, because you will run into it. With swagger-jsdoc the specification is written in JSDoc comments next to the routes:

// src/routes/coffees.js — example of the "code first" approach (NOT Aroma Store's)

/**
 * @openapi
 * /coffees/{id}:
 *   get:
 *     operationId: getCoffeeById
 *     summary: Gets a coffee by its identifier
 *     tags: [Coffees]
 *     parameters:
 *       - $ref: '#/components/parameters/CoffeeId'
 *     responses:
 *       '200':
 *         description: The requested coffee.
 *         content:
 *           application/json:
 *             schema: { $ref: '#/components/schemas/Coffee' }
 *       '404':
 *         $ref: '#/components/responses/Error404'
 */
router.get('/:id', authenticate, asyncHandler(getCoffeeById));

And it is assembled in a configuration module:

// src/config/openapi.js
import swaggerJsdoc from 'swagger-jsdoc';

export const specification = swaggerJsdoc({
  definition: {
    openapi: '3.1.0',
    info: { title: 'Aroma Store API', version: '1.7.0' },
    servers: [{ url: 'http://localhost:3000/v1' }],
  },
  // Files to search for @openapi comments
  apis: ['./src/routes/*.js', './src/schemas/*.js'],
});

Real advantage: the comment sits a centimetre from the code, so whoever changes the route sees the documentation. Real drawback: it is YAML inside comments, with no autocompletion or validation while you type, and an indentation mistake shows up at run time. On top of that, you still have to write it by hand; the only thing that gets automated is the assembly.

An intermediate approach that is gaining ground in the Node ecosystem and deserves a mention: deriving the specification from the validation schemas you already have. Our Zod schemas in src/schemas/ already describe the exact shape of the inputs; with zod-to-json-schema they can be turned into the document's components.schemas, so validation and documentation cannot diverge:

// Helper tool: exports the Zod schemas as JSON Schema
import { zodToJsonSchema } from 'zod-to-json-schema';
import { newCoffeeSchema } from '../src/schemas/coffees.js';

const jsonSchema = zodToJsonSchema(newCoffeeSchema, { target: 'jsonSchema2020-12' });
console.log(JSON.stringify({ components: { schemas: { NewCoffee: jsonSchema } } }, null, 2));

It is the best tool against drift in the structural part, without giving up writing the descriptions and examples by hand. Frameworks such as Fastify and NestJS do this out of the box, as we will see in 05-03.

  1. Serving Swagger UI at /docs

Now we serve the documentation from the project itself.

npm install swagger-ui-express yaml

New file src/config/openapi.js:

// src/config/openapi.js
// Loads and exposes the project's OpenAPI specification.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import YAML from 'yaml';

const here = dirname(fileURLToPath(import.meta.url));
const specificationPath = join(here, '..', '..', 'openapi.yaml');

// It is read ONCE at start-up: it is an immutable file for the life of the process.
// If it fails, let it fail here and not on the first request to /docs.
export const specification = YAML.parse(readFileSync(specificationPath, 'utf8'));

export const apiVersion = specification.info.version;

New file src/routes/documentation.js:

// src/routes/documentation.js
import { Router } from 'express';
import swaggerUi from 'swagger-ui-express';
import { specification } from '../config/openapi.js';
import { environment } from '../config/environment.js';

export const documentationRoutes = Router();

// The raw document: this is what client generators consume, along with
// Prism (05-04), the gateway (05-06) and the Postman import (05-01).
documentationRoutes.get('/openapi.json', (req, res) => {
  res.type('application/json').send(specification);
});

const uiOptions = {
  customSiteTitle: 'Aroma Store API — documentation',
  swaggerOptions: {
    // Locally we point "Try it out" at the local server; in other environments,
    // at whichever is appropriate. Without this, the button fires at production.
    urls: undefined,
    persistAuthorization: true,   // keeps the token between reloads: very convenient
    displayRequestDuration: true,
    docExpansion: 'list',         // lists the operations collapsed, not expanded
    filter: true,                 // search box by tag
    tryItOutEnabled: environment.nodeEnv !== 'production',
  },
};

documentationRoutes.use('/', swaggerUi.serve, swaggerUi.setup(specification, uiOptions));

And its registration in src/app.js. Position matters: before app.use('/v1', v1Routes) and, above all, outside /v1, because documentation is not a versioned resource of the API.

// src/app.js — fragment, between positions 13 and 14 of the chain
import { documentationRoutes } from './routes/documentation.js';

// ... 13. conditionalEtag

// 13-bis. Documentation. Outside /v1 and with its own access policy.
if (environment.publicDocs || environment.nodeEnv !== 'production') {
  app.use('/docs', documentationRoutes);
} else {
  // In production we require employee authentication to view the internal contract.
  app.use('/docs', authenticate, requireRole('employee', 'administrator'), documentationRoutes);
}

// 14. app.use('/v1', v1Routes)

Four considerations about exposing the documentation:

  • helmet and Swagger UI clash. Helmet's default Content-Security-Policy (04-02) blocks the inline styles Swagger UI uses, and the page comes up blank and unstyled. The correct fix is not to disable helmet but to relax the policy on that route only:
// CSP exception scoped to /docs; the rest of the API keeps the strict policy
app.use('/docs', helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", 'data:'],
    scriptSrc: ["'self'", "'unsafe-inline'"],
  },
}), documentationRoutes);
  • /docs must not count against the API's rate limit or pollute the metrics from 04-07. If metricsMiddleware labels it as a route, you will see an anomalous p99 latency caused by people reading documentation.
  • Public or protected? If the API is public, so is the documentation: it is your shop window. If it is internal, the contract is a detailed map of your attack surface —routes, parameters, schemas— and it is valuable information for anyone attacking you. Protecting it is not real security (security lives in the endpoints' authentication), but it does reduce noise and unnecessary exposure.
  • Swagger UI's "Try it out" fires real requests from the browser. In production it is best to disable it, and in any case remember that it needs the documentation's origin to be on the CORS allowlist from 04-05 if you serve it from another domain.

  1. Rendering alternatives: Redoc, Scalar, Stoplight Elements

Swagger UI is not the only way to draw the same openapi.yaml.

Renderer Look Live try-out Strong at When to choose it
Swagger UI Classic, dense Yes Ubiquity; everybody recognises it Internal documentation, development
Redoc Three columns, typographic Only in the paid version Large specifications, long reading, x-tagGroups Public reference documentation
Scalar Modern, dark by default Yes, with a built-in client Speed, good experience, multi-language examples New portals
Stoplight Elements Web component Yes Embedding it in a portal of your own Bespoke developer portal (05-06)

Switching renderer is a matter of minutes because they all consume the same file:

// Alternative with Redoc served statically, with no external CDN dependencies
documentationRoutes.get('/reference', (req, res) => {
  res.type('html').send(`<!doctype html>
<html>
  <head><title>Aroma Store API</title><meta charset="utf-8"></head>
  <body>
    <redoc spec-url="/docs/openapi.json"></redoc>
    <script src="/static/redoc.standalone.js"></script>
  </body>
</html>`);
});

Notice that the script is served from /static and not from an external CDN: a CDN in the documentation is a third-party dependency that the CSP from 04-02 should block, and rightly so.

  1. Validating the specification: swagger-cli and Spectral

There are two levels of validation that solve different problems and you need both.

Level 1: is it a valid OpenAPI document? Structure, resolved references, correct types.

# swagger-cli (package @apidevtools/swagger-cli)
npx swagger-cli validate openapi.yaml
# → openapi.yaml is valid

# A more modern alternative, with better 3.1 support
npx @redocly/cli lint openapi.yaml

This catches a broken $ref, wrong indentation or a type: strng. Without this check, a one-letter mistake breaks the documentation and you do not find out until somebody opens /docs.

Level 2: does it follow the Aroma Store style guide? This is where Spectral comes in with .spectral.yaml, which we already wrote in 04-01.

npx spectral lint openapi.yaml --fail-severity=error

Now that the document is complete, we add three new rules that only make sense with components populated:

# .spectral.yaml — rules added in 05-02
rules:
  aroma-operation-has-operationid:
    description: Every operation declares an operationId; it is the generated method name.
    severity: error
    given: $.paths[*][get,post,put,patch,delete]
    then:
      field: operationId
      function: truthy

  aroma-operationid-in-camelcase:
    description: operationIds are camelCase and in English (getCoffees, createOrder).
    severity: error
    given: $.paths[*][*].operationId
    then:
      function: casing
      functionOptions: { type: camel }

  aroma-errors-use-the-common-schema:
    description: Every 4xx/5xx response references the catalogue's Error schema.
    severity: error
    given: $.paths[*][*].responses[?(@property.match(/^[45]/))].content['application/json'].schema
    then:
      function: schema
      functionOptions:
        schema:
          type: object
          properties:
            $ref: { const: '#/components/schemas/Error' }

  aroma-schemas-have-description:
    description: Every components schema has a description; it is what the consumer reads.
    severity: warn
    given: $.components.schemas[*]
    then:
      field: description
      function: truthy

  aroma-examples-on-200-responses:
    description: 200 and 201 responses include at least one example.
    severity: warn
    given: $.paths[*][*].responses[200,201].content['application/json']
    then:
      function: schema
      functionOptions:
        schema:
          type: object
          anyOf:
            - required: [example]
            - required: [examples]

Both levels become steps in the CI pipeline of 05-05:

{
  "scripts": {
    "contract:validate": "swagger-cli validate openapi.yaml",
    "contract:lint": "spectral lint openapi.yaml --fail-severity=error",
    "contract": "npm run contract:validate && npm run contract:lint"
  }
}

  1. Generating clients with OpenAPI Generator

With the contract complete, the next step is to stop hand-writing the code that calls the API.

# TypeScript client with fetch for the SPA
npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-fetch \
  -o ../aroma-spa/src/generated-api \
  --additional-properties=supportsES6=true,withInterfaces=true,typescriptThreePlus=true

# TypeScript client with axios for Aroma Mobile (React Native)
npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-axios \
  -o ../aroma-mobile/src/generated-api

The result in the SPA, with types derived from the contract:

// SPA code consuming the generated client
import { Configuration, CoffeesApi, type Coffee, RoastEnum } from './generated-api';

const configuration = new Configuration({
  basePath: import.meta.env.VITE_API_URL,          // https://api.aromastore.example/v1
  accessToken: () => session.getToken(),
});

const coffeesApi = new CoffeesApi(configuration);

// The method is named after the operationId; the parameters are typed
const collection = await coffeesApi.getCoffees({
  roast: RoastEnum.Light,       // enum generated from the schema: "purple" does not fit
  priceMax: 15,
  limit: 20,
  sort: '-priceEuros',
});

// collection.data is Coffee[]; collection.total is number
collection.data.forEach((coffee: Coffee) => {
  // coffee.priceEuros is number, coffee.roast is RoastEnum
  console.log(`${coffee.name}: €${coffee.priceEuros.toFixed(2)}`);
});

Generators available for our consumers:

Consumer Generator Output
SPA (React) typescript-fetch Classes and types using native fetch
Aroma Mobile typescript-axios or kotlin / swift5 A client per platform
CataBox (third party) Whichever they choose They only consume the published openapi.yaml
Internal tools python, go Operations scripts
Contract tests The schemas are used directly (05-04)

What you gain: types always aligned with the contract, zero boilerplate fetch code, operationId as the method name, enums that make invalid values impossible at compile time, and —the most valuable effect— a breaking change in the contract becomes a compilation error in the consumer, not a failure in production.

What needs care, and this is what the tutorials do not tell you:

  • Generated code is never edited. It is regenerated. Add the folder to .gitignore or, if you version it for traceability, mark the files as generated and forbid touching them in review. A manual fix disappears at the next generation.
  • It generates a lot of code. A generator can produce hundreds of files for a medium-sized API. Review what it produces before adopting it; some generators drag in heavy dependencies.
  • Quality depends on the generator. typescript-fetch and go are solid; others have quirks. Try it before committing to it.
  • Changing an operationId breaks consumers even though the API is identical. Treat it as part of the contract.
  • Wrap it. Do not expose the generated client to your whole application: put a thin layer on top that translates its errors into your domain's errors and centralises authentication and the retries with jitter from 04-04. That way, changing generator affects one file.

For lighter cases there are alternatives that generate types only: openapi-typescript produces a types file with no client, and openapi-fetch consumes them with a minimal wrapper. For a modern SPA it is often a better option than the official generator's classes.

  1. Keeping the contract in sync

Everything above collapses if openapi.yaml describes an API that no longer exists. The contract's full lifecycle:

graph LR
    A[Proposed change<br/>in openapi.yaml] --> B[Pull request:<br/>design review 04-01]
    B --> C[swagger-cli validate<br/>+ spectral lint]
    C --> D[oasdiff:<br/>is it breaking? 05-04]
    D --> E[Implementation<br/>module 3]
    E --> F[Contract tests:<br/>real responses vs schema]
    F --> G[Publication in CI:<br/>/docs and portal 05-06]
    G --> H[Clients regenerated<br/>SPA and Aroma Mobile]

The team rules that hold that cycle up:

  1. The contract change goes in the same pull request as the implementation. If they are separate, one of the two gets forgotten.
  2. openapi.yaml is the first file to be reviewed, before the code. The contract's diff is where you see whether the change is a good idea; the code only tells you whether it is well built.
  3. The pipeline fails if the contract does not validate or does not pass linting. No exceptions (04-01).
  4. The pipeline warns if the change is breaking, with oasdiff. It is the gate we will see in 05-04.
  5. Integration tests validate real responses against the schemas. It is the only thing that really detects drift, and it is the central topic of 05-04.
  6. Publication is automatic, not a manual step somebody remembers to do on Fridays (05-05).
  7. info.version goes up on every contract change, following SemVer.

Common Mistakes and Tips

  • Confusing openapi: 3.1.0 with info.version. The first is the specification's version; the second, your API's. Changing the first by mistake breaks tools; forgetting to bump the second makes the history useless.
  • Repeating /v1 in servers and in paths. Generated clients call /v1/v1/coffees. With path versioning, the prefix goes in servers and the paths keys start with /coffees.
  • additionalProperties: false on output schemas. It turns any new field into a breaking change for strict consumers, the exact opposite of the compatibility rule from 02-07. Close them on inputs only.
  • Documenting only the happy path. A specification with no 4xx forces every consumer to discover the errors by provoking them. Reusable responses cost one line per operation.
  • Inconsistent examples. cof_001 at €14.50, two units and a total of €99.99 in the order example. As well as looking bad, it feeds the mocks in 05-04 with false data and confuses whoever is integrating.
  • Forgetting operationId, or changing it carelessly. Without it, generators invent names like getCoffeesById_1. Changing it breaks consumers without touching the API.
  • Editing generated code. It disappears at the next regeneration. If you need to change it, wrap it.
  • Serving Swagger UI in production without thinking. Check whether your contract should be public, take helmet's CSP into account and decide whether "Try it out" should be enabled.
  • A complete specification that validates nothing. The prettiest document in the world lies if nobody checks that the real responses comply with it. That is the problem for 05-04.
  • Tip: write the descriptions and examples first, not the types. Types can be derived; the knowledge —that the price is not used in floating-point arithmetic, that the status changes through subresources— exists only in your head.
  • Tip: use a short summary and a long description. Swagger UI shows the summary in the collapsed list, and that is the only thing most people read.
  • Tip: if your team maintains Zod schemas, generate the components.schemas from them instead of writing them twice. Duplication is the mother of drift.

Exercises

Exercise 1: documenting GET /orders/{id} and POST /orders/{id}/payment

Write the paths fragment for these two operations, reusing everything that already exists in components. Requirements:

  • GET /orders/{id}: path parameter, expand=items.coffee as an optional query parameter, responses 200, 304, 401, 403 (another customer's order) and 404, with ETag on the response.
  • POST /orders/{id}/payment: requires Idempotency-Key, the orders.write OAuth scope, a body with the payment method, and responses 200, 402 (payment declined), 409 (order_already_paid) and 428.

Exercise 2: the missing Spectral rule

Write a Spectral rule that forces every operation modifying an existing resource (PUT, PATCH, DELETE) to declare the If-Match header parameter and to document the 412 response. Explain the given, the then and why you would introduce it as warn rather than error.

Exercise 3: choosing the approach for a new service

Aroma Store is going to launch an internal recommendations service (recommendations-api) that will only consume the Aroma Store API itself over gRPC and will also expose two REST endpoints for the internal panel. It will be built by a team of two people in three weeks.

Decide whether to apply "specification first" or "code first", justify it with at least four criteria from the table in section 15, and describe what you would do to avoid drift in the approach you choose.

Solutions

Solution 1

  /orders/{id}:
    get:
      operationId: getOrder
      summary: Gets an order by its identifier
      description: |
        A customer can only look at their own orders; the `employee` and
        `administrator` roles can look at any of them. Trying to read another
        customer's order returns `403`, not `404`: the order's existence is not a secret
        from an authenticated caller, and returning `404` would complicate debugging.
      tags: [Orders]
      security:
        - bearerJWT: []
        - oauth2: [orders.read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^ord_[A-Za-z0-9]+$' }
          example: ord_5001
        - name: expand
          in: query
          description: |
            Embeds related resources instead of returning only their links.
            The only accepted value is `items.coffee`.
          schema: { type: string, enum: [items.coffee] }
        - name: If-None-Match
          in: header
          description: ETag known to the client; if it matches, `304` is returned.
          schema: { type: string }
      responses:
        '200':
          description: The requested order.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Cache-Control:
              description: Private and short-lived; an order changes status.
              schema: { type: string }
              example: 'private, max-age=0, must-revalidate'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
              examples:
                paid:
                  summary: Order already paid, with links to the available actions
                  value:
                    id: ord_5001
                    customerId: cus_842
                    items:
                      - { coffeeId: cof_001, quantity: 2, unitPriceEuros: 14.50, subtotalEuros: 29.00 }
                    totalEuros: 29.00
                    status: paid
                    createdAt: '2026-03-02T10:15:00Z'
                    _links:
                      self: { href: /v1/orders/ord_5001 }
                      invoice: { href: /v1/orders/ord_5001/invoice }
                      return: { href: /v1/orders/ord_5001/return, method: POST }
        '304':
          description: Not modified.
        '401': { $ref: '#/components/responses/Error401' }
        '403':
          description: The order belongs to another customer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: insufficient_permissions
                  message: 'You do not have permission to view this order.'
                  details: []
        '404': { $ref: '#/components/responses/Error404' }

  /orders/{id}/payment:
    post:
      operationId: payOrder
      summary: Pays a pending order
      description: |
        Charges the order and moves it to `paid` status. It is a **state transition
        expressed as a subresource**, not a `PATCH` on `status`.

        It requires `Idempotency-Key`: a retry with the same key returns the original
        response without charging twice. It is the most important guarantee of this operation.
        On completion, the `order.paid` webhook is emitted towards SwiftShip.
      tags: [Orders]
      security:
        - bearerJWT: []
        - oauth2: [orders.write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^ord_[A-Za-z0-9]+$' }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [method]
              additionalProperties: false
              properties:
                method: { type: string, enum: [card, transfer, wallet] }
                cardToken:
                  type: string
                  writeOnly: true
                  description: |
                    Token from the payment gateway. **Never send the card PAN to this
                    API**: tokenise it in the client with the gateway's SDK.
      responses:
        '200':
          description: Payment accepted; the order moves to `paid`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '401': { $ref: '#/components/responses/Error401' }
        '402':
          description: The gateway declined the payment. The order stays `pending_payment`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: payment_declined
                  message: 'The issuing bank declined the payment.'
                  details: [{ field: method, problem: 'insufficient funds' }]
        '409':
          description: The order had already been paid.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: { code: order_already_paid, message: 'The order is already paid.', details: [] }
        '428':
          description: `Idempotency-Key` is missing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/Error429' }

Note: payment_declined was not in the 02-04 catalogue. Adding a code also requires updating the enum in the Error schema and the file src/errors/api-error.js. It is a good example of why a closed catalogue forces a conscious change instead of inventing a code on the fly.

Solution 2

  aroma-modifications-require-if-match:
    description: |
      Every operation that modifies an existing resource must declare the If-Match
      parameter and document the 412 response, so that the optimistic concurrency of
      04-06 is part of the contract and not an implementation detail.
    message: '{{path}} modifies a resource but does not declare If-Match or does not document the 412.'
    severity: warn
    given: $.paths[*][put,patch,delete]
    then:
      - field: parameters
        function: schema
        functionOptions:
          schema:
            type: array
            contains:
              type: object
              properties:
                name: { const: If-Match }
              required: [name]
      - field: responses.412
        function: truthy

Explanation of the given: $.paths[*][put,patch,delete] selects the operation object for those three methods on every path. It does not use the ~ suffix because here we care about the value (the operation object with its parameters and responses), not the key.

Explanation of the then: it is a list of two checks applied to the same node. The first uses the schema function with JSON Schema's contains to require that the parameters array include at least one element with name: If-Match. The second uses truthy on responses.412, which requires that field to exist and not be empty.

Why warn first: the current contract has DELETE operations that do not require If-Match —deleting a cart, for instance. If the rule goes straight in as error, the pipeline goes red and nobody can merge anything until everything is fixed, with the predictable consequence that somebody disables the rule. The correct procedure, the same as in 04-01: it goes in as warn, a task is opened to clean up the violations, and when linting comes out clean it is promoted to error in a one-line pull request. Besides, there are legitimate exceptions —idempotent DELETEs on resources with no concurrency— that are worth documenting before tightening, either with x-spectral-ignore or by rethinking the given.

Solution 3

Decision: code first for recommendations-api, with two caveats.

Justification using the criteria from the table:

Criterion Analysis of the case
Consumers Just one, and internal: the panel. There are no external teams waiting. The main value of "specification first" —enabling parallel work— does not apply.
Working in parallel The panel can wait; it is two endpoints. Setting up a mock or negotiating a contract up front is not worth it.
Up-front cost against the deadline Three weeks and two people. The up-front cost of hand-writing a complete contract eats a noticeable chunk of the budget.
Risk of semantic drift Low: the team writing the service is the same one consuming the endpoint from the panel. Drift hurts when the consumer is someone else.
Expected stability A recommendations service is experimental by nature: the endpoints will change several times in the first months. A contract agreed in advance would be redone constantly.
Surface area Two REST endpoints. The bulk of the service is gRPC, whose contract is the .proto files, which are specification-first by construction.

Caveat 1: gRPC is not negotiable. The .proto files are the contract and are written before the code, with review. The REST part being code first does not change that.

Caveat 2: the contract must exist even if it is generated. "Code first" does not mean "no contract". The service must publish its generated openapi.json at /docs/openapi.json, and that file must go through the same spectral lint as Aroma Store. If the team does not accept this, the correct decision flips to specification first.

Measures against drift in the chosen approach:

  1. Generate from the validation schemas, not from loose annotations. If the service validates with Zod, zod-to-json-schema produces the components.schemas, so the real validation and the documentation are literally the same object and cannot diverge.
  2. Dump the generated openapi.json into a versioned file on every CI build. That way the contract's diff shows up in the pull request and can be reviewed, even though nobody wrote it by hand. It is the trick that gives "code first" the reviewability of "specification first".
  3. Run spectral lint over the generated document, with the organisation's same rules. It forces operationId, descriptions and error responses to be there, which is exactly what the code-first approach tends to forget.
  4. Write the descriptions and examples by hand. The generator derives the types; it does not derive domain knowledge. An annotation with no description produces useless documentation.
  5. Revisit the decision when the context changes. The day a second consumer —Aroma Mobile, or a third party— depends on this service, the analysis changes and it is time to migrate to specification first. It is worth writing that down in an ADR (04-01) so that the decision and its expiry date are documented.

Conclusion

openapi.yaml has stopped being a fragment and become the complete Aroma Store contract. You can tell OpenAPI, the specification, from Swagger, the family of tools, and you know why 3.1 —aligned with JSON Schema 2020-12 and with top-level webhooks— is the right choice for an API that validates with AJV and notifies SwiftShip. You have walked the whole document: info with the front page where the money, date and opaque identifier conventions live; servers with the /v1 in the right place; tags per resource; paths with GET /coffees and POST /orders in full, including the 428 for a missing Idempotency-Key and the two different meanings of the same 409; components with schemas that separate Coffee from NewCoffee through readOnly, reusable parameters and error responses, and securitySchemes with the JWT from 03-06 and the OAuth flows and scopes from 04-03. And you know how to document what almost nobody documents: deprecation with deprecated: true alongside the Deprecation and Sunset headers from 02-07, the rate limits from 04-04 and the signed events sent to SwiftShip.

On top of that contract you have assembled the machinery that makes it useful. The API serves its own documentation at /docs with Swagger UI, outside /v1, with the CSP exception helmet demands, protected in production and with "Try it out" disabled there; the new files are src/config/openapi.js and src/routes/documentation.js, with swagger-ui-express and yaml as dependencies, and the corresponding registration in src/app.js. You know the rendering alternatives and the difference between the two validations you need —swagger-cli validate for structure and Spectral for the style guide, now with five more rules and the contract:validate and contract:lint scripts. And from that same file you have generated the TypeScript clients for the SPA and Aroma Mobile with OpenAPI Generator, knowing that generated code is not edited, that it gets wrapped, and that an operationId is part of the contract. You have also seen why the argument between writing the specification by hand and generating it from the code has no universal winner: it removes structural drift, never semantic drift.

And there lies the gap this lesson cannot fill. We have a beautiful contract, validated as a document, but nothing yet guarantees that the server complies with it: that GET /coffees returns exactly the CoffeeCollection schema, that no error escapes the catalogue, that a change in the YAML does not break the SPA without warning. In 05-04, Contracts, mocks and automated testing, we close that loop: we will bring up a mock with Prism straight from openapi.yaml so the SPA can move without waiting for the backend, we will use msw and nock as test doubles, we will validate real responses with AJV inside the Supertest tests from 03-08, we will detect breaking changes between two versions of the contract with oasdiff applying the rules from 02-07, we will see when consumer-driven contract testing with Pact pays off and when it is over-engineering, and we will organise the complete purchase journey as an end-to-end test. First, however, it is worth lifting our eyes from the project: in 05-03, Popular frameworks for RESTful APIs, we will look at what would have changed —and what would not— if in 03-01 we had chosen Fastify, NestJS, FastAPI, Spring Boot or ASP.NET Core instead of Express.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved