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
- OpenAPI and Swagger: two things people confuse
- Versions: 2.0, 3.0 and 3.1
- The anatomy of the document
openapi,infoandserverstags: the organisation the reader seespaths:GET /coffeesin fullpaths:POST /ordersin fullcomponents.schemas: the Aroma Store types- Reusable
components.parametersandcomponents.responses securitySchemesandsecurity: JWT and OAuth 2.0$refand the limits of reuseexampleversusexamplesoneOf,allOfanddiscriminator- Documenting deprecation and rate limits
- Two ways of working: by hand or from the code
- Generating the specification with
swagger-jsdoc - Serving Swagger UI at
/docs - Rendering alternatives: Redoc, Scalar, Stoplight Elements
- Validating the specification:
swagger-cliand Spectral - Generating clients with OpenAPI Generator
- Keeping the contract in sync
- 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.
- 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:
- 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. - Top-level
webhooks. Our architecture sendsorder.paidandorder.shippedto 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.
- 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 documentationOf 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.
openapi, info and servers
openapi, info and serversopenapi: 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.versionis your API's version, not OpenAPI's. They are different fields and people constantly confuse them. We use SemVer:1.7.0means there have been seven rounds of compatible additions since 1.0.0. A2.0.0would imply a breaking change and therefore a/v2in the path, as per 02-07.info.descriptionis 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'
urlincludes/v1. A direct consequence of our decision to version in the path: thepathskeys 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.
tags: the organisation the reader sees
tags: the organisation the reader seestags 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.
paths: GET /coffees in full
paths: GET /coffees in fullWe 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:
operationIdis mandatory in practice. It is the method name in generated clients:getCoffeesproducesapi.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
noResultsexample documents a design decision from 02-04 —a filter with no matches is200with an empty list, not404— 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,404is read as a number.securityat operation level overrides the global one. HerePOST /coffeesexplicitly requiresbearerJWTbecause it does not accept the third-party OAuth flow.
paths: POST /orders in full
paths: POST /orders in fullThe 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.
components.schemas: the Aroma Store types
components.schemas: the Aroma Store typesThe 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: truemarks the fields the server computes. Generators make use of it: the generatedCoffeetype includes them, but the creation body type omits them. That is whyNewCoffeeexists as a separate schema instead of reusingCoffee.additionalProperties: falseonly on inputs. In the bodies we receive, an unknown field is a client error and we return400(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
enumof 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 writescoffe_not_found. multipleOf: 0.01formally documents the two-decimal rule we have been carrying since 02-05.
- Reusable
components.parameters and components.responses
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.
securitySchemes and security: JWT and OAuth 2.0
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.
$ref and the limits of reuse
$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.yamlTwo warnings from experience:
- A remote
$refis 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
$refignores its siblings. Writing{ $ref: '#/...', description: 'something else' }silently discarded the description. In 3.1 this was fixed anddescriptionandsummaryare respected alongside$ref, but not every tool has caught up; if you need to vary something,allOfis still the safe route.
example versus examples
example versus examplesexample |
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.
oneOf, allOf and discriminator
oneOf, allOf and discriminatorThe 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. }
- 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.
- 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.
- Generating the specification with
swagger-jsdoc
swagger-jsdocEven 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.
- Serving Swagger UI at
/docs
/docsNow we serve the documentation from the project itself.
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:
helmetand Swagger UI clash. Helmet's defaultContent-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);/docsmust not count against the API's rate limit or pollute the metrics from 04-07. IfmetricsMiddlewarelabels 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.
- 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.
- Validating the specification:
swagger-cli and Spectral
swagger-cli and SpectralThere 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.yamlThis 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.
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"
}
}
- 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-apiThe 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
.gitignoreor, 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-fetchandgoare solid; others have quirks. Try it before committing to it. - Changing an
operationIdbreaks 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.
- 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:
- The contract change goes in the same pull request as the implementation. If they are separate, one of the two gets forgotten.
openapi.yamlis 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.- The pipeline fails if the contract does not validate or does not pass linting. No exceptions (04-01).
- The pipeline warns if the change is breaking, with
oasdiff. It is the gate we will see in 05-04. - 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.
- Publication is automatic, not a manual step somebody remembers to do on Fridays (05-05).
info.versiongoes up on every contract change, following SemVer.
Common Mistakes and Tips
- Confusing
openapi: 3.1.0withinfo.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
/v1inserversand inpaths. Generated clients call/v1/v1/coffees. With path versioning, the prefix goes inserversand thepathskeys start with/coffees. additionalProperties: falseon 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
4xxforces every consumer to discover the errors by provoking them. Reusable responses cost one line per operation. - Inconsistent examples.
cof_001at €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 likegetCoffeesById_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
summaryand a longdescription. Swagger UI shows thesummaryin the collapsed list, and that is the only thing most people read. - Tip: if your team maintains Zod schemas, generate the
components.schemasfrom 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.coffeeas an optional query parameter, responses200,304,401,403(another customer's order) and404, withETagon the response.POST /orders/{id}/payment: requiresIdempotency-Key, theorders.writeOAuth scope, a body with the payment method, and responses200,402(payment declined),409(order_already_paid) and428.
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: truthyExplanation 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:
- Generate from the validation schemas, not from loose annotations. If the service validates with Zod,
zod-to-json-schemaproduces thecomponents.schemas, so the real validation and the documentation are literally the same object and cannot diverge. - Dump the generated
openapi.jsoninto 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". - Run
spectral lintover the generated document, with the organisation's same rules. It forcesoperationId, descriptions and error responses to be there, which is exactly what the code-first approach tends to forget. - 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.
- 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
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
