REST API Design and OpenAPI Documentation21 min read
Module P-10·21 min read
REST constraints and resource modeling, versioning strategies, cursor vs offset pagination, OpenAPI 3.0 spec, Swagger UI — designing APIs that don't need a changelog.
What this module covers: A well-designed API is a product. Developers who consume it should be able to predict its behaviour, discover its capabilities, and upgrade to new versions without their code breaking. This module covers the REST constraints that produce predictable APIs (including HATEOAS, the constraint most APIs skip), resource modelling, versioning strategies and their trade-offs, offset vs compound cursor pagination, the HTTP status codes that actually matter, RFC 7807 Problem Details and rate-limit response headers, idempotency keys for safely retrying mutations, and generating interactive OpenAPI 3.0 documentation from your Express routes with Swagger UI.
REST (Representational State Transfer) is not a standard — it is a set of architectural constraints defined by Roy Fielding. An API that follows them is predictable and easier to consume.
The six constraints, and what they mean in practice:
1. Uniform interface — a consistent way to interact with all resources. In practice: use HTTP methods semantically, use nouns not verbs in URLs, use standard status codes.
2. Stateless — each request contains all information needed to process it. No session state stored on the server between requests. Authentication via token (not session cookie) follows this constraint.
3. Client-server separation — the client and server evolve independently. Your mobile app and your API can be deployed separately.
4. Cacheable — responses must declare whether they can be cached. Use Cache-Control, ETag, and Last-Modified headers.
5. Layered system — the client doesn't know if it's talking to the actual server or a proxy/load balancer.
6. Code on demand (optional) — servers can send executable code to clients (e.g., JavaScript). Rarely used in APIs.
Most "REST APIs" only follow constraints 1–3. That's fine. What matters practically is the uniform interface.
Constraint 1 actually has several sub-constraints in Fielding's original definition, and the one almost every "REST API" skips is HATEOAS — Hypermedia As The Engine Of Application State. The idea: a response includes links describing what the client can legally do next, so the client discovers valid transitions from the response itself instead of relying on out-of-band knowledge (documentation memorized in advance) of the API's URL structure.
A client that understands _links doesn't hardcode "orders can be cancelled by POSTing to /orders/{id}/cancel" — it checks whether a cancel link is present on this order (an already-shipped order's response simply wouldn't include one) and follows it if present. That's what turns an API into a hypermedia-driven state machine instead of a fixed contract the client has memorized in advance.
In practice, almost no public REST API implements this fully — Stripe, GitHub, and most others document fixed URL patterns instead, because generic HATEOAS clients are rare and the tooling ecosystem (OpenAPI included) is built around fixed, documented paths rather than discovered ones. It's still worth naming explicitly: when people say an API "isn't really RESTful," HATEOAS is almost always the specific constraint they mean, even when they don't use the term.
URLs identify resources. HTTP methods express what to do with them.
# Wrong — verbs in URLs GET /getUsers POST /createUser POST /deleteUser?id=42 POST /updateUserStatus
# Right — nouns + HTTP methods GET /users → list users POST /users → create a user GET /users/42 → get user 42 PATCH /users/42 → update user 42 (partial update) PUT /users/42 → replace user 42 (full update) DELETE /users/42 → delete user 42
Nested resources — when a resource only makes sense in the context of another:
GET /users/42/posts → posts by user 42 POST /users/42/posts → create a post for user 42 GET /users/42/posts/7 → post 7 by user 42 DELETE /users/42/posts/7 → delete post 7 by user 42
Limit nesting depth — more than two levels becomes unwieldy. /users/42/posts/7/comments/3/likes is hard to read and hard to maintain. At that depth, consider a top-level resource: GET /comments/3/likes.
Actions that don't fit CRUD — some operations are not naturally resource-oriented. Use sub-resources with a verb:
POST /orders/42/cancel → cancel order 42 POST /users/42/verify-email → verify user's email POST /posts/7/publish → publish a draft post POST /accounts/merge → merge two accounts
These are acceptable when the alternative (overloading PATCH) would be unclear.
Full replacement — same result if called multiple times
PATCH
✗
✗
Partial update
DELETE
✓
✗
Delete — second call returns 404, not an error
Idempotent: calling the operation N times produces the same result as calling it once. Idempotency enables safe retries — crucial for unreliable networks. Design your API to support this: DELETE returning 404 on a second call is correct, not an error.
PATCH vs PUT:
// PATCH — update only the provided fields PATCH /users/42 { "name": "New Name" } // → only name changes, email/role unchanged
// PUT — full replacement (omitted fields may be reset to defaults) PUT /users/42 { "name": "New Name", "email": "existing@email.com", "role": "user" } // → entire resource replaced
For most APIs, PATCH is the right choice for updates — PUT requires the client to know the full current state.
The table above marks POST as neither idempotent nor safe — correctly, since POST usually creates a new resource, and calling it twice normally means two resources. That's a real problem the moment a client needs to retry a POST after a dropped connection: it has no way to tell whether the original request succeeded and only the response was lost in transit, or whether nothing happened at all.
The fix is a client-supplied Idempotency-Key header — a UUID the client generates once per logical operation and resends unchanged on every retry of that same operation:
POST /payments Idempotency-Key: 8f14e45f-ceea-467e-9575-b8c1a3a2f4b0 { "orderId": 42, "amount": 5000 }
The server's contract: the first request with a given key executes normally and its response is cached against that key; every subsequent request with the same key gets the original response replayed, without re-executing the underlying mutation. (The full middleware — a thin Express layer backed by Redis — is built out in Module P-7's "Idempotency Keys for Client-Retried Mutations" section; this is the same mechanism, described here from the API-design side rather than the implementation side.)
Production story: a UPI merchant integration had a customer's flaky mobile network drop the response to POST /payments after the transaction had already cleared on the backend. The client's retry logic, seeing no response, resubmitted the identical request. Without an Idempotency-Key, the API had no way to recognize the second POST as a retry of the first rather than a brand-new charge — it processed both, and the customer was debited twice. The fix wasn't a new feature so much as a new requirement: document Idempotency-Key as mandatory on /payments and reject requests that omit it.
Use the smallest set of status codes necessary. Consistency beats completeness.
2xx — Success 200 OK — standard success (GET, PATCH, PUT, DELETE with body) 201 Created — resource created (POST that creates something) 204 No Content — success, no body (DELETE, logout)
3xx — Redirection 301 Moved Permanently — resource has a new permanent URL 304 Not Modified — ETag match, use cached response
4xx — Client errors (the caller did something wrong) 400 Bad Request — malformed syntax, invalid parameters 401 Unauthorized — not authenticated (misleadingly named) 403 Forbidden — authenticated but not permitted 404 Not Found — resource doesn't exist 409 Conflict — state conflict (duplicate, insufficient stock) 410 Gone — resource existed but was permanently deleted 422 Unprocessable — validation failure (semantically invalid) 429 Too Many Requests — rate limited
5xx — Server errors (your code is wrong) 500 Internal Server Error — unexpected error 502 Bad Gateway — upstream service returned an error 503 Service Unavailable — intentional downtime / overloaded 504 Gateway Timeout — upstream service timed out
A note on 400 vs 422: both are used for validation errors. 400 is more common, 422 is technically more correct for semantically invalid input that is syntactically valid. Pick one and be consistent — mixing them confuses clients.
The table above says which code to return; it says nothing about the shape of the error body, and every API in practice invents its own — { "error": "..." }, { "message": "..." }, { "errors": [...] } — forcing client code to special-case error parsing per API it talks to.
RFC 7807 standardizes this as application/problem+json:
{ "type": "https://myapp.com/errors/insufficient-stock", "title": "Insufficient stock", "status": 409, "detail": "Only 2 units of SKU WIDGET-42 are available, but 5 were requested.", "instance": "/orders/8291" }
type — a URI identifying the error kind. It doesn't need to resolve to anything; it's a stable identifier clients can branch on, more specific than the HTTP status alone.
title — a short, human-readable summary, constant for a given type.
status — the HTTP status code, duplicated in the body for clients that inspect the body without reading headers.
detail — a human-readable explanation specific to this occurrence.
instance — a URI identifying this specific occurrence, often just the request path.
Clients that don't know about RFC 7807 still work fine — it's just JSON with a few conventional field names. Clients that do can branch on type reliably instead of pattern-matching detail strings, which change wording over time.
The table above lists 429 Too Many Requests with no guidance for the caller on what to do next. A bare 429 tells a client it was rejected; it doesn't say when trying again might succeed — without that, a client's only options are guessing a backoff or retrying immediately and getting rate-limited again.
HTTP/1.1 429 Too Many Requests Retry-After: 30 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1718980800
Retry-After — seconds (or an HTTP date) until the client should retry. Standardized (RFC 9110), understood natively by some HTTP clients.
X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset — not standardized, but a de facto convention (GitHub, Stripe, and Twitter all use variants) that lets a well-behaved client throttle itself before ever hitting 429, by watching X-RateLimit-Remaining drop and backing off proactively.
// src/middleware/rateLimit.ts — set these headers on every response, not just 429s res.setHeader('X-RateLimit-Limit', String(limit)); res.setHeader('X-RateLimit-Remaining', String(Math.max(0, limit - count))); res.setHeader('X-RateLimit-Reset', String(resetTimestamp));
Sending X-RateLimit-* on successful responses too — not only on the 429 itself — is what lets clients self-throttle ahead of the limit instead of only reacting after being rejected.
Pros: obvious, easy to test in browser, easy to route at the load balancer. Cons: URLs are supposed to identify resources, not API versions.
Strategy 2: Header versioning — cleaner URLs:
GET /users Accept-Version: 2
// src/middleware/version.ts export function versionRouter(v1Handler, v2Handler) { return (req, res, next) => { const version = parseInt(req.headers['accept-version'] as string ?? '1'); if (version === 2) return v2Handler(req, res, next); return v1Handler(req, res, next); }; }
Pros: clean URLs. Cons: harder to test, often stripped by proxies.
Strategy 3: Content negotiation:
Accept: application/vnd.myapi.v2+json
Used by GitHub and Stripe. Most expressive, most complex.
The pragmatic choice: URL versioning (/v1/, /v2/) for public APIs. Header versioning for internal APIs. Start with /v1/ from day one — retrofitting versioning is painful.
A cursor is a bookmark in a paperback — no matter how many pages someone else adds to the front of the book, your bookmark reliably picks up exactly where you left off. Offset pagination is memorizing "page 43" — add a page up front and you re-read a paragraph or skip one entirely.
Offset pagination — simple but has problems at scale:
GET /posts?page=3&limit=20 GET /posts?offset=40&limit=20
Page drift: if an item is inserted while paginating, page 3 starts one record later — items get skipped or duplicated.
Performance: SELECT ... OFFSET 10000 forces the database to scan and discard 10,000 rows.
Cursor pagination — solves both problems, harder to implement:
GET /posts?limit=20 GET /posts?cursor=eyJpZCI6NDJ9&limit=20 // cursor encodes the last seen row's position
A single-field cursor (id < cursor.id, sorted by id alone) only produces a stable order when the sorted field is itself unique. The moment sorting is exposed as a client-facing option — sort=-createdAt, sort=title from the filtering section above — a plain { createdAt: { lt: cursorValue } } breaks: two posts can share a createdAt timestamp or a title, and rows tied on that value get silently skipped or repeated across pages. Cursor pagination and arbitrary sorting only work together with a compound cursor — the sort field plus a unique tie-breaker (id), both encoded in the cursor and both used in the comparison:
// src/repositories/posts.repository.ts type SortableField = 'createdAt' | 'title' | 'id';
// Allow-list, not the raw query string — never let a client-supplied field // name flow straight into a Prisma `orderBy`/`where` key unchecked const SORTABLE_FIELDS: SortableField[] = ['createdAt', 'title', 'id'];
interface CursorPayload { value: string | number; // the sort field's value at the cursor row id: number; // tie-breaker — guarantees a total order even when `value` repeats }
export async function findPublishedCursor(limit: number, sortParam: string, cursor?: string) { const direction = sortParam.startsWith('-') ? 'desc' : 'asc'; const requestedField = sortParam.replace(/^-/, '') || 'id'; const sortField = SORTABLE_FIELDS.includes(requestedField as SortableField) ? (requestedField as SortableField) : 'createdAt';
const posts = await prisma.post.findMany({ where: { published: true, ...(decodedCursor && { // (sortField, id) as a pair, compared lexicographically: either the // sort field is strictly past the cursor, or it's tied and id // breaks the tie. This is what keeps the order total and stable // even when `sortField` has duplicate values. OR: [ { [sortField]: { [cmp]: decodedCursor.value } }, { [sortField]: decodedCursor.value, id: { [cmp]: decodedCursor.id } }, ], }), }, orderBy: [{ [sortField]: direction }, { id: direction }], // sort field first, id breaks ties take: limit + 1, // fetch one extra to determine hasMore });
A compound cursor is only fast if the database has a matching compound index to seek on — index (sortField, id) for every field you actually allow sorting by, not just (id). This is also why sortField is validated against SORTABLE_FIELDS rather than accepted as free text: an unindexed or arbitrary column both defeats the performance point of cursor pagination and (if it ever reached a raw query builder instead of Prisma's parameterized where) would be an injection risk.
Cursor pagination is O(1) at any depth and is immune to page drift. Use it for feeds, activity streams, and any large dataset. Offset pagination is fine for admin UIs with small datasets where users need to jump to a specific page.
OpenAPI (formerly Swagger) is the standard for describing REST APIs. A valid OpenAPI spec generates interactive documentation, client SDKs, and test mocks.
// Client sends ETag back in If-None-Match header if (req.headers['if-none-match'] === etag) { return res.status(304).send(); // Not Modified — client uses cached version }
URLs are nouns, methods are verbs./users/42 is the resource; DELETE /users/42 is the operation. Verbs in URLs are a design smell.
HATEOAS is the specific "uniform interface" sub-constraint almost every API skips — links in the response describing valid next actions, discovered rather than memorized from docs.
Limit nesting to two levels. Deeper nesting is hard to maintain. Prefer top-level resources with filter params over deep nesting.
PATCH for partial updates, PUT for full replacement. PATCH is almost always what you want.
Idempotency matters for reliability. GET, DELETE, and PUT should be safely retryable. An Idempotency-Key header makes POST safely retryable too — essential once a client can't tell a lost response from a failed request.
URL versioning (/v1/, /v2/) is the pragmatic choice. Start with /v1/ on day one.
RFC 7807 Problem Details standardizes the error body shape (type/title/status/detail); rate-limit headers (Retry-After, X-RateLimit-*) tell clients when a 429 is worth retrying.
Compound cursor pagination (sort field + id tie-breaker) for large datasets and feeds where sorting is client-selectable — a single-field cursor breaks the moment the sorted field isn't unique. Offset pagination for small admin datasets where jumping to page N matters.
OpenAPI 3.0 spec + Swagger UI = interactive docs your API consumers can use immediately. Generate from JSDoc comments in route files — documentation stays co-located with the code.
Next: GraphQL with Apollo Server — schema-first API design, queries, mutations, subscriptions, the N+1 problem and how DataLoader solves it, and when GraphQL is the better choice over REST.
Knowledge Check
When designing a REST API, what is the key difference between the HTTP PUT and PATCH methods?
Why is cursor-based pagination generally preferred over offset-based pagination for large datasets or real-time feeds?
Which of the following is considered a breaking change when versioning an API?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.