--- name: api-design description: "Designs and reviews REST APIs: resource naming, versioning, pagination, error contracts, idempotency, long-running operations, OpenAPI. Use when creating endpoints, writing OpenAPI specs, choosing status codes, or reviewing API changes for breaking compatibility." license: MIT metadata: author: lammanhhoang version: "1.0.0" --- # REST API Design Rules use RFC-2119 keywords (MUST / SHOULD / MAY) and are numbered so findings are citable: "violates rule #32". Rule numbers below match the full pack in references/RULES.md (load when doing a full API review or authoring a spec from scratch — 54 rules with rationale). `(Z-###)` marks the corresponding rule in the Zalando RESTful API Guidelines. ## When to use - Designing new REST endpoints or whole APIs; writing or reviewing OpenAPI specs. - Choosing status codes, error shapes, pagination, versioning, idempotency strategy. - Reviewing a PR that touches public API surface for breaking changes. Do NOT use for: GraphQL or gRPC schema design, debugging a client that consumes someone else's API, event/message schemas, internal function signatures. If the org follows Microsoft/Azure conventions (camelCase, `api-version`), read references/AZURE-DIALECT.md and apply its swaps on top of these rules. ## Core principle: API-first Rule #1 (MUST, Z-100/101): write and review the OpenAPI spec BEFORE implementation. The spec is the contract; code conforms to it, never the reverse. A "spec" generated from annotations after the handlers exist is documentation, not a contract — it inherits every accidental implementation detail as a compatibility promise. ## Workflow 1. Copy `assets/openapi-skeleton.yaml` as the starting point (OpenAPI 3.1, problem+json, cursor pagination, bearer auth already wired). 2. Model resources as nouns with state; list every state transition. Transitions that don't map to CRUD become sub-resources (`POST /orders/{order_id}/cancellations`), not verb URLs. 3. Fill in paths, schemas, and error responses. Every operation gets a unique `operationId` and at least the `default` problem+json response. 4. Self-review against references/RULES.md; cite rule numbers for each finding. 5. Lint: `npx @stoplight/spectral-cli lint openapi.yaml` (default linter; escape hatch: `redocly lint` if the repo already uses Redocly). 6. On any change to an existing spec, diff for breakage: `oasdiff breaking old.yaml new.yaml` (escape hatch: `openapi-diff` if oasdiff unavailable). Any reported breaking change blocks merge unless a new major API is being introduced deliberately. 7. Only then implement. ## Naming matrix Rule #6–#15. One convention per element — no mixing: | Element | Convention | Example | |---|---|---| | Path segments | kebab-case, lowercase (Z-129) | `/sales-orders` | | Collection resources | plural noun (Z-134) | `/orders`, not `/order` | | Actions | noun resource, never a verb (Z-141) | `POST /orders/{order_id}/cancellations`, not `POST /orders/{order_id}/cancel` | | Path parameters | snake_case | `{order_id}` | | Query parameters | snake_case (Z-130) | `?created_after=`, `?next_cursor=` | | JSON properties | snake_case, never camelCase (Z-118) | `created_at` | | Enum values | UPPER_SNAKE_CASE strings | `"PAYMENT_FAILED"` | | Headers | Hyphenated-Pascal | `Idempotency-Key` | | Trailing slash | never; `/orders/` is a different (wrong) URL | — | | Base path | no `/api` prefix; version never in path (rule #49) | — | Where a verb URL is genuinely unavoidable (e.g. a search too complex for query params), use `POST /{resource}/search` and document the exception — do not let it spread. ## HTTP method semantics Rules #24–#25 (Z-148/149). Violating this table breaks caches, retries, and crawlers: | Method | Safe | Idempotent | Use for | |---|---|---|---| | GET | yes | yes | Read. Never triggers side effects. Never has a request body. | | HEAD | yes | yes | GET without body (existence/size checks). | | PUT | no | yes | Full replacement at a client-known URL. | | PATCH | no | no* | Partial update. Default format: JSON Merge Patch (RFC 7396). | | POST | no | no | Create under a collection; processing. Make retry-safe via rule #42. | | DELETE | no | yes | Remove. Second DELETE returns 404 — still idempotent (server state identical). | *PATCH becomes effectively idempotent with `If-Match` (rule #43). ## Status codes Rule #26: use only these unless you can cite the RFC for another. Rule #27: never 200 with an error body. | Code | One-line rule | |---|---| | 200 | Synchronous success with response body. | | 201 | Resource created; MUST set `Location`; body SHOULD be the created representation. | | 202 | Accepted for async processing; MUST set `Location` to a status monitor (see LRO below). | | 204 | Success, no body — DELETE, and PUT/PATCH when you return nothing. | | 400 | Malformed request: bad JSON, wrong type, invalid parameter syntax. | | 401 | Missing or invalid credentials ("who are you?"). | | 403 | Authenticated but not permitted ("you can't do that"). | | 404 | Resource does not exist; also use instead of 403 when existence itself is sensitive. | | 409 | State conflict: duplicate unique key, illegal state transition, concurrent in-flight idempotent replay. | | 412 | `If-Match` precondition failed — client's ETag is stale (lost-update guard). | | 422 | Syntactically valid but semantically invalid body (business validation failure). | | 429 | Rate limited; MUST include `Retry-After`. | | 500 | Unhandled server bug; generic problem body, details go to logs only. | | 503 | Temporarily unavailable / overloaded; SHOULD include `Retry-After`. | ## Error contract — RFC 9457 Rule #32 (MUST, Z-176): every 4xx/5xx body is `application/problem+json` with exactly these standard members — `type`, `title`, `status`, `detail`, `instance`: ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://api.example.com/problems/out-of-stock", "title": "Item Out of Stock", "status": 409, "detail": "Item 8f1e2c is out of stock; 0 units remaining.", "instance": "/orders/ord_7f3a" } ``` - `type` is a stable URI identifying the error class — it is contract; clients switch on it. Changing it is a breaking change (rule #33). - `title` is the short human label for the type; `detail` is occurrence-specific prose and MAY change freely. - Extension members are allowed (rule #35): add `"errors": [{"field": "email", "message": "..."}]` for field-level validation on 422. - Never leak stack traces, SQL, or internal hostnames (rule #34). ## Pagination Rule #37 (MUST, Z-160): cursor-based pagination for any collection that grows or mutates. Offset/limit degrades (`OFFSET 500000` scans and discards) and skips or duplicates rows when writes land between pages. Offset MAY be used only for small, effectively static sets (e.g. a country list). ```http GET /orders?limit=50&cursor=eyJpZCI6Im9yZF83ZjNhIn0 ``` ```json { "items": [ { "id": "ord_7f3b", "...": "..." } ], "next_cursor": "eyJpZCI6Im9yZF83ZjNiIn0" } ``` - Envelope is an object, never a bare array (rule #16) — bare arrays can't grow new fields. - `next_cursor` absent or `null` means last page. Cursor is opaque base64 — clients MUST NOT parse it; server MAY encode `(sort_key, id)` inside (rule #39). - `limit`: default 20, max 100; exceeding max is a 400 (rule #40). ## Idempotency - PUT and DELETE are idempotent by definition — implement them so a retry is a no-op (rule #41). - POST endpoints with money or creation side effects MUST accept an `Idempotency-Key` header (rule #42): client sends a UUIDv4; server stores key → response for ≥ 24h. | Situation | Server behavior | |---|---| | First request | Process normally, store response under key. | | Retry, same key + same body | Return the stored original response; do not re-execute. | | Same key + different body | 422 problem — key misuse. | | Retry while original still executing | 409 problem — tell client to wait and retry. | ## Long-running operations Rule #44–#45. Anything slower than a few seconds returns `202 Accepted` plus a status-monitor resource: ```http POST /reports HTTP/1.1 202 Accepted Location: /operations/op_91c4 Retry-After: 5 GET /operations/op_91c4 HTTP/1.1 200 OK { "id": "op_91c4", "status": "running", "created_at": "2026-07-26T09:30:00Z" } ``` - `status` ∈ `QUEUED | RUNNING | SUCCEEDED | FAILED | CANCELLED`. Terminal states (last three) are immutable — a monitor never leaves them. - On `SUCCEEDED`, the monitor MUST link the result: `"result_url": "/reports/rep_5512"` (or respond `303 See Other` + `Location`). - On `FAILED`, embed the problem object: `"error": { "type": "...", "title": "...", ... }`. - Keep monitors readable for a documented retention window (e.g. 24h) after reaching a terminal state. ## Compatibility and versioning Rule #46 (MUST, Z-106): never break compatibility of a published API. Breaking = removing/renaming a field or endpoint, changing a field's type or meaning, making an optional request field required, adding a new required request field, removing an enum value from responses is fine but **adding** one breaks non-tolerant clients — so also rule #21: declare enums extensible (`x-extensible-enum`) and require clients to handle unknown values. Changing a problem `type` URI, tightening validation, and changing defaults are all breaking. - Evolve additively: new optional fields, new endpoints, new enum values under rule #21 (rule #47–#48, Z-113). - Rule #49 (MUST, Z-115): no URL versioning — no `/v1/`. A version in the path forks the resource identity and forces client-wide migrations. If a breaking change is truly unavoidable, use media type versioning (Z-114): `Accept: application/vnd.example.order+json;version=2`, or stand up a new API under a new hostname and deprecate the old one on a dated schedule. - Exception dialect: Azure/Microsoft-style `api-version=2026-07-26` query parameter. Only when the org already standardizes on it — see references/AZURE-DIALECT.md. - Gate every spec change in CI: `oasdiff breaking` (rule #50). ## Security - Rule #51 (MUST, Z-104): every endpoint authenticated — OAuth 2.0 bearer / OIDC by default. An intentionally public endpoint requires an explicit, recorded decision. - Rule #52 (Z-105): one scope per operation, named `{service}.{resource}.{action}`, action ∈ `read | write`. Example: `payments.refunds.write`. Declare scopes in the OpenAPI security block per operation. - Rule #53 (MUST): no secrets, tokens, API keys, session ids, or PII in URLs — paths and query strings end up in access logs, proxies, and browser history. Credentials travel in `Authorization`; sensitive filters travel in POST bodies. ## Field conventions | Field | Rule | |---|---| | `created_at`, `updated_at` | Every mutable resource has both. ISO-8601 UTC with `Z`: `"2026-07-26T09:30:00Z"` (rule #18). All timestamp properties end in `_at`. | | Money | `{"amount": "12.34", "currency": "EUR"}` — amount is a decimal **string** (never float — IEEE 754 corrupts cents), currency is ISO 4217 (rules #19–#20). | | `id` | Opaque string on the resource itself; foreign references are `{resource}_id` (rule #23). | | Booleans | No `null` tri-state; use an enum if there are three states. | ## What NOT to do - Do not put verbs in URLs, versions in paths, or camelCase in JSON (unless AZURE-DIALECT applies). - Do not return 200 for errors, bare arrays as top-level JSON, or floats for money. - Do not invent error envelopes — RFC 9457 exists; use it. - Do not ship offset pagination on a table that grows. - Do not "fix" a published field name — add the new one, deprecate the old, remove only in a new major API. - Do not write handlers first and generate the spec after.