# Patterns — Designing REST APIs ## Contents - URL and method matrix - Pagination envelope (cursor) - problem+json error schemas - Idempotency-Key handling - OpenAPI 3.1 skeleton - Gotchas ## URL and method matrix ``` GET /v1/orders list (paginated) 200 POST /v1/orders create 201 + Location GET /v1/orders/{id} read 200 / 404 PUT /v1/orders/{id} full replace (idempotent) 200 / 404 PATCH /v1/orders/{id} partial update 200 / 404 / 409 DELETE /v1/orders/{id} delete (idempotent) 204 / 404 POST /v1/orders/{id}/refund non-CRUD action 201 / 409 GET /v1/customers/{id}/orders nested read-only view 200 ``` Nest at most one level; deeper relations get top-level resources with filters (`/v1/orders?customer_id=7`), not `/customers/7/orders/3/items/9`. ## Pagination envelope (cursor) ```json { "data": [{ "id": "ord_01J8", "status": "active" }], "pagination": { "next_cursor": "eyJpZCI6Im9yZF8wMUo4In0", "has_more": true, "limit": 20 } } ``` Request: `GET /v1/orders?limit=20&cursor=eyJpZCI6...`. The cursor is opaque (base64 of the last sort key) — clients must not parse it. `limit` above the max of 100 → clamp to 100, do not error. ## problem+json error schemas Header: `Content-Type: application/problem+json` ```json { "type": "https://api.example.com/errors/validation", "title": "Validation failed", "status": 422, "detail": "2 fields are invalid.", "instance": "/v1/orders", "errors": [ { "field": "email", "message": "must be a valid email address" }, { "field": "quantity", "message": "must be between 1 and 99" } ] } ``` Conflict example: ```json { "type": "https://api.example.com/errors/state-conflict", "title": "Order already shipped", "status": 409, "detail": "Order ord_01J8 cannot be cancelled after shipment.", "instance": "/v1/orders/ord_01J8/cancellation" } ``` Define the schema once under `components/schemas/Problem` and `$ref` it from every 4xx/5xx response. ## Idempotency-Key handling ```python # Pseudocode for POST endpoints that create resources. # Keys expire after 24h — long enough for client retry storms, short enough # to bound storage. def create_order(request): key = request.headers.get("Idempotency-Key") if key: cached = idempotency_store.get(key) if cached: if cached.request_hash != hash(request.body): return problem(422, "Idempotency-Key reused with different body") return cached.response # replay, no side effect response = do_create(request.body) # single side effect if key: idempotency_store.put(key, hash(request.body), response, ttl_hours=24) return response ``` ## OpenAPI 3.1 skeleton ```yaml openapi: 3.1.0 info: { title: Orders API, version: 1.0.0 } servers: [{ url: https://api.example.com/v1 }] paths: /orders: get: parameters: - { name: limit, in: query, schema: { type: integer, maximum: 100, default: 20 } } - { name: cursor, in: query, schema: { type: string } } responses: "200": { $ref: "#/components/responses/OrderList" } "429": { $ref: "#/components/responses/Problem" } post: parameters: - { name: Idempotency-Key, in: header, schema: { type: string } } responses: "201": { $ref: "#/components/responses/Order" } "422": { $ref: "#/components/responses/Problem" } components: schemas: Problem: type: object properties: type: { type: string } title: { type: string } status: { type: integer } detail: { type: string } instance: { type: string } ``` ## Gotchas - **404 vs 403 leaks existence.** If callers must not learn a resource exists, return 404 for both missing and forbidden. - **PUT with partial body silently nulls fields** — that is correct replace semantics; if clients expect merge, they need PATCH. Document which you offer. - **Offset pagination drifts** when rows are inserted mid-scan; deep offsets also get slow. Cursors fix both — default to them. - **Trailing slashes**: `/orders` and `/orders/` must not be two resources. Redirect or normalize one to the other. - **Enum widening is breaking for clients that switch exhaustively.** Adding an enum value is only additive if the spec documents "unknown values may appear" from v1. - **Date-times**: always RFC 3339 UTC (`2026-08-05T14:30:00Z`); epoch integers and local times cause silent client bugs.