A usable API is built around noun resources, uses HTTP methods with the meaning RFC 9110 gives them, answers with precise status codes and Problem Details errors (RFC 9457), pages through large lists with a cursor and evolves without breaking clients. Teams write these conventions into an OpenAPI 3.1 document before coding, check it with a linter and test the implementation against that contract.
After this you will be able to
- design resource URLs and pick the method and status code for each operation according to RFC 9110
- define a single
application/problem+jsonerror format (RFC 9457) with anerrorsextension member - implement cursor pagination, protect
POSTfrom duplicates withIdempotency-Keyand updates from being lost withETagandIf-Match - capture the contract in OpenAPI 3.1, check it with
redocly lintand with contract tests against a stub
Know beforehand
Verified with
OpenAPI 3.1.2Node.js 24 LTS (24.18)Express 5.3.0Ajv 8.20 (JSON Schema 2020-12)@redocly/cli 2.62.1supertest 7.3.1node:test
Checked against docs: October 11, 2026
Contents
- Resources and URLs: nouns, collections, nesting
- Methods, idempotency and status codes
- Error format: Problem Details (RFC 9457)
- Pagination, filtering and sorting
- Retrying POST without duplicates: the Idempotency-Key header
- Versioning and backward compatibility
- The contract in OpenAPI 3.1 and linting it
- A stub that follows the contract, tests and a review checklist
Resources and URLs: nouns, collections, nesting
The running example is a salon booking API: clients book appointments (bookings) with masters. We design it step by step, then capture the contract in OpenAPI, lint it and write a stub with tests. This guide is for developers who have already built a REST API (for instance, in the Express lesson) and now want one that is pleasant to use and can evolve without breakage.
A resource is a domain entity with its own address: a booking, a master, a service. A REST API (a style where data is exposed as addressable resources and actions are standard HTTP methods) is organised around resources, not actions. Microsoft's web API design guide puts it this way: base URIs on nouns, use plural nouns for collections, and let the HTTP method carry the verb.
POST /createBooking
- Prefer
POST /bookings- Why
- The method expresses the action; the path names only the resource
GET /getBooking?id=42
- Prefer
GET /bookings/{bookingId}- Why
- Every booking has its own stable address
POST /bookings/42/cancel
- Prefer
PATCH /bookings/42with{"status":"cancelled"}- Why
- Cancelling changes the resource state. An action path is justified only when the operation is not a field change
GET /salons/1/masters/7/bookings/42
- Prefer
GET /bookings/42,GET /bookings?master_id=7- Why
- Microsoft advises against URIs more complex than collection/item/collection
/Bookings, /booking_list
- Prefer
/bookings- Why
- One style: lower case, plural
- IDs in URLs are opaque: a UUID does not reveal how many records exist and cannot be walked like 1, 2, 3
- One field naming style across the API: ours is
snake_case(master_id,starts_at); never mix it withcamelCase - Dates are RFC 3339 strings with an offset (
2026-11-02T10:00:00+05:00); the server stores and returns UTC - The major version lives in the base URL (
/v1inservers), not in individual resources
Methods, idempotency and status codes
RFC 9110 gives methods two properties. A safe method is essentially read-only: the client does not request any state change on the server. An idempotent method is one where several identical requests have the same intended effect as a single one. Of the methods in RFC 9110, GET, HEAD, OPTIONS and TRACE are safe; those plus PUT and DELETE are idempotent. POST is neither, and RFC 5789 says PATCH is neither safe nor idempotent either.
GET
- Safe
- yes
- Idempotent
- yes
- In our API
- List bookings and read one
POST
- Safe
- no
- Idempotent
- no
- In our API
- Create a booking
PUT
- Safe
- no
- Idempotent
- yes
- In our API
- Replace the whole resource — not used here
PATCH
- Safe
- no
- Idempotent
- no
- In our API
- Partial change: reschedule, cancel, add a note
DELETE
- Safe
- no
- Idempotent
- yes
- In our API
- Delete — we cancel through
PATCHinstead to keep history
These properties matter for retries. RFC 9110 allows an idempotent request to be repeated automatically if the connection fails before the response arrives, and says a client SHOULD NOT automatically retry a non-idempotent request unless it has some means to know the retry is safe. For POST, that means is an idempotency key, covered below.
Pick status codes by their RFC 9110 meaning, not "everything is 200, the error is in the body". The table is our API's agreement; we will write the same agreement into OpenAPI.
200 OK
- When
- Reads and successful
PATCH - Response contains
- The resource and
ETag
201 Created
- When
- Booking created
- Response contains
- The resource,
Locationwith its URL,ETag
204 No Content
- When
- Success without a body, such as
DELETE - Response contains
- Nothing
400 Bad Request
- When
- Malformed JSON, invalid fields or parameters, missing
Idempotency-Key - Response contains
- Problem Details and
errors
401 Unauthorized
- When
- Missing or invalid token
- Response contains
- Problem Details and
WWW-Authenticate
403 Forbidden
- When
- The user is known but lacks permission
- Response contains
- Problem Details
404 Not Found
- When
- No such booking or route
- Response contains
- Problem Details
409 Conflict
- When
- Conflicts with current state: the slot is taken
- Response contains
- Problem Details with its own
type
412 Precondition Failed
- When
If-Matchdid not match: the booking changed since it was read- Response contains
- Problem Details
415 Unsupported Media Type
- When
PATCHnot sent asapplication/merge-patch+json- Response contains
- Problem Details
422 Unprocessable Content
- When
- Idempotency key already used with a different body
- Response contains
- Problem Details
428 Precondition Required
- When
PATCHwithoutIf-Match(RFC 6585)- Response contains
- Problem Details
429 Too Many Requests
- When
- Rate limit exceeded (RFC 6585)
- Response contains
- Problem Details and
Retry-After
The stub does not implement 403 and 429: permissions and rate limits have their own lessons. Some APIs report validation errors with 400, others with 422: RFC 9110 describes 422 as "the syntax is correct, but the instructions cannot be processed", and the RFC 9457 example uses it. We use 400, as in the Express lesson, and keep 422 for a reused idempotency key, as the IETF draft suggests. Both conventions exist; pick one and write it into the contract.
Error format: Problem Details (RFC 9457)
Clients need one predictable error format so the frontend and integrations can parse every error with one function. The standard is Problem Details for HTTP APIs, RFC 9457, which obsoletes RFC 7807. The response has the application/problem+json media type and these members:
type
- Meaning
- URI of the problem type — the primary identifier for programs
- RFC 9457 rule
- Defaults to
about:blankwhen absent; absolute URIs are recommended
title
- Meaning
- Short summary of the problem type
- RFC 9457 rule
- Does not change between occurrences; for
about:blankit matches the status phrase, such as "Not Found"
status
- Meaning
- The status code
- RFC 9457 rule
- Advisory only: the server MUST use the same code in the actual HTTP response
detail
- Meaning
- Explanation of this occurrence
- RFC 9457 rule
- Helps the client fix the request; consumers SHOULD NOT parse it
instance
- Meaning
- URI of this occurrence
- RFC 9457 rule
- For example, an event ID in the error log
Extensions
- Meaning
- Your own members, such as
errors - RFC 9457 rule
- Consumers MUST ignore extensions they do not recognise
Generic errors — missing booking, missing token — use type: about:blank, because the status code says it all. Domain errors get their own type: …/problems/slot-taken, …/problems/idempotency-key-reused. Clients branch on that URI, and developers find its documentation. Here is the stub's actual response to a request without service and with tomorrow as the date (formatted for reading):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Request validation failed",
"errors": [
{
"pointer": "#/service",
"detail": "must have required property 'service'"
},
{
"pointer": "#/starts_at",
"detail": "must match format \"date-time\""
}
]
}errors is an extension member, pointer is a JSON Pointer to the field, as in the RFC 9457 exampledetailnever contains stack traces, SQL or database messages: they help attackers. Details go to the log; the client gets a generic sentence- The code in
statusand in the status line is always the same: proxies, caches and HTTP libraries only look at the status line - The UI takes user-facing text from its own translations keyed by
typeand showsdetailin debug mode at most
Pagination, filtering and sorting
A list that returns 20 bookings today will return 20,000 in a year. Build pagination into the first version: adding it later is a breaking change, because clients expect the whole list.
Offset: `?limit=20&offset=40`
- Easy to implement:
LIMIT … OFFSET …in SQL - You can jump straight to page seven
- If rows are inserted or deleted between requests, items shift: duplicates and gaps appear
- A large
OFFSETmakes the database read and discard every preceding row
Cursor: `?limit=20&cursor=…`
- The cursor points at the last item of the page; the next page is everything after it
- Inserts and deletes cause no duplicates or gaps
- With an index on the sort key, speed does not depend on depth
- No jumping to an arbitrary page — forward only (backward if you return a second cursor)
Microsoft recommends meaningful defaults (its example is limit=25 and offset=0) and an upper bound on limit so one request cannot dump the whole database. Our default limit is 20, the maximum is 100, and anything larger gets a 400.
Sort with a unique tiebreaker
Bookings are ordered by
starts_at, then byidfor equal times. Without the second key, two bookings at the same time can swap places between requests and one of them gets lost.The cursor is opaque
The cursor encodes the sort order plus
starts_atandidof the last item in base64url. Clients do not parse it; they passnext_cursoralong, so the format can change without a new API version.The cursor is bound to the sort
A cursor from
sort=-starts_atis rejected together withsort=starts_at: the stub answers 400 and names thecursorparameter.The end is explicit
On the last page
next_cursorisnull. The field is required in the schema, so the client never has to guess whether the list is over.
Filters are plain query parameters on resource fields: ?status=confirmed&master_id=m-1. Sorting works well as a single signed parameter: sort=starts_at ascending, sort=-starts_at descending. List the allowed values in the contract with enum so clients cannot sort by an unindexed field. Microsoft describes partial responses (?fields=id,starts_at) as a way to reduce payload size; we left them out — for small resources they rarely pay off.
The next-page link can also be sent in a Link header with rel="next" per RFC 8288. A next_cursor field in the body is easier to read from JavaScript, the header suits generic clients; nothing stops you from sending both.
Retrying POST without duplicates: the Idempotency-Key header
A client sent POST /bookings and got no response: the network dropped, the mobile connection timed out. Was the booking created? Retrying is risky — a second booking may appear; not retrying is bad too — the client may end up with none. The fix is an idempotency key: the client generates a unique key per operation and sends it with every retry, and the server recognises the retry by the key and returns the stored result.
What the draft suggests and what the stub does:
Key format
- Draft -07
- Structured Field String in quotes; a UUID is recommended
- Stub
- Accepts only
"…"with 16–64 Latin letters, digits and dashes, otherwise 400
Key missing where required
- Draft -07
- 400 with a link to documentation
- Stub
- 400,
type…/idempotency-key-missing
Key reused with a different body
- Draft -07
- 422
- Stub
- 422,
type…/idempotency-key-reused
First request with this key still running
- Draft -07
- 409
- Stub
- Not implemented: the handler is synchronous, so there is no race
Retry after completion
- Draft -07
- Return the earlier result
- Stub
- The same
201,Location,ETagand body
Key retention
- Draft -07
- The service publishes its expiry policy
- Stub
- Keys live in memory until restart
The server decides whether the body is the same by a fingerprint — SHA-256 of the JSON body in the stub. Keys are stored together with the client account: the draft's security section warns that otherwise a guessed key would return someone else's result. This is how it looks from outside — a curl script against the running stub:
API=http://localhost:3917/v1
AUTH="Authorization: Bearer demo-token"
JSON="Content-Type: application/json"
KEY='Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"'
BODY='{"master_id":"m-1","service":"haircut","starts_at":"2026-11-02T10:00:00+05:00"}'
# 1. Create, then retry with the same key: the same booking comes back
curl -si -w '\n' -X POST $API/bookings -H "$AUTH" -H "$KEY" -H "$JSON" -d "$BODY"
curl -s -w '\n' -X POST $API/bookings -H "$AUTH" -H "$KEY" -H "$JSON" -d "$BODY"
# 2. Same key, different body: 422
curl -s -w '\n' -X POST $API/bookings -H "$AUTH" -H "$KEY" -H "$JSON" \
-d '{"master_id":"m-1","service":"coloring","starts_at":"2026-11-02T10:00:00+05:00"}'
# 3. Invalid body: 400 with a pointer per field
curl -s -w '\n' -X POST $API/bookings -H "$AUTH" -H "$JSON" \
-H 'Idempotency-Key: "0b5f1c2e-7d1a-4c11-9e55-3f0c2a9d7b10"' \
-d '{"master_id":"m-1","starts_at":"tomorrow"}'$ bash demo.sh
HTTP/1.1 201 Created
Location: /v1/bookings/9ff0c277-6654-4fd6-8282-75f1156b6564
ETag: "v1"
Content-Type: application/json; charset=utf-8
Content-Length: 183
{"id":"9ff0c277-6654-4fd6-8282-75f1156b6564","master_id":"m-1","service":"haircut","starts_at":"2026-11-02T05:00:00.000Z","status":"confirmed","created_at":"2026-10-12T02:25:52.357Z"}
{"id":"9ff0c277-6654-4fd6-8282-75f1156b6564","master_id":"m-1","service":"haircut","starts_at":"2026-11-02T05:00:00.000Z","status":"confirmed","created_at":"2026-10-12T02:25:52.357Z"}
{"type":"https://api.salon.test/problems/idempotency-key-reused","title":"Idempotency-Key is already used","status":422,"detail":"This key was already used with a different request body"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"pointer":"#/service","detail":"must have required property 'service'"},{"pointer":"#/starts_at","detail":"must match format \"date-time\""}]}Date, Connection and Keep-Alive headers removed. The retry returned the same booking, the key with another body got 422, the invalid body got 400 with pointers- In production, keys live in the database with a unique constraint on account plus key, and the result is written in the same transaction as the booking — otherwise a crash in between leaves the key and the data out of sync
- Mark the key as in progress before processing so a concurrent retry gets 409 instead of creating a duplicate
- Publish the key format and retention period in the API documentation
- Decide whether to store error responses. The stub stores only successful creation, so after a 409 "slot taken" a retry with the same key runs again
Versioning and backward compatibility
The best versioning strategy is to release new versions as rarely as possible. Agree up front which changes do not break clients, and write clients that ignore unknown fields — just as RFC 9457 requires consumers to ignore unknown Problem Details extensions.
Non-breaking
- A new optional response field
- A new resource or operation
- A new optional parameter whose default keeps the old behaviour
- A new error type using a status code clients already handle
Breaking
- Removing or renaming a field, changing its type or format
- A new required request field
- A new
enumvalue in a response if clients parse it with a strictswitch - Changing a status code, a URL or the meaning of a field — say, an amount in tenge instead of tiyn
In the path
- Example
/v1/bookings- Pros
- Visible, easy to route, cache-friendly
- Cons
- Many versions mean many routes; the resource URL changes with the version
Query parameter
- Example
/bookings?api-version=2- Pros
- The resource URL stays the same and caches well
- Cons
- Every handler must parse the parameter
Header
- Example
Api-Version: 2- Pros
- Clean URLs
- Cons
- Invisible in links; the server and caches need extra logic
Media type
- Example
Accept: application/vnd.salon.v2+json- Pros
- The version belongs to the representation, not the URL
- Cons
- Harder for clients and tooling
This summarises the trade-offs in Microsoft's guide: path and query versions are the easiest to cache, header-based versions need more server logic. In our experience, for APIs consumed by mobile apps and partner integrations, a major version in the path (/v1) with only backward-compatible changes inside it is the most convenient. The contract revision lives in the document's info.version, not in the URL.
An old version is not switched off by surprise. RFC 9745 (Standards Track, March 2025) defines the Deprecation header with the date the resource was or will be deprecated, and a rel="deprecation" link to an explanation. The shutdown date comes from the Sunset header of RFC 8594. Headers from the RFC 9745 examples:
Deprecation: @1688169599
Link: <https://developer.example.com/deprecation>;
rel="deprecation"; type="text/html"
Sunset: Sun, 30 Jun 2024 23:59:59 UTCDeprecation value is a Structured Fields date in Unix seconds: @1688169599 is 30 June 2023, 23:59:59 UTCThe contract in OpenAPI 3.1 and linting it
OpenAPI is a machine-readable description of an HTTP API in YAML or JSON: resources, parameters, request bodies, status codes, data schemas and authentication. Contract-first means the document is written and reviewed before the code: frontend, mobile and integration teams see the API early, and documentation, clients and checks are produced from the file.
We use OpenAPI 3.1.2, the latest patch of the 3.1 line. The specification says patch versions fix the text, not the feature set, and tooling should not distinguish between 3.1.0 and 3.1.1. The 3.2 line is out (the latest on spec.openapis.org is 3.2.1), but check that your generators and validators support it before switching. The key change from 3.0 to 3.1: the Schema Object is a superset of JSON Schema 2020-12. So instead of nullable: true you write type: [string, 'null'], and a regular JSON Schema validator can check the schemas — which the stub relies on.
The document, the stub and the tests live in one folder. Dependencies:
{
"name": "bookings-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "node --test",
"lint:api": "redocly lint openapi.yaml"
},
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"express": "^5.3.0",
"yaml": "^2.9.1"
},
"devDependencies": {
"@redocly/cli": "^2.62.1",
"supertest": "^7.3.1"
}
}The document itself. Schemas, error responses and the ETag header are defined once in components and referenced with $ref. Booking does not forbid extra properties, so new fields can be added to responses; the incoming BookingCreate and BookingPatch are closed with additionalProperties: false, so a typo in a field name does not pass silently.
openapi: 3.1.2
info:
title: Bookings API
version: 1.0.0
summary: Client bookings for a salon.
description: |
Errors use Problem Details (RFC 9457, application/problem+json).
Clients must ignore unknown response fields: new optional fields
are added without a new major version.
license:
name: MIT
identifier: MIT
servers:
- url: https://api.salon.test/v1
security:
- bearerAuth: []
tags:
- name: bookings
description: Client bookings
paths:
/bookings:
get:
tags: [bookings]
operationId: listBookings
summary: List bookings (cursor pagination)
parameters:
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
- name: cursor
in: query
description: Opaque value of next_cursor from the previous page.
schema: { type: string }
- name: status
in: query
schema: { $ref: '#/components/schemas/BookingStatus' }
- name: master_id
in: query
schema: { type: string }
- name: sort
in: query
description: Sort field; a leading minus means descending.
schema: { type: string, enum: [starts_at, -starts_at], default: starts_at }
responses:
'200':
description: One page of bookings.
content:
application/json:
schema: { $ref: '#/components/schemas/BookingPage' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
tags: [bookings]
operationId: createBooking
summary: Create a booking
parameters:
- name: Idempotency-Key
in: header
required: true
description: |
Quoted unique string, e.g. "8e03978e-40d5-43e8-bc93-6894a57f9324".
A retry with the same key and body returns the stored result.
schema: { type: string, pattern: '^"[A-Za-z0-9-]{16,64}"$' }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/BookingCreate' }
responses:
'201':
description: Booking created.
headers:
Location:
description: URL of the new booking.
schema: { type: string, format: uri-reference }
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/Booking' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { $ref: '#/components/responses/Conflict' }
'422': { $ref: '#/components/responses/UnprocessableContent' }
/bookings/{bookingId}:
parameters:
- name: bookingId
in: path
required: true
schema: { type: string, format: uuid }
get:
tags: [bookings]
operationId: getBooking
summary: Get a booking
responses:
'200':
description: The booking.
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/Booking' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
tags: [bookings]
operationId: updateBooking
summary: Reschedule, cancel or annotate a booking (JSON Merge Patch)
parameters:
- name: If-Match
in: header
required: true
description: ETag from the last GET; protects against lost updates.
schema: { type: string }
requestBody:
required: true
content:
application/merge-patch+json:
schema: { $ref: '#/components/schemas/BookingPatch' }
responses:
'200':
description: Updated booking.
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/Booking' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'409': { $ref: '#/components/responses/Conflict' }
'412': { $ref: '#/components/responses/PreconditionFailed' }
'415': { $ref: '#/components/responses/UnsupportedMediaType' }
'428': { $ref: '#/components/responses/PreconditionRequired' }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer }
headers:
ETag:
description: Version of the booking; send it back in If-Match.
schema: { type: string }
schemas:
BookingStatus:
type: string
enum: [confirmed, cancelled]
Booking:
type: object
required: [id, master_id, service, starts_at, status, created_at]
properties:
id: { type: string, format: uuid }
master_id: { type: string }
service: { type: string }
starts_at: { type: string, format: date-time }
status: { $ref: '#/components/schemas/BookingStatus' }
note: { type: string, maxLength: 500 }
created_at: { type: string, format: date-time }
BookingCreate:
type: object
additionalProperties: false
required: [master_id, service, starts_at]
properties:
master_id: { type: string, minLength: 1 }
service: { type: string, minLength: 1 }
starts_at: { type: string, format: date-time }
note: { type: string, maxLength: 500 }
BookingPatch:
type: object
additionalProperties: false
minProperties: 1
properties:
starts_at: { type: string, format: date-time }
status: { const: cancelled }
note:
type: [string, 'null']
maxLength: 500
description: null removes the note (RFC 7396).
BookingPage:
type: object
required: [data, next_cursor]
properties:
data:
type: array
items: { $ref: '#/components/schemas/Booking' }
next_cursor:
type: [string, 'null']
description: Pass as ?cursor= to get the next page; null on the last page.
Problem:
type: object
required: [type, title, status]
properties:
type: { type: string, format: uri-reference }
title: { type: string }
status: { type: integer, minimum: 400, maximum: 599 }
detail: { type: string }
instance: { type: string, format: uri-reference }
errors:
type: array
description: Extension member — one item per invalid field or parameter.
items:
type: object
required: [detail]
properties:
detail: { type: string }
pointer: { type: string, description: JSON Pointer into the request body. }
parameter: { type: string, description: Name of the invalid query parameter. }
responses:
BadRequest:
description: Malformed request or failed validation.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
Unauthorized:
description: Missing or invalid bearer token.
headers:
WWW-Authenticate:
description: Authentication scheme the client must use.
schema: { type: string }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
NotFound:
description: No booking with this id.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
Conflict:
description: The slot is already taken.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
PreconditionFailed:
description: If-Match does not match the current ETag; re-read the booking.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
PreconditionRequired:
description: The If-Match header is required.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
UnprocessableContent:
description: Idempotency-Key was already used with a different body.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
UnsupportedMediaType:
description: PATCH body must be application/merge-patch+json.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }Idempotency-Key on POST, If-Match on PATCH, every error as application/problem+jsonLinting is done with Redocly CLI. Without a config file, redocly lint applies the built-in recommended ruleset: structural validity, unresolved $refs, unused components, 4xx responses on operations and more. recommended-strict is the stricter set, and custom rules go into redocly.yaml.
$ npm run lint:api
> bookings-api@1.0.0 lint:api
> redocly lint openapi.yaml
No configurations were provided -- using built in recommended configuration by default.
validating openapi.yaml...
openapi.yaml: validated in 23ms
Woohoo! Your API description is valid. 🎉To make sure the linter catches mistakes, we introduced a typo in one reference: BookingsPage instead of BookingPage. The linter reported both the unresolved reference and the now unused schema, and the command exited with a non-zero code — so it can run in CI and keep a broken contract out of the main branch:
$ npx redocly lint openapi.yaml --format=stylish
No configurations were provided -- using built in recommended configuration by default.
validating openapi.yaml...
openapi.yaml:
49:23 error no-unresolved-refs Can't resolve $ref
176:5 warning no-unused-components Component: "BookingPage" is never used.
openapi.yaml: validated in 24ms
❌ Validation failed with 1 error and 1 warning.
run `redocly lint --generate-ignore-file` to add all problems to the ignore file.
$ echo $?
1A stub that follows the contract, tests and a review checklist
A stub is a minimal implementation that behaves according to the contract but keeps data in memory. It lets the frontend start before the backend is ready and proves the contract can be implemented at all. The key move: schemas from openapi.yaml are loaded into the Ajv validator in JSON Schema 2020-12 mode, so the same contract checks incoming requests on the server and responses in the tests.
import { readFileSync } from "node:fs";
import { Ajv2020 } from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { parse } from "yaml";
const doc = parse(readFileSync(new URL("../openapi.yaml", import.meta.url), "utf8"));
// OpenAPI 3.1 schemas are JSON Schema 2020-12, so one validator serves
// both the server (requests) and the tests (responses).
const ajv = new Ajv2020({ allErrors: true, strict: false });
addFormats(ajv);
ajv.addSchema({ $id: "openapi", components: doc.components });
export function schemaErrors(name, data) {
const validate = ajv.getSchema(`openapi#/components/schemas/${name}`);
if (validate(data)) return [];
return validate.errors.map((e) => {
const missing = e.params.missingProperty ? `/${e.params.missingProperty}` : "";
const extra = e.params.additionalProperty ? `/${e.params.additionalProperty}` : "";
return { pointer: `#${e.instancePath}${missing}${extra}`, detail: e.message };
});
}The server runs on Express 5. Errors are a Problem class that the central handler turns into application/problem+json. Authentication is a stub: any bearer token counts as an account; real verification is covered in the authentication and authorization lessons.
import express from "express";
import { createHash, randomUUID } from "node:crypto";
import { STATUS_CODES } from "node:http";
import { schemaErrors } from "./contract.js";
const TYPES = "https://api.salon.test/problems/";
class Problem extends Error {
constructor(status, detail, { type, title, headers, ...extra } = {}) {
super(detail);
this.status = status;
this.headers = headers ?? {};
this.body = {
type: type ? TYPES + type : "about:blank",
title: title ?? STATUS_CODES[status],
status,
detail,
...extra,
};
}
}
const etag = (b) => `"v${b.version}"`;
const view = ({ version, ...booking }) => booking;
const encodeCursor = (b, sort) =>
Buffer.from(JSON.stringify([sort, b.starts_at, b.id])).toString("base64url");
function decodeCursor(cursor, sort) {
try {
const [s, startsAt, id] = JSON.parse(Buffer.from(cursor, "base64url").toString());
if (s === sort && typeof startsAt === "string" && typeof id === "string") return { startsAt, id };
} catch {}
throw new Problem(400, "Cursor is invalid or belongs to another sort order", {
errors: [{ parameter: "cursor", detail: "use next_cursor from the previous page" }],
});
}
export function createApp() {
const app = express();
app.disable("x-powered-by");
app.set("etag", false); // ETag is the booking version, set explicitly below
const bookings = new Map();
const idempotency = new Map();
const slotTaken = (masterId, startsAt, exceptId) =>
[...bookings.values()].some(
(b) => b.id !== exceptId && b.status === "confirmed" &&
b.master_id === masterId && b.starts_at === startsAt,
);
app.use(express.json({ type: ["application/json", "application/merge-patch+json"] }));
// Stub authentication: a real API verifies the token (see the auth lessons).
app.use((req, res, next) => {
const token = /^Bearer (\S+)$/.exec(req.get("authorization") ?? "")?.[1];
if (!token) {
throw new Problem(401, "Send a bearer token", { headers: { "WWW-Authenticate": "Bearer" } });
}
req.account = token;
next();
});
const v1 = express.Router();
v1.get("/bookings", (req, res) => {
const { limit = "20", cursor, status, master_id, sort = "starts_at" } = req.query;
const size = Number(limit);
const errors = [];
if (!Number.isInteger(size) || size < 1 || size > 100) {
errors.push({ parameter: "limit", detail: "must be an integer from 1 to 100" });
}
if (!["starts_at", "-starts_at"].includes(sort)) {
errors.push({ parameter: "sort", detail: "must be starts_at or -starts_at" });
}
if (status !== undefined && !["confirmed", "cancelled"].includes(status)) {
errors.push({ parameter: "status", detail: "must be confirmed or cancelled" });
}
if (errors.length) throw new Problem(400, "Query validation failed", { errors });
const dir = sort === "-starts_at" ? -1 : 1;
const cmp = (a, b) => dir * (a.starts_at.localeCompare(b.starts_at) || a.id.localeCompare(b.id));
let rows = [...bookings.values()]
.filter((b) => (!status || b.status === status) && (!master_id || b.master_id === master_id))
.sort(cmp);
if (cursor) {
const after = decodeCursor(cursor, sort);
rows = rows.filter((b) => cmp(b, { starts_at: after.startsAt, id: after.id }) > 0);
}
const page = rows.slice(0, size);
const hasMore = rows.length > size;
res.json({
data: page.map(view),
next_cursor: hasMore ? encodeCursor(page.at(-1), sort) : null,
});
});
v1.post("/bookings", (req, res) => {
const key = req.get("idempotency-key");
if (!key) {
throw new Problem(400, "This operation requires an Idempotency-Key header", {
type: "idempotency-key-missing", title: "Idempotency-Key is missing",
});
}
if (!/^"[A-Za-z0-9-]{16,64}"$/.test(key)) {
throw new Problem(400, "Idempotency-Key must be a quoted string of 16-64 letters, digits or dashes", {
type: "idempotency-key-invalid", title: "Idempotency-Key is invalid",
});
}
const errors = schemaErrors("BookingCreate", req.body);
if (errors.length) throw new Problem(400, "Request validation failed", { errors });
// The key is scoped to the account: one client cannot replay another's result.
const scope = `${req.account}:${key}`;
const fingerprint = createHash("sha256").update(JSON.stringify(req.body)).digest("hex");
const saved = idempotency.get(scope);
if (saved) {
if (saved.fingerprint !== fingerprint) {
throw new Problem(422, "This key was already used with a different request body", {
type: "idempotency-key-reused", title: "Idempotency-Key is already used",
});
}
return res.status(201).set(saved.headers).json(saved.body);
}
const startsAt = new Date(req.body.starts_at).toISOString();
if (slotTaken(req.body.master_id, startsAt)) {
throw new Problem(409, "The master already has a booking at this time", {
type: "slot-taken", title: "Slot is already taken",
});
}
const booking = {
id: randomUUID(),
master_id: req.body.master_id,
service: req.body.service,
starts_at: startsAt,
status: "confirmed",
...(req.body.note !== undefined && { note: req.body.note }),
created_at: new Date().toISOString(),
version: 1,
};
bookings.set(booking.id, booking);
const headers = { Location: `/v1/bookings/${booking.id}`, ETag: etag(booking) };
idempotency.set(scope, { fingerprint, headers, body: view(booking) });
res.status(201).set(headers).json(view(booking));
});
const find = (id) => {
const booking = bookings.get(id);
if (!booking) throw new Problem(404, `Booking ${id} not found`);
return booking;
};
v1.get("/bookings/:id", (req, res) => {
const booking = find(req.params.id);
res.set("ETag", etag(booking)).json(view(booking));
});
v1.patch("/bookings/:id", (req, res) => {
if (!req.is("application/merge-patch+json")) {
throw new Problem(415, "Use Content-Type: application/merge-patch+json");
}
const booking = find(req.params.id);
const ifMatch = req.get("if-match");
if (!ifMatch) throw new Problem(428, "Send If-Match with the ETag from your last GET");
if (ifMatch !== etag(booking)) {
throw new Problem(412, "The booking has changed since you read it; GET it again");
}
const errors = schemaErrors("BookingPatch", req.body);
if (errors.length) throw new Problem(400, "Request validation failed", { errors });
const next = { ...booking };
if ("starts_at" in req.body) next.starts_at = new Date(req.body.starts_at).toISOString();
if ("status" in req.body) next.status = req.body.status;
if (req.body.note === null) delete next.note; // RFC 7396: null removes the member
else if ("note" in req.body) next.note = req.body.note;
if (next.status === "confirmed" && slotTaken(next.master_id, next.starts_at, next.id)) {
throw new Problem(409, "The master already has a booking at this time", {
type: "slot-taken", title: "Slot is already taken",
});
}
next.version += 1;
bookings.set(next.id, next);
res.set("ETag", etag(next)).json(view(next));
});
app.use("/v1", v1);
app.use((req, res, next) => {
next(new Problem(404, `Route ${req.method} ${req.path} not found`));
});
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
const problem =
err instanceof Problem ? err
: err.type === "entity.parse.failed" ? new Problem(400, "Request body is not valid JSON")
: null;
if (!problem) {
console.error(err);
return res.status(500).type("application/problem+json")
.json(new Problem(500, "Unexpected server error").body);
}
res.status(problem.status).set(problem.headers)
.type("application/problem+json").json(problem.body);
});
return app;
}import { createApp } from "./app.js";
const port = Number(process.env.PORT ?? 3000);
createApp().listen(port, () => console.log(`Bookings API stub on http://localhost:${port}/v1`));The tests on node:test and supertest check not only status codes but also that bodies match the Booking, BookingPage and Problem schemas — these are contract tests.
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import request from "supertest";
import { createApp } from "../src/app.js";
import { schemaErrors } from "../src/contract.js";
const AUTH = { Authorization: "Bearer test-account" };
const key = () => `"${randomUUID()}"`;
const slot = (h) => `2026-11-02T${String(h).padStart(2, "0")}:00:00.000Z`;
const draft = (h = 10) => ({ master_id: "m-1", service: "haircut", starts_at: slot(h) });
// Contract check: the body matches the schema from openapi.yaml.
function matches(name, body) {
assert.deepEqual(schemaErrors(name, body), [], `${name}: ${JSON.stringify(body)}`);
}
function isProblem(res, status) {
assert.equal(res.status, status);
assert.match(res.headers["content-type"], /^application\/problem\+json/);
assert.equal(res.body.status, status);
matches("Problem", res.body);
}
test("POST creates a booking: 201, Location, ETag, body matches the contract", async () => {
const res = await request(createApp()).post("/v1/bookings")
.set(AUTH).set("Idempotency-Key", key()).send(draft());
assert.equal(res.status, 201);
assert.equal(res.headers.location, `/v1/bookings/${res.body.id}`);
assert.equal(res.headers.etag, '"v1"');
matches("Booking", res.body);
});
test("retry with the same key and body returns the same booking, no duplicate", async () => {
const app = createApp();
const k = key();
const first = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft());
const retry = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft());
assert.equal(retry.status, 201);
assert.equal(retry.body.id, first.body.id);
const list = await request(app).get("/v1/bookings").set(AUTH);
assert.equal(list.body.data.length, 1);
});
test("same key with another body is 422, missing key is 400", async () => {
const app = createApp();
const k = key();
await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft(10));
isProblem(await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft(11)), 422);
isProblem(await request(app).post("/v1/bookings").set(AUTH).send(draft(12)), 400);
});
test("invalid body is 400 with pointers; taken slot is 409", async () => {
const app = createApp();
const bad = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key())
.send({ master_id: "m-1", starts_at: "tomorrow", price: 0 });
isProblem(bad, 400);
const pointers = bad.body.errors.map((e) => e.pointer).sort();
assert.deepEqual(pointers, ["#/price", "#/service", "#/starts_at"]);
await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft(10));
isProblem(await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft(10)), 409);
});
test("cursor pagination walks every booking exactly once", async () => {
const app = createApp();
for (const h of [13, 9, 11, 10, 12]) {
await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft(h));
}
const seen = [];
let cursor = null;
do {
const res = await request(app).get("/v1/bookings")
.query({ limit: 2, sort: "-starts_at", ...(cursor && { cursor }) }).set(AUTH);
assert.equal(res.status, 200);
matches("BookingPage", res.body);
seen.push(...res.body.data.map((b) => b.starts_at));
cursor = res.body.next_cursor;
} while (cursor);
assert.deepEqual(seen, [13, 12, 11, 10, 9].map(slot));
isProblem(await request(app).get("/v1/bookings").query({ limit: 500 }).set(AUTH), 400);
isProblem(await request(app).get("/v1/bookings").query({ cursor: "garbage" }).set(AUTH), 400);
});
test("PATCH needs If-Match: 428 without it, 412 when stale, 200 when current", async () => {
const app = createApp();
const created = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft());
const url = created.headers.location;
const patch = (body, ifMatch) => {
const req = request(app).patch(url).set(AUTH).set("Content-Type", "application/merge-patch+json");
return (ifMatch ? req.set("If-Match", ifMatch) : req).send(JSON.stringify(body));
};
isProblem(await patch({ note: "window seat" }), 428);
const ok = await patch({ note: "window seat" }, created.headers.etag);
assert.equal(ok.status, 200);
assert.equal(ok.body.note, "window seat");
assert.equal(ok.headers.etag, '"v2"');
isProblem(await patch({ status: "cancelled" }, created.headers.etag), 412);
const cleared = await patch({ note: null }, ok.headers.etag);
assert.equal("note" in cleared.body, false);
matches("Booking", cleared.body);
});
test("unknown booking is 404, request without a token is 401", async () => {
const app = createApp();
isProblem(await request(app).get(`/v1/bookings/${randomUUID()}`).set(AUTH), 404);
const anon = await request(app).get("/v1/bookings");
isProblem(anon, 401);
assert.equal(anon.headers["www-authenticate"], "Bearer");
});$ npm test
> bookings-api@1.0.0 test
> node --test
✔ POST creates a booking: 201, Location, ETag, body matches the contract (31.7978ms)
✔ retry with the same key and body returns the same booking, no duplicate (7.2981ms)
✔ same key with another body is 422, missing key is 400 (9.4061ms)
✔ invalid body is 400 with pointers; taken slot is 409 (5.7477ms)
✔ cursor pagination walks every booking exactly once (21.0143ms)
✔ PATCH needs If-Match: 428 without it, 412 when stale, 200 when current (7.9507ms)
✔ unknown booking is 404, request without a token is 401 (2.9521ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 374.3534Conditional requests from outside: without If-Match — 428, with the current ETag — 200 and the new version "v2", the stale ETag again — 412. The note was appended at the end of the object; clients must not depend on JSON field order.
API=http://localhost:3917/v1
AUTH="Authorization: Bearer demo-token"
PATCH="Content-Type: application/merge-patch+json"
URL=$API/bookings/$1
# Without If-Match: 428
curl -s -w '\n' -X PATCH $URL -H "$AUTH" -H "$PATCH" -d '{"note":"first visit"}'
# With the current ETag: 200 and a new ETag
curl -si -w '\n' -X PATCH $URL -H "$AUTH" -H "$PATCH" -H 'If-Match: "v1"' -d '{"note":"first visit"}'
# The same stale ETag again: 412
curl -s -w '\n' -X PATCH $URL -H "$AUTH" -H "$PATCH" -H 'If-Match: "v1"' -d '{"status":"cancelled"}'$ bash demo-patch.sh 9ff0c277-6654-4fd6-8282-75f1156b6564
{"type":"about:blank","title":"Precondition Required","status":428,"detail":"Send If-Match with the ETag from your last GET"}
HTTP/1.1 200 OK
ETag: "v2"
Content-Type: application/json; charset=utf-8
Content-Length: 204
{"id":"9ff0c277-6654-4fd6-8282-75f1156b6564","master_id":"m-1","service":"haircut","starts_at":"2026-11-02T05:00:00.000Z","status":"confirmed","created_at":"2026-10-12T02:25:52.357Z","note":"first visit"}
{"type":"about:blank","title":"Precondition Failed","status":412,"detail":"The booking has changed since you read it; GET it again"}We checked the tests with mutations: disabling the fingerprint comparison, the If-Match check or key storage, or making the cursor comparison non-strict, makes the matching test fail. One mutation survived: removing id from the sort keeps the tests green, because none of them has two bookings at the same time. Closing that gap is the first exercise.
- URLs are plural nouns, nested no deeper than collection/item/collection
- Methods match their meaning:
GETchanges nothing, repeatingPUTandDELETEis safe - Every operation documents its success response and 4xx errors;
201returnsLocation - Every error is
application/problem+json; domain errors have their owntype - Lists are paginated from the first version,
limithas an upper bound, ordering is deterministic - A
POSTthat must not be duplicated (a payment, a booking) acceptsIdempotency-Key - Updates are protected from lost updates with
ETagandIf-Match - A change either does not break clients or ships as a new major version, with
DeprecationandSunseton the old one redocly lintpasses in CI, and tests check responses against the contract schemas
Next: the same conventions in Spring Boot — in the Spring Boot REST API lesson — and the REST versus GraphQL choice in a separate comparison.
How this was verified5 · passed2 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.