From b0d07138c9f298cc187b0ecaf2aea81d0bedb4a3 Mon Sep 17 00:00:00 2001 From: Camgineer Date: Mon, 14 Sep 2026 12:28:17 -0400 Subject: [PATCH] feat(cstack): add rest-api-best-practices Park this general engineering skill in C-Stack for later review. --- skills/rest-api-best-practices/SKILL.md | 148 ++++++++ .../references/api-design-philosophy.md | 133 +++++++ .../references/caching.md | 149 ++++++++ .../references/documentation.md | 145 ++++++++ .../references/error-handling.md | 102 +++++ .../references/go-examples.md | 345 +++++++++++++++++ .../references/http-semantics.md | 168 +++++++++ .../references/java-examples.md | 325 ++++++++++++++++ .../references/language-examples.md | 52 +++ .../references/observability.md | 107 ++++++ .../references/php-examples.md | 304 +++++++++++++++ .../references/python-examples.md | 352 ++++++++++++++++++ .../references/request-response-design.md | 227 +++++++++++ .../references/rust-examples.md | 275 ++++++++++++++ .../references/security.md | 127 +++++++ .../references/testing-apis.md | 105 ++++++ .../references/typescript-examples.md | 332 +++++++++++++++++ .../references/url-design.md | 181 +++++++++ tests/inventory.test.mjs | 3 +- 19 files changed, 3579 insertions(+), 1 deletion(-) create mode 100644 skills/rest-api-best-practices/SKILL.md create mode 100644 skills/rest-api-best-practices/references/api-design-philosophy.md create mode 100644 skills/rest-api-best-practices/references/caching.md create mode 100644 skills/rest-api-best-practices/references/documentation.md create mode 100644 skills/rest-api-best-practices/references/error-handling.md create mode 100644 skills/rest-api-best-practices/references/go-examples.md create mode 100644 skills/rest-api-best-practices/references/http-semantics.md create mode 100644 skills/rest-api-best-practices/references/java-examples.md create mode 100644 skills/rest-api-best-practices/references/language-examples.md create mode 100644 skills/rest-api-best-practices/references/observability.md create mode 100644 skills/rest-api-best-practices/references/php-examples.md create mode 100644 skills/rest-api-best-practices/references/python-examples.md create mode 100644 skills/rest-api-best-practices/references/request-response-design.md create mode 100644 skills/rest-api-best-practices/references/rust-examples.md create mode 100644 skills/rest-api-best-practices/references/security.md create mode 100644 skills/rest-api-best-practices/references/testing-apis.md create mode 100644 skills/rest-api-best-practices/references/typescript-examples.md create mode 100644 skills/rest-api-best-practices/references/url-design.md diff --git a/skills/rest-api-best-practices/SKILL.md b/skills/rest-api-best-practices/SKILL.md new file mode 100644 index 0000000..c964318 --- /dev/null +++ b/skills/rest-api-best-practices/SKILL.md @@ -0,0 +1,148 @@ +--- +name: rest-api-best-practices +description: REST API design guidance. Use when designing or reviewing REST endpoints, choosing HTTP methods and status codes, defining URL structure and naming conventions, formatting error responses, implementing pagination or filtering, versioning an API, securing endpoints with authentication or rate limiting, or reviewing whether an API exposes domain capabilities rather than raw CRUD operations. +license: MIT +metadata: + author: luckys + version: "1.0.0" +--- + +# REST API Best Practices + +Use this skill when the main question is how to design, review, or improve a REST API — from URL structure to error formats to security. + +## Working Style + +1. Start from what the consumer needs, not from what the database looks like. +2. Use HTTP as designed — methods, status codes, and headers carry meaning. +3. Express domain capabilities, not data operations. +4. Make errors as informative as successes. +5. Design for change: version early, break compatibility deliberately. + +## Design Workflow + +1. **Define the resources.** + - What are the concepts the consumer needs to interact with? + - Name them with nouns, in plural form. + - Avoid verbs in URLs — the HTTP method is the verb. + +2. **Choose the right HTTP method.** + - GET for retrieval (safe, idempotent, cacheable) + - POST for creation or non-idempotent operations + - PUT for full replacement (idempotent) + - PATCH for partial update + - DELETE for removal (idempotent) + +3. **Design the URL.** + - Keep paths flat where possible — at most two levels of nesting. + - Use query parameters for filtering, sorting, and pagination. + - Use task URLs (`POST /orders/{id}/cancel`) for domain operations that aren't simple CRUD. + +4. **Define response shapes.** + - Choose a consistent field naming convention (camelCase or snake_case) and stick to it. + - Return only what the consumer needs. + - Use HTTP headers for metadata (pagination, rate limits). + +5. **Define error responses.** + - Use the right 4xx or 5xx status code — never return 200 for errors. + - Return a structured error body with a machine-readable code and a human-readable message. + - Follow RFC 9457 (Problem Details, which obsoletes RFC 7807) when the team needs a standard. + +6. **Add versioning.** + - Default to URL versioning (`/v1/`). + - Treat a breaking change as a reason to bump the version, not to patch silently. + +## Heuristics + +### Resource Naming +Nouns, plural, lowercase, kebab-case for multi-word: `/user-profiles`, `/order-items`. + +### HTTP Method Semantics +GET is safe and must never change state. POST creates or triggers actions. PUT replaces the entire resource. PATCH patches specific fields. DELETE removes. + +### Status Code Choice +2xx = success. 4xx = client error. 5xx = server error. Never return 200 for a failed operation. + +### Error Format +One consistent error envelope for all failures. Machine-readable `code` field, human-readable `message`, optional `errors` array for field-level validation details. + +### Pagination +Default to cursor-based for large or real-time datasets. Offset-based is fine for small, bounded collections. Always include a `Link` header with navigation links. + +### CRUD vs. Task-Based Design +Avoid exposing raw CRUD when the domain has real business operations. Prefer `POST /orders/{id}/cancel` over `PATCH /orders/{id}` with `{ "status": "cancelled" }`. + +## Day-to-Day Rules + +- Use plural nouns for collection endpoints. +- Never put verbs in URL paths — the HTTP method is the verb. +- Return 201 Created (not 200) after a successful POST that creates a resource. +- Include a `Location` header pointing to the new resource after 201. +- Return 204 No Content when there is nothing meaningful to return in the body. +- Never expose stack traces, SQL errors, or internal paths in error responses. +- Paginate every collection endpoint — never return an unbounded list. +- Use `Accept` and `Content-Type` headers for content negotiation. +- Put secrets in headers, never in query parameters. +- Always use HTTPS — never plain HTTP for API traffic. + +## Good Signals + +- URL paths read like a sentence: `GET /orders/{id}/items`. +- Every status code is intentional and precise. +- Errors have a machine-readable code the client can branch on. +- Pagination is consistent across all collection endpoints. +- Breaking changes require a version bump. +- The API surface reflects domain language, not table names. + +## Warning Signs + +- Verbs in URL paths: `/getUser`, `/createOrder`, `/deleteItem`. +- 200 OK returned for validation errors or business failures. +- Error bodies expose stack traces or raw SQL. +- Collections returned without pagination. +- Inconsistent field naming: sometimes `userId`, sometimes `user_id`. +- Every endpoint maps directly to a database table with full CRUD. +- No versioning strategy — breaking changes deployed silently. +- API key or token passed as a query parameter. +- Response objects nested more than two levels deep. +- No request ID header — errors cannot be correlated in logs. +- No documentation or documentation that doesn't match reality. + +## References + +- Read `references/url-design.md` for URL structure, naming conventions, nesting rules, versioning, and task-based URLs. +- Read `references/http-semantics.md` for HTTP method semantics, idempotency, safety, status code selection, and key headers. +- Read `references/request-response-design.md` for filtering, sorting, pagination (offset and cursor), content negotiation, and response shape. +- Read `references/error-handling.md` for RFC 9457 Problem Details, centralized mapping, status policy, redaction, domain failure translation, and validation errors. +- Read `references/security.md` for authentication (JWT, API keys, OAuth 2.0), authorization, rate limiting, CORS, and HTTPS enforcement. +- Read `references/api-design-philosophy.md` for REST architectural constraints, the CRUD anti-pattern, task-based API design, and HATEOAS. +- Read `references/observability.md` for request IDs, structured logging, metrics, distributed tracing, health endpoints, and alerting. +- Read `references/documentation.md` for what to document per endpoint, working examples, OpenAPI 3.x spec structure, keeping docs executable with contract testing, and changelog practices. +- Read `references/caching.md` for Cache-Control directives, ETag/Last-Modified validation, conditional writes (optimistic concurrency), cache layers, and invalidation strategies. +- Read `references/testing-apis.md` for the API test pyramid, contract testing (OpenAPI, Pact), what to test beyond the happy path, and test isolation. +- Read `references/language-examples.md` for an index of language-specific implementation examples. +- Read `references/typescript-examples.md` for REST API implementation in TypeScript (Express, NestJS). +- Read `references/python-examples.md` for REST API implementation in Python (FastAPI, Django REST Framework). +- Read `references/go-examples.md` for REST API implementation in Go (net/http, Chi). +- Read `references/php-examples.md` for REST API implementation in PHP (Laravel, Symfony). +- Read `references/java-examples.md` for REST API implementation in Java (Spring Boot 3.x). +- Read `references/rust-examples.md` for REST API implementation in Rust (axum). + +## Related Skills + +- Use `ddd-best-practices` when the API surface should reflect a Bounded Context or align with domain language. +- Use `design-patterns-best-practices` for internal patterns within the API implementation (Repository, Service Layer, etc.). +- Use `oop-best-practices` for controller and service design within the API layer. + +## Source Influences + +This skill is synthesized from: + +- *REST API Design Rulebook* by Mark Masse +- *RESTful Web APIs* by Leonard Richardson & Mike Amundsen +- RFC 9457 — Problem Details for HTTP APIs +- RFC 9110 — HTTP Semantics +- Fran Iglesias — API REST series (franiglesias.github.io) +- Derek Comartin — "CRUD APIs are Poor Design" (codeopinion.com) +- Postman REST API Best Practices +- *Building Microservices* by Sam Newman diff --git a/skills/rest-api-best-practices/references/api-design-philosophy.md b/skills/rest-api-best-practices/references/api-design-philosophy.md new file mode 100644 index 0000000..bb8feb4 --- /dev/null +++ b/skills/rest-api-best-practices/references/api-design-philosophy.md @@ -0,0 +1,133 @@ +# API Design Philosophy + +## REST Architectural Constraints + +REST (Representational State Transfer) is defined by six architectural constraints. Breaking them does not make an API wrong, but it does forfeit the guarantees REST provides. + +### 1. Uniform Interface + +The most important constraint. It has four sub-constraints: + +1. **Resource identification** — resources are identified by URI, independent of their representation. +2. **Manipulation through representations** — clients interact with resources via representations (JSON, XML), not directly with the resource. +3. **Self-descriptive messages** — each message includes enough information to describe how to process it (Content-Type, status codes, Cache-Control). +4. **HATEOAS** — representations include links to available next actions. + +### 2. Statelessness + +Each request must contain all information needed to process it. The server holds no client session state between requests. + +- Benefits: horizontal scalability, fault tolerance, simplicity. +- Implication: authentication credentials must be sent with every request. +- Never store "current user state" on the server to resume across requests. + +### 3. Cacheability + +Responses must explicitly declare whether they are cacheable. GET responses are cacheable by default unless told otherwise. Proper caching reduces load and improves perceived latency. + +### 4. Client–Server Separation + +The client and server are decoupled and can evolve independently. The only coupling is the API contract (URLs, methods, response shapes). The server should not know or care about the client's rendering or state. + +### 5. Layered System + +A client cannot tell whether it is talking to the origin server or an intermediary (load balancer, CDN, API gateway, caching proxy). APIs must behave identically regardless of intermediaries. + +### 6. Code on Demand (optional) + +Servers may optionally send executable code to clients (JavaScript). Rarely used in practice. + +--- + +## CRUD APIs Are Poor Design + +Exposing a direct CRUD interface over domain entities is the most common REST anti-pattern. + +> "CRUD APIs and CRUD-driven systems — meaning systems built around Create, Read, Update, and Delete operations — are, in the long run, the hardest to change and evolve." — Derek Comartin + +Problems with pure CRUD APIs: + +**Couples clients to data structure.** A schema change (renaming a field, splitting a table) immediately breaks consumers. The API leaks the database model. + +**Hides business intent.** `PATCH /orders/{id}` with `{ "status": "cancelled" }` and `{ "status": "shipped" }` look identical. The consumer must know what transitions are legal and what side effects they trigger (email sent? stock updated?). + +**Misses invariants.** A rule like "an order cannot be cancelled after it ships" is invisible in a raw PATCH. The client must implement the guard — and different clients will implement it differently. + +**Forces multi-step choreography.** A single business operation (place an order: validate stock, charge payment, send confirmation) becomes a sequence of CRUD calls that the client must orchestrate correctly. + +**When pure CRUD is acceptable:** +- Truly data-centric resources with no business logic: configuration entries, reference data, admin panels on lookup tables. +- Internal tooling where the consumer controls both sides of the contract. + +--- + +## Task-Based API Design + +Design endpoints around business capabilities, not data rows. + +``` +# CRUD (poor for domain operations) +PATCH /orders/{id} { "status": "cancelled" } +PATCH /orders/{id} { "status": "shipped" } +PATCH /accounts/{id} { "active": false } + +# Task-based (expresses domain intent) +POST /orders/{id}/cancel +POST /orders/{id}/ship +POST /accounts/{id}/deactivate +``` + +Benefits: +- The URL reads like the domain's ubiquitous language. +- Business rules live on the server: `cancel` enforces that you cannot cancel a shipped order. +- Clients express **intent**, not internal state mutations. +- Each action is independently versioned, documented, and tested. +- Side effects (events, notifications, stock updates) are encapsulated behind the action. + +### Naming task URLs + +Use the domain's verb in past tense or imperative: + +``` +POST /invoices/{id}/send +POST /invoices/{id}/void +POST /subscriptions/{id}/pause +POST /subscriptions/{id}/resume +POST /users/{id}/verify-email +POST /users/{id}/request-password-reset +``` + +--- + +## HATEOAS + +Hypermedia As The Engine Of Application State: responses include links that guide the client to available next actions. The client discovers state transitions from the response rather than hard-coding them. + +```json +{ + "id": "ord_01J8X", + "status": "pending", + "total": { "amount": 120.00, "currency": "EUR" }, + "_links": { + "self": { "href": "/orders/ord_01J8X" }, + "cancel": { "href": "/orders/ord_01J8X/cancel", "method": "POST" }, + "items": { "href": "/orders/ord_01J8X/items" }, + "customer": { "href": "/users/usr_42" } + } +} +``` + +When the order is shipped, `cancel` disappears from `_links` — the client does not need to know the cancellation business rule; it just checks whether the link is present. + +Full HATEOAS is rarely implemented in practice (the tooling and discipline cost is high). A pragmatic middle ground: include `_links` for navigation and action discovery without requiring full hypermedia compliance. + +--- + +## API as a Product + +- **Design for the consumer**, not for the implementation. The right surface is discovered by talking to consumers, not by mirroring the database. +- **Provide documentation before shipping** — an undocumented API is a broken API. +- **Treat breaking changes like database migrations**: they require coordination, a migration path, and a deadline. +- **Gather consumer feedback** early and continuously. The API surface you ship first is not the right one. +- **A stable contract is more valuable than an internally "correct" one.** Once consumers depend on a shape, stability trumps elegance. +- **Version proactively** — add versioning before the first consumer, not after the first breaking change. diff --git a/skills/rest-api-best-practices/references/caching.md b/skills/rest-api-best-practices/references/caching.md new file mode 100644 index 0000000..974b7e0 --- /dev/null +++ b/skills/rest-api-best-practices/references/caching.md @@ -0,0 +1,149 @@ +# Caching + +Caching is the highest-leverage performance tool in a REST API. Done well, it reduces latency, server load, and bandwidth. Done wrong, it serves stale or wrong data to the wrong users. + +## Cache-Control Directives + +The `Cache-Control` response header tells clients and intermediaries how to cache. + +``` +# Public — any cache (CDN, proxy, browser) may store it +Cache-Control: public, max-age=3600 + +# Private — only the end client may cache (user-specific data) +Cache-Control: private, max-age=60 + +# Never store (sensitive or always-fresh data) +Cache-Control: no-store + +# Store but revalidate with the server before every use +Cache-Control: no-cache + +# Allow stale while revalidating in the background +Cache-Control: max-age=600, stale-while-revalidate=60 +``` + +| Directive | Meaning | +|-----------|---------| +| `public` | Any cache may store the response | +| `private` | Only the browser/client may store it — never shared caches | +| `no-store` | Do not store anywhere (most restrictive) | +| `no-cache` | May store, but must revalidate before serving | +| `max-age=N` | Fresh for N seconds | +| `s-maxage=N` | Fresh for N seconds in shared caches (overrides max-age there) | +| `must-revalidate` | Once stale, must revalidate — never serve stale | +| `stale-while-revalidate=N` | Serve stale up to N seconds while refreshing in background | + +Rule of thumb: +- User-specific data → `private`. +- Public reference data → `public` with a sensible `max-age`. +- Anything with auth or PII → `no-store` unless you are certain. + +## Validation: ETag and Last-Modified + +Validation lets a client confirm its cached copy is still good without re-downloading the body. + +### ETag (strong validator) + +``` +# First response — server provides an opaque version token +HTTP/1.1 200 OK +ETag: "a1b2c3d4" +Cache-Control: private, max-age=0, must-revalidate + +{ "id": "usr_01", "name": "Ana García" } + +# Next request — client asks "is my copy still valid?" +GET /users/usr_01 +If-None-Match: "a1b2c3d4" + +# Unchanged → 304, no body (saves bandwidth) +HTTP/1.1 304 Not Modified +ETag: "a1b2c3d4" + +# Changed → 200 with new body and new ETag +HTTP/1.1 200 OK +ETag: "e5f6g7h8" +{ "id": "usr_01", "name": "Ana López" } +``` + +The ETag is an opaque token — typically a hash of the representation or a version number. Clients must treat it as opaque and not parse it. + +### Last-Modified (weak validator) + +``` +HTTP/1.1 200 OK +Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT + +# Client revalidates by date +GET /users/usr_01 +If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT + +# → 304 Not Modified if unchanged +``` + +Prefer ETag over Last-Modified when you can: it is precise to the byte, while Last-Modified has one-second granularity and breaks for sub-second updates. + +## Conditional Writes (Optimistic Concurrency) + +ETags also prevent lost updates. The client sends the ETag it last saw; the server rejects the write if the resource changed in the meantime. + +``` +PUT /users/usr_01 +If-Match: "a1b2c3d4" +{ "name": "Ana López" } + +# Resource unchanged since a1b2c3d4 → write succeeds +HTTP/1.1 200 OK +ETag: "e5f6g7h8" + +# Resource changed since a1b2c3d4 → reject (someone else wrote first) +HTTP/1.1 412 Precondition Failed +``` + +This turns "last write wins" into "first write wins, others must refetch" — eliminating silent overwrites. + +## Where to Cache + +| Layer | What it caches | TTL guidance | +|-------|----------------|--------------| +| Browser / client | Per-user responses | Short (seconds–minutes) | +| CDN / edge | Public, cacheable GETs | Medium–long (minutes–hours) | +| Reverse proxy (Varnish, nginx) | Public responses near origin | Medium | +| Application cache (Redis) | Expensive computed results, read models | Domain-dependent | +| Database query cache | Hot queries | Short | + +Push caching as close to the client as the data sensitivity allows. Public reference data belongs on the CDN; user-specific data stays private/in-app. + +## Cache Invalidation + +The hard part. Strategies, simplest to strongest: + +1. **TTL expiry** — let entries expire naturally. Simple, but serves stale data within the window. Good for data that tolerates slight staleness. +2. **Write-through** — update the cache when you update the source. Keeps cache fresh, adds write latency. +3. **Explicit invalidation** — delete/purge cache keys on mutation. Precise, but you must track every key a change affects. +4. **Versioned keys** — embed a version in the cache key (`user:usr_01:v3`); a new version makes old entries unreachable and they expire naturally. + +For CDN-cached public endpoints, trigger a purge on the relevant paths when the underlying resource changes. + +## What Not to Cache + +- Responses to authenticated requests, unless explicitly marked `private` and safe. +- Anything containing PII, tokens, or payment data → `no-store`. +- Non-idempotent responses (POST results) unless using an idempotency key mechanism. +- Error responses (4xx/5xx) beyond very short windows, to avoid pinning a transient failure. + +## Vary Header + +When a response depends on a request header (content negotiation, auth, encoding), tell caches so they key correctly: + +``` +Vary: Accept, Accept-Encoding, Authorization +``` + +Without `Vary: Authorization`, a shared cache could serve one user's response to another. Always set `Vary` (or `Cache-Control: private`) when responses differ per user. + +## Related References + +- `http-semantics.md` — status codes (304, 412), conditional request headers, idempotency. +- `observability.md` — measuring cache hit rates and their effect on latency. diff --git a/skills/rest-api-best-practices/references/documentation.md b/skills/rest-api-best-practices/references/documentation.md new file mode 100644 index 0000000..500d396 --- /dev/null +++ b/skills/rest-api-best-practices/references/documentation.md @@ -0,0 +1,145 @@ +# Documentation + +An undocumented API is a broken API. Clients cannot safely use what they cannot understand, and undocumented behavior becomes accidental contracts that are impossible to change. + +## What to Document for Every Endpoint + +For each endpoint, document: + +| Section | Content | +|---------|---------| +| **Method + URL** | `POST /v1/orders` | +| **Description** | What this endpoint does in domain terms | +| **Authentication** | Required auth method and scope | +| **Request headers** | Required and optional headers | +| **Path parameters** | Name, type, constraints | +| **Query parameters** | Name, type, default, constraints | +| **Request body** | Schema with field descriptions and constraints | +| **Response body** | Schema for each success response | +| **Status codes** | All possible codes with meaning | +| **Error responses** | Error codes, messages, and when each occurs | + +## Working Examples + +Every endpoint should have at least one complete request/response example — not a schema, but actual content a developer can copy and run. + +``` +# Create an order +POST /v1/orders +Content-Type: application/json +Authorization: Bearer eyJhbGci... + +{ + "customerId": "usr_01J8X", + "items": [ + { "productId": "prod_abc", "quantity": 2 } + ], + "shippingAddress": { + "street": "Calle Mayor 10", + "city": "Madrid", + "postalCode": "28001", + "country": "ES" + } +} + +# → 201 Created +Location: /v1/orders/ord_xyz789 + +{ + "id": "ord_xyz789", + "status": "pending", + "customerId": "usr_01J8X", + "total": { "amount": 59.90, "currency": "EUR" }, + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +Include examples for the most common error cases too: + +``` +# → 422 Unprocessable Entity (validation error) +{ + "type": "https://api.example.com/errors/validation-failed", + "title": "Validation Failed", + "status": 422, + "errors": [ + { "field": "items", "message": "Must contain at least one item" } + ] +} +``` + +## OpenAPI Specification + +Use OpenAPI 3.x as the machine-readable contract. It generates: +- Interactive documentation (Swagger UI, Redoc) +- Client SDKs in any language +- Server stubs +- Automated contract tests + +Keep the spec in the repository alongside the code. Treat spec drift (spec out of sync with implementation) as a bug. + +```yaml +# openapi.yaml skeleton +openapi: "3.1.0" +info: + title: Orders API + version: "1.0.0" +paths: + /v1/orders: + post: + summary: Create an order + operationId: createOrder + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateOrderRequest" + responses: + "201": + description: Order created + headers: + Location: + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "422": + $ref: "#/components/responses/ValidationError" +``` + +## Keeping Documentation Executable + +Documentation that cannot be verified against the real API will drift. Strategies to keep it current: + +- **Contract testing**: use the OpenAPI spec to generate tests that run against the real server (Schemathesis, Dredd, Spectral). +- **Example-driven testing**: use the examples in the spec as test inputs and verify the outputs match the documented schemas. +- **CI gate**: fail the pipeline if the spec fails linting or contract tests. +- **One source of truth**: generate both docs and validation from the same spec — never maintain parallel copies. + +## Documenting Deprecations + +When an endpoint or field is deprecated, document it prominently: +- Mark it as deprecated in the OpenAPI spec (`deprecated: true`). +- Include the removal date and the migration path. +- Link to the replacement endpoint or field. + +```yaml +/v1/users/{id}: + get: + deprecated: true + summary: "[Deprecated - removed 2025-12-01] Use /v2/users/{id} instead" +``` + +## Changelog + +Maintain a public changelog that documents: +- New endpoints and fields (non-breaking) +- Deprecated endpoints with removal dates +- Breaking changes with migration guides (per major version) + +A changelog is the first place an API consumer checks when something breaks after an upgrade. diff --git a/skills/rest-api-best-practices/references/error-handling.md b/skills/rest-api-best-practices/references/error-handling.md new file mode 100644 index 0000000..131df23 --- /dev/null +++ b/skills/rest-api-best-practices/references/error-handling.md @@ -0,0 +1,102 @@ +# HTTP Error Handling + +## Core Contract + +An error response is an API contract. It must use the correct status, a consistent machine-readable body, explicit safe details, and no stack trace, SQL text, vendor message, hostname, secret, or unauthorized identifier. + +## Problem Details (RFC 9457) + +RFC 9457 obsoletes RFC 7807 while retaining the familiar Problem Details shape. Return it with: + +```http +Content-Type: application/problem+json +``` + +```json +{ + "type": "https://api.example.com/problems/validation-failed", + "title": "Validation failed", + "status": 422, + "detail": "The request contains invalid fields.", + "instance": "/orders/requests/req-123", + "errors": [{ "field": "quantity", "code": "must_be_positive" }] +} +``` + +- `type`: stable public problem identifier, ideally documented. +- `title`: stable human summary for the type. +- `status`: repeated HTTP status. +- `detail`: occurrence-specific but explicitly safe text. +- `instance`: occurrence/resource URI without secrets. +- extensions: namespaced/defined fields such as validation errors or correlation ID. + +The public `type` is owned by the API. Do not expose an internal class name or domain code automatically. + +## Central Boundary Mapper + +Use one framework-level middleware/filter/advice for ordinary formatting. Endpoint-local handling is reserved for endpoint-specific recovery. + +```typescript +function toProblem(error: unknown, requestId: string): ProblemDetails { + if (error instanceof OrderNotFound) { + return problem(404, "order-not-found", "Order not found", requestId); + } + if (error instanceof OrderAlreadyCancelled) { + return problem(409, "order-already-cancelled", "Order is already cancelled", requestId); + } + + safeExceptionLogger.capture(error, { requestId }); // non-throwing redaction adapter + return problem(500, "internal-error", "An unexpected error occurred", requestId); +} +``` + +The mapper must receive `unknown`, recognize known variants with real runtime guards/stable discriminants, and treat unmatched failures as unknown. Never cast a caught base error to a caller-selected union to manufacture exhaustiveness. + +## Redaction + +Construct public details from an allow-list. `error.message`, `Throwable.getMessage()`, and Go `err.Error()` are diagnostic by default, not public copy. + +Do not reflect over all enumerable error properties. A future password, token, rejected content body, internal ID, SQL fragment, or provider response can otherwise become public silently. + +Log unknown details server-side with redaction and correlation/trace ID. Return only a generic 500 body. + +## Status Policy + +Status selection depends on endpoint semantics and must be consistent: + +| Situation | Common choice | Notes | +|---|---|---| +| Malformed JSON/syntax | 400 | Parse failure at delivery boundary | +| Semantically invalid fields | 400 or 422 | Choose and document one policy | +| Missing/invalid authentication | 401 | Include appropriate challenge where required | +| Authenticated but forbidden | 403 | May intentionally hide resource existence | +| Target resource absent | 404 | Enumeration policy may alter response | +| Current-state/version conflict | 409 | Include safe recovery hints when useful | +| Rate limited | 429 | Include `Retry-After` when known | +| Unexpected failure | 500 | Generic body; log/trace internally | +| Dependency temporarily unavailable | 503 | Use only when the API can identify temporary unavailability | + +A unique-constraint violation becomes 409 only after the adapter recognizes and translates the intended business conflict. Never expose raw constraint text. + +## Validation + +Parse and validate untrusted JSON before domain construction. Distinguish malformed syntax from schema/semantic validation, cap body/list sizes, and derive actor identity from authentication rather than caller-supplied IDs. + +Return field-level errors only when safe. Prefer stable field codes over internal validator messages. Aggregate multiple independent field issues when it improves client correction; domain commands may still fail fast on invariant violations. + +## Exhaustiveness and Evolution + +Every declared application failure should have a contract test for its public mapping. Adding a variant should fail compilation/static analysis where possible or fail a mapping test. + +Version public problem types deliberately. Internal class renames must not alter the API. Document Problem Details schemas in OpenAPI and test `application/problem+json`. + +## Checklist + +- Correct status and `application/problem+json`? +- Stable API-owned `type`, not a class name? +- Public message/details explicitly allow-listed? +- Unknown failure logged with correlation ID and generic 500? +- Malformed JSON and validation mapped separately? +- Authentication-derived identity and authorization applied? +- Every expected application failure mapped and tested? +- No raw library/vendor messages or reflected error fields? diff --git a/skills/rest-api-best-practices/references/go-examples.md b/skills/rest-api-best-practices/references/go-examples.md new file mode 100644 index 0000000..adc6334 --- /dev/null +++ b/skills/rest-api-best-practices/references/go-examples.md @@ -0,0 +1,345 @@ +# Go Examples + +Examples using the standard `net/http` library and Chi router. + +## Route Definition and URL Structure + +```go +package main + +import ( + "net/http" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func NewRouter(h *OrderHandler) http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + + r.Route("/v1/orders", func(r chi.Router) { + r.Use(AuthMiddleware) // applied to all /v1/orders routes + + r.Get("/", h.List) // collection + r.Post("/", h.Create) // create + r.Get("/{id}", h.Get) // one resource + r.Patch("/{id}", h.Update) // partial update + r.Delete("/{id}", h.Delete) // remove + + // Sub-collection + r.Get("/{id}/items", h.ListItems) + + // Task-based URLs — domain operations + r.Post("/{id}/cancel", h.Cancel) + r.Post("/{id}/ship", h.Ship) + }) + + return r +} +``` + +## Status Codes in Responses + +```go +package handler + +import ( + "encoding/json" + "net/http" +) + +func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) { + var body CreateOrderRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + renderProblem(w, http.StatusBadRequest, "MALFORMED_REQUEST", "Invalid JSON body.") + return + } + + order, err := h.service.Create(r.Context(), body) + if err != nil { + renderDomainError(w, r, err) + return + } + + w.Header().Set("Location", "/v1/orders/"+order.ID) + renderJSON(w, http.StatusCreated, order) +} + +func (h *OrderHandler) Delete(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := h.service.Delete(r.Context(), id); err != nil { + renderDomainError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} +``` + +## Request Validation + +```go +package handler + +import ( + "errors" + "github.com/go-playground/validator/v10" +) + +var validate = validator.New() + +type OrderItemRequest struct { + ProductID string `json:"productId" validate:"required,uuid4"` + Quantity int `json:"quantity" validate:"required,gt=0"` +} + +type ShippingAddressRequest struct { + Street string `json:"street" validate:"required,min=1"` + City string `json:"city" validate:"required,min=1"` + PostalCode string `json:"postalCode" validate:"required"` + Country string `json:"country" validate:"required,len=2"` +} + +type CreateOrderRequest struct { + CustomerID string `json:"customerId" validate:"required,uuid4"` + Items []OrderItemRequest `json:"items" validate:"required,min=1,dive"` + ShippingAddress ShippingAddressRequest `json:"shippingAddress" validate:"required"` +} + +func decodeAndValidate[T any](r *http.Request) (T, []ValidationError, error) { + var body T + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + return body, nil, err + } + if err := validate.Struct(body); err != nil { + var ve validator.ValidationErrors + if errors.As(err, &ve) { + details := make([]ValidationError, len(ve)) + for i, e := range ve { + details[i] = ValidationError{Field: e.Field(), Message: e.Tag()} + } + return body, details, nil + } + } + return body, nil, nil +} +``` + +## Error Response Format (RFC 9457) + +```go +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +type ProblemDetails struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail"` + Errors []ValidationError `json:"errors,omitempty"` +} + +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func renderProblem(w http.ResponseWriter, status int, code, detail string) { + slug := strings.ToLower(strings.ReplaceAll(code, "_", "-")) + p := ProblemDetails{ + Type: fmt.Sprintf("https://api.example.com/errors/%s", slug), + Title: strings.ReplaceAll(code, "_", " "), + Status: status, + Detail: detail, + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(p) +} + +func renderJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} +``` + +## Domain Error Translation + +```go +package handler + +import ( + "errors" + "fmt" + "net/http" + "your/module/domain" + "your/module/middleware" +) + +func renderDomainError(w http.ResponseWriter, r *http.Request, err error) { + var notFound *domain.OrderNotFound + var alreadyCancelled *domain.OrderAlreadyCancelled + var insufficientStock *domain.InsufficientStock + + switch { + case errors.As(err, ¬Found): + renderProblem(w, http.StatusNotFound, "ORDER_NOT_FOUND", "Order not found.") + case errors.As(err, &alreadyCancelled): + renderProblem(w, http.StatusConflict, "ORDER_ALREADY_CANCELLED", "This order has already been cancelled.") + case errors.As(err, &insufficientStock): + renderProblem(w, http.StatusUnprocessableEntity, "INSUFFICIENT_STOCK", + fmt.Sprintf("Stock is short by %d units.", insufficientStock.Shortfall)) + default: + requestID := middleware.RequestID(r) + safeExceptionLogger.Capture(err, requestID) // redacts fields but preserves the cause chain + renderProblem(w, http.StatusInternalServerError, "INTERNAL_ERROR", "An unexpected error occurred.") + } +} +``` + +## Pagination + +```go +package handler + +import ( + "math" + "net/http" + "strconv" + "fmt" +) + +type PaginationParams struct { + Page int + PerPage int +} + +func parsePagination(r *http.Request) (PaginationParams, error) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page")) + if page < 1 { page = 1 } + if perPage < 1 { perPage = 25 } + if perPage > 100 { perPage = 100 } + return PaginationParams{Page: page, PerPage: perPage}, nil +} + +func (h *OrderHandler) List(w http.ResponseWriter, r *http.Request) { + p, _ := parsePagination(r) + offset := (p.Page - 1) * p.PerPage + + orders, total, err := h.repo.FindMany(r.Context(), offset, p.PerPage) + if err != nil { + renderProblem(w, http.StatusInternalServerError, "INTERNAL_ERROR", "An unexpected error occurred.") + return + } + + totalPages := int(math.Ceil(float64(total) / float64(p.PerPage))) + base := "/v1/orders" + + links := []string{ + fmt.Sprintf(`<%s?page=1&per_page=%d>; rel="first"`, base, p.PerPage), + fmt.Sprintf(`<%s?page=%d&per_page=%d>; rel="last"`, base, totalPages, p.PerPage), + } + if p.Page > 1 { + links = append(links, fmt.Sprintf(`<%s?page=%d&per_page=%d>; rel="prev"`, base, p.Page-1, p.PerPage)) + } + if p.Page < totalPages { + links = append(links, fmt.Sprintf(`<%s?page=%d&per_page=%d>; rel="next"`, base, p.Page+1, p.PerPage)) + } + + w.Header().Set("Link", strings.Join(links, ", ")) + w.Header().Set("X-Total-Count", strconv.Itoa(total)) + renderJSON(w, http.StatusOK, orders) +} +``` + +## Authentication Middleware (JWT) + +```go +package middleware + +import ( + "context" + "net/http" + "strings" + "github.com/golang-jwt/jwt/v5" +) + +type contextKey string +const userKey contextKey = "user" + +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + renderProblem(w, http.StatusUnauthorized, "UNAUTHORIZED", "Bearer token required.") + return + } + + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + token, err := jwt.Parse(tokenStr, keyFunc, jwt.WithValidMethods([]string{"RS256"})) + if err != nil || !token.Valid { + renderProblem(w, http.StatusUnauthorized, "UNAUTHORIZED", "Invalid or expired token.") + return + } + + claims := token.Claims.(jwt.MapClaims) + user := AuthenticatedUser{ID: claims["sub"].(string)} + ctx := context.WithValue(r.Context(), userKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func CurrentUser(r *http.Request) AuthenticatedUser { + return r.Context().Value(userKey).(AuthenticatedUser) +} +``` + +## Request ID Middleware + +```go +package middleware + +import ( + "net/http" + "github.com/go-chi/chi/v5/middleware" +) + +// chi/middleware.RequestID handles this automatically. +// Access it in handlers: +func RequestID(r *http.Request) string { + return middleware.GetReqID(r.Context()) +} + +// Ensure it's echoed in all responses via a wrapper: +func RequestIDHeader(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Request-ID", middleware.GetReqID(r.Context())) + next.ServeHTTP(w, r) + }) +} +``` + +## Task-Based Endpoint + +```go +func (h *OrderHandler) Cancel(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + user := middleware.CurrentUser(r) + + order, err := h.service.Cancel(r.Context(), id, user.ID) + if err != nil { + renderDomainError(w, r, err) + return + } + renderJSON(w, http.StatusOK, order) +} +``` diff --git a/skills/rest-api-best-practices/references/http-semantics.md b/skills/rest-api-best-practices/references/http-semantics.md new file mode 100644 index 0000000..e3d194b --- /dev/null +++ b/skills/rest-api-best-practices/references/http-semantics.md @@ -0,0 +1,168 @@ +# HTTP Semantics + +REST is not a framework — it is a set of constraints on how to use HTTP. Using HTTP correctly means its built-in semantics do work for you: caching, intermediaries, clients, and tooling all understand the protocol. + +## HTTP Methods + +| Method | Safe | Idempotent | Cacheable | Use for | +|--------|------|------------|-----------|---------| +| GET | ✅ | ✅ | ✅ | Retrieve a resource or collection | +| HEAD | ✅ | ✅ | ✅ | Check resource existence or metadata without body | +| OPTIONS | ✅ | ✅ | ❌ | Discover allowed methods (CORS preflight) | +| POST | ❌ | ❌ | ❌ | Create a resource; trigger a non-idempotent action | +| PUT | ❌ | ✅ | ❌ | Full replacement of a resource | +| PATCH | ❌ | ❌* | ❌ | Partial update of a resource | +| DELETE | ❌ | ✅ | ❌ | Remove a resource | + +**Safe** = the operation must not change server state — clients can call it freely without side effects. +**Idempotent** = calling it N times has the same effect as calling it once — safe to retry on network failure. +*PATCH can be made idempotent by design (e.g., `{ "set": { "status": "active" } }`), but is not idempotent by default. + +## Status Codes + +### 2xx — Success + +| Code | Name | When to use | +|------|------|-------------| +| 200 | OK | Successful GET, PUT, PATCH; DELETE with a response body | +| 201 | Created | Successful POST that created a resource. Always include `Location` header. | +| 202 | Accepted | Request accepted for async processing; result not yet available | +| 204 | No Content | Successful operation with no response body (DELETE, PUT/PATCH when body is not useful) | + +### 3xx — Redirection + +| Code | Name | When to use | +|------|------|-------------| +| 301 | Moved Permanently | Resource URL has permanently changed; include `Location` | +| 304 | Not Modified | Conditional GET; the cached version is still valid (ETag or Last-Modified match) | + +### 4xx — Client Error + +| Code | Name | When to use | +|------|------|-------------| +| 400 | Bad Request | Malformed request syntax, invalid JSON, unrecognized parameters | +| 401 | Unauthorized | No credentials provided, or credentials are invalid/expired | +| 403 | Forbidden | Credentials are valid but access to this resource is denied | +| 404 | Not Found | Resource does not exist | +| 405 | Method Not Allowed | HTTP method is not supported for this resource. Include `Allow` header. | +| 406 | Not Acceptable | Server cannot produce a response matching the `Accept` header | +| 409 | Conflict | State conflict: duplicate create, optimistic lock failure, illegal state transition | +| 410 | Gone | Resource existed but has been permanently deleted | +| 415 | Unsupported Media Type | Server cannot process the `Content-Type` of the request body | +| 422 | Unprocessable Entity | Request is syntactically valid but semantically invalid (domain/validation error) | +| 429 | Too Many Requests | Rate limit exceeded. Always include `Retry-After`. | + +### 5xx — Server Error + +| Code | Name | When to use | +|------|------|-------------| +| 500 | Internal Server Error | Unexpected server-side failure. Never leak internal details. | +| 502 | Bad Gateway | Upstream service returned an invalid response | +| 503 | Service Unavailable | Server temporarily unable to handle requests; include `Retry-After` | +| 504 | Gateway Timeout | Upstream service did not respond in time | + +## Key Request Headers + +| Header | Purpose | Example | +|--------|---------|---------| +| `Accept` | Content types the client can handle | `application/json` | +| `Authorization` | Credentials | `Bearer ` | +| `Content-Type` | Media type of the request body | `application/json` | +| `If-None-Match` | Conditional GET by ETag | `"abc123"` | +| `If-Modified-Since` | Conditional GET by date | `Wed, 21 Oct 2015 07:28:00 GMT` | +| `Idempotency-Key` | Retry safety for POST operations | `a1b2-c3d4-e5f6` | + +## Key Response Headers + +| Header | Purpose | +|--------|---------| +| `Content-Type` | Media type of the response body | +| `Location` | URL of the created resource (after 201) or redirect target | +| `Link` | Related resource URLs; pagination navigation | +| `ETag` | Opaque identifier for caching and conditional requests | +| `Last-Modified` | Timestamp for caching and conditional requests | +| `Cache-Control` | Caching directives | +| `X-RateLimit-Limit` | Max requests allowed in current window | +| `X-RateLimit-Remaining` | Requests remaining in current window | +| `X-RateLimit-Reset` | Epoch seconds when the window resets | +| `Retry-After` | Seconds to wait before retrying (429, 503) | +| `Allow` | Methods allowed on this resource (405 responses) | +| `Deprecation` | Date this endpoint version is deprecated | +| `Sunset` | Date this endpoint version will be removed | + +## Idempotency Keys + +For non-idempotent POST operations where the client needs retry safety (e.g., payment creation): + +``` +POST /payments +Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +Content-Type: application/json + +{ "amount": 99.90, "currency": "EUR" } +``` + +The server stores the result keyed by the idempotency key. Retries with the same key return the stored result without re-executing the operation. Use UUIDs generated by the client. + +## Caching + +``` +# Public, cacheable for 5 minutes (CDN + browser) +Cache-Control: public, max-age=300 + +# Private (user-specific), not shared-cacheable +Cache-Control: private, max-age=60 + +# No caching at all +Cache-Control: no-store + +# Revalidate with server before using cached copy +Cache-Control: no-cache +``` + +### ETag Validation + +``` +# Server sends ETag with initial response +HTTP/1.1 200 OK +ETag: "d3b07384" +Content-Type: application/json + +{ ... } + +# Client sends ETag on next request +GET /users/123 +If-None-Match: "d3b07384" + +# Server: unchanged → 304, no body +HTTP/1.1 304 Not Modified + +# Server: changed → 200 with new ETag +HTTP/1.1 200 OK +ETag: "b026324c" +``` + +Use ETags for GET responses on resources that change infrequently. They reduce bandwidth and server load significantly. + +## Content Negotiation + +The client requests a format; the server confirms or rejects it: + +``` +# Client requests JSON +GET /users/123 +Accept: application/json + +# Server responds with JSON +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 + +# Client requests a format the server does not support +GET /users/123 +Accept: application/xml + +# Server cannot comply +HTTP/1.1 406 Not Acceptable +``` + +Default to `application/json`. Only add other formats (`text/csv`, `application/xml`) if there is a clear consumer requirement. diff --git a/skills/rest-api-best-practices/references/java-examples.md b/skills/rest-api-best-practices/references/java-examples.md new file mode 100644 index 0000000..2f42f1a --- /dev/null +++ b/skills/rest-api-best-practices/references/java-examples.md @@ -0,0 +1,325 @@ +# Java Examples + +Examples using Spring Boot 3.x with Spring Web MVC. + +## Route Definition and URL Structure + +```java +package com.example.orders.api; + +import org.springframework.web.bind.annotation.*; +import org.springframework.http.*; + +@RestController +@RequestMapping("/v1/orders") +public class OrderController { + + @GetMapping public ResponseEntity> list(...) {} + @PostMapping public ResponseEntity create(...) {} + @GetMapping("/{id}") public ResponseEntity get(...) {} + @PatchMapping("/{id}") public ResponseEntity update(...) {} + @DeleteMapping("/{id}") public ResponseEntity delete(...) {} + + // Sub-collection + @GetMapping("/{id}/items") public ResponseEntity> listItems(...) {} + + // Task-based URLs — domain operations + @PostMapping("/{id}/cancel") public ResponseEntity cancel(...) {} + @PostMapping("/{id}/ship") public ResponseEntity ship(...) {} +} +``` + +## Status Codes in Responses + +```java +@PostMapping +public ResponseEntity create( + @RequestBody @Valid CreateOrderRequest body, + UriComponentsBuilder uriBuilder +) { + Order order = orderService.create(body); + URI location = uriBuilder.path("/v1/orders/{id}").buildAndExpand(order.getId()).toUri(); + + return ResponseEntity + .created(location) // 201 Created + Location header + .body(OrderResponse.from(order)); +} + +@DeleteMapping("/{id}") +public ResponseEntity delete(@PathVariable String id) { + orderService.delete(id); + return ResponseEntity.noContent().build(); // 204 No Content +} + +@PostMapping("/{id}/cancel") +public ResponseEntity cancel(@PathVariable String id, Authentication auth) { + Order order = orderService.cancel(id, auth.getName()); + return ResponseEntity.ok(OrderResponse.from(order)); // 200 OK +} +``` + +## Request Validation (Bean Validation) + +```java +import jakarta.validation.constraints.*; +import jakarta.validation.Valid; + +public class CreateOrderRequest { + + @NotNull @Pattern(regexp = "^[0-9a-f-]{36}$") + private String customerId; + + @NotNull @Size(min = 1, message = "must contain at least one item") + @Valid + private List items; + + @NotNull @Valid + private ShippingAddressRequest shippingAddress; +} + +public class OrderItemRequest { + @NotNull @Pattern(regexp = "^[0-9a-f-]{36}$") + private String productId; + + @NotNull @Min(1) + private Integer quantity; +} + +public class ShippingAddressRequest { + @NotBlank private String street; + @NotBlank private String city; + @NotBlank private String postalCode; + @NotBlank @Size(min = 2, max = 2) private String country; +} + +// Spring validates automatically when @Valid is present on @RequestBody. +// MethodArgumentNotValidException → handled in exception handler below. +``` + +## Error Response Format (RFC 9457) + +```java +package com.example.orders.api.error; + +import java.util.List; +import org.springframework.http.MediaType; +import org.slf4j.MDC; + +public record ProblemDetails( + String type, + String title, + int status, + String detail, + List errors +) { + public record FieldError(String field, String message) {} + + public static ProblemDetails of(int status, String code, String detail) { + String slug = code.toLowerCase().replace('_', '-'); + return new ProblemDetails( + "https://api.example.com/errors/" + slug, + code.replace('_', ' '), + status, + detail, + null + ); + } +} + +// Global exception handler +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static String publicValidationMessage(String code) { + return switch (code) { + case "NotNull", "NotBlank" -> "Required field."; + case "Size" -> "Invalid length."; + default -> "Invalid value."; + }; + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + List errors = ex.getBindingResult() + .getFieldErrors() + .stream() + .map(e -> new ProblemDetails.FieldError(e.getField(), publicValidationMessage(e.getCode()))) + .toList(); + + ProblemDetails body = new ProblemDetails( + "https://api.example.com/errors/validation-failed", + "Validation Failed", + 422, + "The request body contains fields that failed validation.", + errors + ); + return ResponseEntity.unprocessableEntity().contentType(MediaType.APPLICATION_PROBLEM_JSON).body(body); + } + + @ExceptionHandler(OrderNotFoundException.class) + public ResponseEntity handleOrderNotFound(OrderNotFoundException ex) { + return ResponseEntity.status(404) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(ProblemDetails.of(404, "ORDER_NOT_FOUND", "Order not found.")); + } + + @ExceptionHandler(OrderAlreadyCancelledException.class) + public ResponseEntity handleAlreadyCancelled(OrderAlreadyCancelledException ex) { + return ResponseEntity.status(409) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(ProblemDetails.of(409, "ORDER_ALREADY_CANCELLED", "This order has already been cancelled.")); + } + + @ExceptionHandler(InsufficientStockException.class) + public ResponseEntity handleInsufficientStock(InsufficientStockException ex) { + return ResponseEntity.status(422) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(ProblemDetails.of(422, "INSUFFICIENT_STOCK", + "Stock is short by " + ex.getShortfall() + " units.")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnknown(Exception ex) { + String requestId = MDC.get("requestId"); + safeExceptionLogger.capture(ex, requestId); + return ResponseEntity.internalServerError() + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(ProblemDetails.of(500, "INTERNAL_ERROR", "An unexpected error occurred.")); + } +} +``` + +## Pagination + +```java +import org.springframework.data.domain.*; +import org.springframework.data.web.PageableDefault; + +@GetMapping +public ResponseEntity> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(name = "per_page", defaultValue = "25") int perPage, + HttpServletResponse response +) { + perPage = Math.min(perPage, 100); + int offset = (page - 1) * perPage; + + PageResult result = orderRepo.findMany(offset, perPage); + int totalPages = (int) Math.ceil((double) result.total() / perPage); + String base = "/v1/orders"; + + List links = new ArrayList<>(); + links.add(String.format("<%s?page=1&per_page=%d>; rel=\"first\"", base, perPage)); + links.add(String.format("<%s?page=%d&per_page=%d>; rel=\"last\"", base, totalPages, perPage)); + if (page > 1) links.add(String.format("<%s?page=%d&per_page=%d>; rel=\"prev\"", base, page - 1, perPage)); + if (page < totalPages) links.add(String.format("<%s?page=%d&per_page=%d>; rel=\"next\"", base, page + 1, perPage)); + + response.setHeader("Link", String.join(", ", links)); + response.setHeader("X-Total-Count", String.valueOf(result.total())); + + return ResponseEntity.ok(result.items()); +} +``` + +## Filtering and Sorting + +```java +@GetMapping +public ResponseEntity> list( + @RequestParam(required = false) OrderStatus status, + @RequestParam(required = false) String customerId, + @RequestParam(defaultValue = "createdAt") String sort, + @RequestParam(defaultValue = "desc") String order, + @RequestParam(defaultValue = "1") int page, + @RequestParam(name = "per_page", defaultValue = "25") int perPage +) { + // Validate sort field against allowlist + if (!ALLOWED_SORT_FIELDS.contains(sort)) { + throw new InvalidParameterException("sort", "must be one of: " + ALLOWED_SORT_FIELDS); + } + ... +} + +private static final Set ALLOWED_SORT_FIELDS = Set.of("createdAt", "total", "status"); +``` + +## Authentication (Spring Security + JWT) + +```java +// SecurityConfig.java +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/health/**").permitAll() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.decoder(jwtDecoder())) + ) + .build(); + } +} + +// Controller — access authenticated user +@PostMapping("/{id}/cancel") +public ResponseEntity cancel( + @PathVariable String id, + @AuthenticationPrincipal Jwt jwt +) { + String userId = jwt.getSubject(); + Order order = orderService.cancel(id, userId); + return ResponseEntity.ok(OrderResponse.from(order)); +} +``` + +## Request ID Filter + +```java +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import org.springframework.stereotype.Component; +import java.util.UUID; + +@Component +public class RequestIdFilter implements Filter { + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + + String upstreamRequestId = request.getHeader("X-Request-ID"); // validate/log separately if needed + String requestId = UUID.randomUUID().toString(); + + response.setHeader("X-Request-ID", requestId); + + // Put in MDC for structured logging + try (var ignored = MDC.putCloseable("requestId", requestId)) { + chain.doFilter(req, res); + } + } +} +``` + +## Task-Based Endpoint + +```java +@PostMapping("/{id}/cancel") +public ResponseEntity cancel( + @PathVariable String id, + @AuthenticationPrincipal Jwt jwt +) { + // Domain exceptions propagate to @RestControllerAdvice + Order order = orderService.cancel(id, jwt.getSubject()); + return ResponseEntity.ok(OrderResponse.from(order)); +} +``` diff --git a/skills/rest-api-best-practices/references/language-examples.md b/skills/rest-api-best-practices/references/language-examples.md new file mode 100644 index 0000000..d8cbd2c --- /dev/null +++ b/skills/rest-api-best-practices/references/language-examples.md @@ -0,0 +1,52 @@ +# Language Examples + +Use this file as an index to language-specific REST API example files. + +## Covered Languages + +- `references/typescript-examples.md` — TypeScript with Express and NestJS +- `references/python-examples.md` — Python with FastAPI and Django REST Framework +- `references/go-examples.md` — Go with net/http and Chi +- `references/php-examples.md` — PHP with Laravel and Symfony +- `references/java-examples.md` — Java with Spring Boot 3.x +- `references/rust-examples.md` — Rust with axum + +## Shared Concept Set + +Each language file covers the same set of implementation patterns: + +- Route definition and URL structure +- HTTP method handlers (GET, POST, PATCH, DELETE) +- Status code responses (200, 201, 204, 4xx, 5xx) +- Request body validation +- Error response format (RFC 9457 Problem Details or custom envelope) +- Pagination (offset-based with Link header) +- Filtering and sorting via query parameters +- Authentication middleware (JWT Bearer) +- Domain error translation at the API boundary +- Task-based endpoints +- Request ID middleware (`X-Request-ID`) + +## How to Use + +- Start with the language file that matches the user's stack. +- For deeper design questions, consult: + - `url-design.md` — URL naming, versioning, backward compatibility, task-based URLs + - `http-semantics.md` — method semantics, status codes, headers + - `request-response-design.md` — filtering, pagination, nested structure guidance + - `error-handling.md` — error formats, domain error mapping + - `security.md` — auth, rate limiting, CORS + - `api-design-philosophy.md` — REST constraints, CRUD anti-pattern, HATEOAS + - `observability.md` — request IDs, logging, metrics, health endpoints + - `documentation.md` — OpenAPI, working examples, contract testing + +## Quick Reference + +| Language | Framework | Validation | Auth | +|------------|----------------------|--------------------------|-----------------------------| +| TypeScript | Express, NestJS | Zod, class-validator | jsonwebtoken, Passport | +| Python | FastAPI, DRF | Pydantic, DRF Serializer | python-jose, djangorestframework-simplejwt | +| Go | net/http, Chi | go-playground/validator | golang-jwt/jwt | +| PHP | Laravel, Symfony | Form Request, Assert | Sanctum, LexikJWTBundle | +| Java | Spring Boot 3.x | Bean Validation (Jakarta)| Spring Security + OAuth2 | +| Rust | axum | validator crate | jsonwebtoken | diff --git a/skills/rest-api-best-practices/references/observability.md b/skills/rest-api-best-practices/references/observability.md new file mode 100644 index 0000000..67dd27a --- /dev/null +++ b/skills/rest-api-best-practices/references/observability.md @@ -0,0 +1,107 @@ +# Observability + +A reliable API is an observable API. Without logs, metrics, and request tracing, diagnosing production issues requires guesswork. + +## Request IDs + +Include a unique identifier in every response. Clients include it when reporting issues; your logging pipeline uses it to correlate all events for that request across services. + +``` +# Server generates and returns the ID +X-Request-ID: f47ac10b-58cc-4372-a567-0e02b2c3d479 + +# If the client sends one, echo it back — allows end-to-end tracing +X-Request-ID: client-provided-uuid +``` + +Rules: +- Generate a UUID v4 server-side if the client does not provide one. +- Propagate the request ID to all downstream service calls (pass it in internal request headers). +- Include it in every log line for the duration of the request. +- Never trust a client-provided ID for security purposes — only use it for correlation. + +## What to Log + +Log at the structured (JSON) level, not as unformatted strings. Every request log entry should include: + +```json +{ + "requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "method": "POST", + "path": "/v1/orders", + "statusCode": 422, + "durationMs": 34, + "userId": "usr_01J8X", + "userAgent": "MyApp/2.1 (iOS 17)", + "ip": "203.0.113.5" +} +``` + +Never log: +- Request bodies that may contain passwords, tokens, or PII. +- Authorization header values. +- Full credit card numbers or payment data. +- Raw user-supplied strings without sanitization. + +## Metrics to Track + +| Metric | Why it matters | +|--------|---------------| +| Request rate (rpm) | Traffic baseline; spike detection | +| Error rate (4xx, 5xx %) | API health; regression detection | +| Latency (p50, p95, p99) | Performance SLO tracking | +| Endpoint-level breakdown | Identify slow or error-prone routes | +| Rate limit hit rate | Signal capacity or abuse issues | + +Set alerts on error rate spikes (e.g., 5xx > 1% of requests) and latency degradation (p99 > threshold). + +## Distributed Tracing + +In microservice environments, propagate trace context using standard headers: + +``` +# W3C Trace Context (standard) +traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 + +# Or OpenTelemetry / Jaeger / Zipkin B3 headers +X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736 +X-B3-SpanId: 00f067aa0ba902b7 +``` + +Propagating trace IDs lets you reconstruct the full call graph when a request touches multiple services. + +## Health Endpoints + +Expose at minimum two health endpoints — not versioned, never authenticated: + +``` +GET /health → liveness: is the process running? +GET /health/ready → readiness: is the service ready to receive traffic? +``` + +Liveness response (200 or 503): +```json +{ "status": "ok" } +``` + +Readiness response — include dependency checks: +```json +{ + "status": "ok", + "checks": { + "database": "ok", + "redis": "ok", + "stripe": "degraded" + } +} +``` + +Return 503 Service Unavailable when the service is not ready. Load balancers and orchestrators (Kubernetes) use readiness to route traffic. + +## Alerting Checklist + +- 5xx rate > 1% sustained for 2 minutes → page on-call +- p99 latency > SLO threshold → page on-call +- Rate limit saturation > 80% → notify team +- Upstream dependency health check failing → notify team +- Zero requests in a window where traffic is expected → page on-call (dead man's switch) diff --git a/skills/rest-api-best-practices/references/php-examples.md b/skills/rest-api-best-practices/references/php-examples.md new file mode 100644 index 0000000..1fdbb60 --- /dev/null +++ b/skills/rest-api-best-practices/references/php-examples.md @@ -0,0 +1,304 @@ +# PHP Examples + +Examples using Laravel (primary) and Symfony (secondary). + +## Route Definition and URL Structure + +```php +// routes/api.php — Laravel +use Illuminate\Support\Facades\Route; +use App\Http\Controllers\OrderController; + +Route::prefix('v1')->middleware('auth:sanctum')->group(function () { + // Collection and resource routes + Route::get('orders', [OrderController::class, 'index']); + Route::post('orders', [OrderController::class, 'store']); + Route::get('orders/{id}', [OrderController::class, 'show']); + Route::patch('orders/{id}', [OrderController::class, 'update']); + Route::delete('orders/{id}', [OrderController::class, 'destroy']); + + // Sub-collection + Route::get('orders/{id}/items', [OrderController::class, 'items']); + + // Task-based URLs — domain operations + Route::post('orders/{id}/cancel', [OrderController::class, 'cancel']); + Route::post('orders/{id}/ship', [OrderController::class, 'ship']); +}); + + +// Symfony — config/routes.yaml +// Or via annotations/attributes in the controller: +#[Route('/v1/orders', name: 'orders_')] +class OrderController extends AbstractController +{ + #[Route('', name: 'list', methods: ['GET'])] + #[Route('', name: 'create', methods: ['POST'])] + #[Route('/{id}', name: 'show', methods: ['GET'])] + #[Route('/{id}', name: 'update', methods: ['PATCH'])] + #[Route('/{id}', name: 'delete', methods: ['DELETE'])] + #[Route('/{id}/cancel', name: 'cancel', methods: ['POST'])] +} +``` + +## Status Codes in Responses + +```php +// Laravel — using response helpers +class OrderController extends Controller +{ + public function store(CreateOrderRequest $request): JsonResponse + { + $order = $this->orderService->create($request->validated()); + + return response()->json($order->toArray(), 201) + ->header('Location', "/v1/orders/{$order->id}"); + } + + public function destroy(string $id): JsonResponse + { + $this->orderService->delete($id); + return response()->noContent(); // 204 + } + + public function cancel(string $id): JsonResponse + { + $order = $this->orderService->cancel($id, auth()->id()); + return response()->json($order->toArray(), 200); + } +} +``` + +## Request Validation + +```php +// Laravel Form Request — validation runs before the controller method +namespace App\Http\Requests; + +use Illuminate\Foundation\Http\FormRequest; + +class CreateOrderRequest extends FormRequest +{ + public function rules(): array + { + return [ + 'customer_id' => ['required', 'uuid'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_id' => ['required', 'uuid'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'shipping_address.street' => ['required', 'string'], + 'shipping_address.city' => ['required', 'string'], + 'shipping_address.postal_code' => ['required', 'string'], + 'shipping_address.country' => ['required', 'string', 'size:2'], + ]; + } +} + +// Laravel returns 422 automatically with all errors when validation fails: +// { +// "message": "The given data was invalid.", +// "errors": { +// "items": ["The items field must contain at least 1 items."], +// "shipping_address.country": ["The shipping address.country must be 2 characters."] +// } +// } + +// To use RFC 9457 Problem Details, override failedValidation: +protected function failedValidation(Validator $validator) +{ + $errors = collect($validator->errors()->toArray()) + ->map(fn ($_messages, $field) => ['field' => $field, 'message' => 'Invalid value.']) + ->values() + ->all(); + + throw new HttpResponseException(response()->json([ + 'type' => 'https://api.example.com/errors/validation-failed', + 'title' => 'Validation Failed', + 'status' => 422, + 'detail' => 'The request body contains fields that failed validation.', + 'errors' => $errors, + ], 422, ['Content-Type' => 'application/problem+json'])); +} +``` + +## Error Response Format (RFC 9457) + +```php +// app/Exceptions/Handler.php — Laravel +namespace App\Exceptions; + +use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Symfony\Component\HttpFoundation\Response; + +class Handler extends ExceptionHandler +{ + public function render($request, Throwable $e): Response + { + if ($request->expectsJson()) { + return $this->handleApiException($request, $e); + } + return parent::render($request, $e); + } + + private function handleApiException($request, Throwable $e): JsonResponse + { + if ($e instanceof OrderNotFoundException) { + return $this->problem(404, 'ORDER_NOT_FOUND', 'Order not found.'); + } + if ($e instanceof OrderAlreadyCancelledException) { + return $this->problem(409, 'ORDER_ALREADY_CANCELLED', 'This order has already been cancelled.'); + } + if ($e instanceof InsufficientStockException) { + return $this->problem(422, 'INSUFFICIENT_STOCK', "Stock is short by {$e->shortfall} units."); + } + + app(SafeExceptionLogger::class)->capture($e, $request->attributes->get('request_id')); + return $this->problem(500, 'INTERNAL_ERROR', 'An unexpected error occurred.'); + } + + private function problem(int $status, string $code, string $detail): JsonResponse + { + $slug = strtolower(str_replace('_', '-', $code)); + return response()->json([ + 'type' => "https://api.example.com/errors/{$slug}", + 'title' => str_replace('_', ' ', $code), + 'status' => $status, + 'detail' => $detail, + ], $status, ['Content-Type' => 'application/problem+json']); + } +} +``` + +## Pagination + +```php +// Laravel built-in pagination +class OrderController extends Controller +{ + public function index(Request $request): JsonResponse + { + $perPage = min($request->integer('per_page', 25), 100); + + $paginator = Order::query() + ->when($request->status, fn ($q, $v) => $q->where('status', $v)) + ->orderBy($request->get('sort', 'created_at'), $request->get('order', 'desc')) + ->paginate($perPage); + + // Build Link header + $links = []; + if ($paginator->onFirstPage() === false) { + $links[] = "<{$paginator->previousPageUrl()}>; rel=\"prev\""; + } + if ($paginator->hasMorePages()) { + $links[] = "<{$paginator->nextPageUrl()}>; rel=\"next\""; + } + $links[] = "<{$paginator->url(1)}>; rel=\"first\""; + $links[] = "<{$paginator->url($paginator->lastPage())}>; rel=\"last\""; + + return response()->json([ + 'data' => $paginator->items(), + 'meta' => [ + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + ], + ])->header('Link', implode(', ', $links)) + ->header('X-Total-Count', $paginator->total()); + } +} +``` + +## Filtering and Sorting + +```php +class OrderController extends Controller +{ + public function index(Request $request): JsonResponse + { + $request->validate([ + 'status' => ['nullable', 'in:pending,confirmed,shipped,cancelled'], + 'customer_id' => ['nullable', 'uuid'], + 'sort' => ['nullable', 'in:created_at,total,status'], + 'order' => ['nullable', 'in:asc,desc'], + ]); + + $orders = Order::query() + ->when($request->status, fn ($q, $v) => $q->where('status', $v)) + ->when($request->customer_id, fn ($q, $v) => $q->where('customer_id', $v)) + ->orderBy($request->get('sort', 'created_at'), $request->get('order', 'desc')) + ->paginate(min($request->integer('per_page', 25), 100)); + + return response()->json($orders); + } +} +``` + +## Authentication Middleware (Sanctum / JWT) + +```php +// Laravel Sanctum — stateless API tokens +// routes/api.php +Route::middleware('auth:sanctum')->group(function () { + Route::get('orders', [OrderController::class, 'index']); +}); + +// Controller — access authenticated user +public function store(CreateOrderRequest $request): JsonResponse +{ + $user = $request->user(); // injected by Sanctum + $order = $this->orderService->create($request->validated(), $user->id); + ... +} + +// Return 401 when unauthenticated — override in app/Exceptions/Handler.php: +protected function unauthenticated($request, AuthenticationException $exception) +{ + return response()->json([ + 'type' => 'https://api.example.com/errors/unauthorized', + 'title' => 'Unauthorized', + 'status' => 401, + 'detail' => 'Authentication is required to access this resource.', + ], 401, ['Content-Type' => 'application/problem+json']); +} +``` + +## Request ID Middleware + +```php +namespace App\Http\Middleware; + +use Closure; +use Illuminate\Support\Str; + +class RequestIdMiddleware +{ + public function handle($request, Closure $next) + { + $upstreamRequestId = $request->header('X-Request-ID'); // log separately after validation if needed + $requestId = Str::uuid()->toString(); + $request->headers->set('X-Request-ID', $requestId); + $request->attributes->set('request_id', $requestId); + + $response = $next($request); + $response->headers->set('X-Request-ID', $requestId); + + return $response; + } +} + +// Register in app/Http/Kernel.php: +protected $middleware = [ + \App\Http\Middleware\RequestIdMiddleware::class, +]; +``` + +## Task-Based Endpoint + +```php +public function cancel(string $id, Request $request): JsonResponse +{ + // Domain exception → caught by Handler::render() above + $order = $this->orderService->cancel($id, $request->user()->id); + + return response()->json(OrderResource::make($order), 200); +} +``` diff --git a/skills/rest-api-best-practices/references/python-examples.md b/skills/rest-api-best-practices/references/python-examples.md new file mode 100644 index 0000000..9f32f0d --- /dev/null +++ b/skills/rest-api-best-practices/references/python-examples.md @@ -0,0 +1,352 @@ +# Python Examples + +Examples using FastAPI (primary) and Django REST Framework (secondary). + +## Route Definition and URL Structure + +```python +from fastapi import APIRouter + +router = APIRouter(prefix="/v1") + +# Collection and resource routes +router.get("/orders")(list_orders) +router.post("/orders")(create_order) +router.get("/orders/{order_id}")(get_order) +router.patch("/orders/{order_id}")(update_order) +router.delete("/orders/{order_id}")(delete_order) + +# Sub-collection +router.get("/orders/{order_id}/items")(list_order_items) + +# Task-based URLs — domain operations +router.post("/orders/{order_id}/cancel")(cancel_order) +router.post("/orders/{order_id}/ship")(ship_order) + + +# Django REST Framework — ViewSet +from rest_framework.routers import DefaultRouter +from rest_framework.decorators import action + +router = DefaultRouter() +router.register(r"orders", OrderViewSet, basename="order") + +class OrderViewSet(ViewSet): + def list(self, request): ... + def create(self, request): ... + def retrieve(self, request, pk): ... + def partial_update(self, request, pk): ... + def destroy(self, request, pk): ... + + @action(detail=True, methods=["post"], url_path="cancel") + def cancel(self, request, pk): ... +``` + +## Status Codes in Responses + +```python +from fastapi import status +from fastapi.responses import JSONResponse, Response + +# 200 OK — default for GET +@router.get("/orders/{order_id}") +async def get_order(order_id: str) -> OrderResponse: + return order # FastAPI serializes and returns 200 + +# 201 Created — POST that creates a resource +@router.post("/orders", status_code=status.HTTP_201_CREATED) +async def create_order( + body: CreateOrderRequest, + response: Response, +) -> OrderResponse: + order = await order_service.create(body) + response.headers["Location"] = f"/v1/orders/{order.id}" + return order + +# 204 No Content — DELETE with no body +@router.delete("/orders/{order_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_order(order_id: str) -> None: + await order_service.delete(order_id) + +# 202 Accepted — async operation +@router.post("/orders/{order_id}/cancel", status_code=status.HTTP_202_ACCEPTED) +async def cancel_order(order_id: str) -> dict: + job = await order_service.queue_cancel(order_id) + return {"jobId": job.id, "message": "Cancellation queued."} +``` + +## Request Validation (Pydantic) + +```python +from pydantic import BaseModel, EmailStr, field_validator, UUID4 + +class OrderItemRequest(BaseModel): + product_id: UUID4 + quantity: int + + @field_validator("quantity") + @classmethod + def quantity_must_be_positive(cls, v: int) -> int: + if v <= 0: + raise ValueError("must be greater than 0") + return v + +class ShippingAddressRequest(BaseModel): + street: str + city: str + postal_code: str + country: str # ISO 3166-1 alpha-2 + +class CreateOrderRequest(BaseModel): + customer_id: UUID4 + items: list[OrderItemRequest] + shipping_address: ShippingAddressRequest + + @field_validator("items") + @classmethod + def items_must_not_be_empty(cls, v: list) -> list: + if not v: + raise ValueError("must contain at least one item") + return v + + +# FastAPI validates automatically — 422 returned on failure +@router.post("/orders", status_code=201) +async def create_order(body: CreateOrderRequest) -> OrderResponse: + ... +``` + +## Error Response Format (RFC 9457) + +```python +from pydantic import BaseModel +from typing import Optional +from fastapi import Request +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError + +class ErrorDetail(BaseModel): + field: str + message: str + +class ProblemDetails(BaseModel): + type: str + title: str + status: int + detail: str + errors: Optional[list[ErrorDetail]] = None + +def problem(status: int, code: str, detail: str) -> ProblemDetails: + slug = code.lower().replace("_", "-") + return ProblemDetails( + type=f"https://api.example.com/errors/{slug}", + title=code.replace("_", " ").title(), + status=status, + detail=detail, + ) + +def public_validation_message(error_type: str) -> str: + return { + "missing": "Required field.", + "string_too_short": "Value is too short.", + "greater_than": "Value is below the allowed minimum.", + }.get(error_type, "Invalid value.") + +# Override FastAPI's default 422 handler to use RFC 9457 Problem Details +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + errors = [ + ErrorDetail( + field=".".join(str(loc) for loc in e["loc"][1:]), + message=public_validation_message(e["type"]), + ) + for e in exc.errors() + ] + body = ProblemDetails( + type="https://api.example.com/errors/validation-failed", + title="Validation Failed", + status=422, + detail="The request body contains fields that failed validation.", + errors=errors, + ) + return JSONResponse(status_code=422, content=body.model_dump(), media_type="application/problem+json") +``` + +## Domain Error Translation + +```python +from fastapi import Request +from fastapi.responses import JSONResponse + +# Domain errors — no HTTP knowledge +class OrderNotFound(Exception): + def __init__(self, order_id: str): + self.order_id = order_id + +class OrderAlreadyCancelled(Exception): ... +class InsufficientStock(Exception): + def __init__(self, shortfall: int): + self.shortfall = shortfall + +# Global exception handlers — one place, all errors +@app.exception_handler(OrderNotFound) +async def order_not_found_handler(request: Request, exc: OrderNotFound) -> JSONResponse: + body = problem(404, "ORDER_NOT_FOUND", "Order not found.") + return JSONResponse(status_code=404, content=body.model_dump(), media_type="application/problem+json") + +@app.exception_handler(OrderAlreadyCancelled) +async def order_already_cancelled_handler(request: Request, exc: OrderAlreadyCancelled) -> JSONResponse: + body = problem(409, "ORDER_ALREADY_CANCELLED", "This order has already been cancelled.") + return JSONResponse(status_code=409, content=body.model_dump(), media_type="application/problem+json") + +@app.exception_handler(InsufficientStock) +async def insufficient_stock_handler(request: Request, exc: InsufficientStock) -> JSONResponse: + body = problem(422, "INSUFFICIENT_STOCK", f"Stock is short by {exc.shortfall} units.") + return JSONResponse(status_code=422, content=body.model_dump(), media_type="application/problem+json") + +@app.exception_handler(Exception) +async def unknown_error_handler(request: Request, exc: Exception) -> JSONResponse: + request_id = request.state.request_id # generated by server middleware + safe_exception_logger.capture(exc, request_id=request_id) + body = problem(500, "INTERNAL_ERROR", "An unexpected error occurred.") + return JSONResponse(status_code=500, content=body.model_dump(), media_type="application/problem+json") +``` + +## Pagination + +```python +from pydantic import BaseModel, Field +from typing import TypeVar, Generic + +T = TypeVar("T") + +class PaginationParams(BaseModel): + page: int = Field(default=1, ge=1) + per_page: int = Field(default=25, ge=1, le=100) + +class PaginatedResponse(BaseModel, Generic[T]): + data: list[T] + meta: dict + +@router.get("/orders") +async def list_orders( + pagination: Annotated[PaginationParams, Query()], + response: Response, +) -> PaginatedResponse[OrderSummary]: + offset = (pagination.page - 1) * pagination.per_page + orders, total = await order_repo.find_many(offset=offset, limit=pagination.per_page) + total_pages = math.ceil(total / pagination.per_page) + base = "/v1/orders" + + links = [f'<{base}?page=1&per_page={pagination.per_page}>; rel="first"'] + links.append(f'<{base}?page={total_pages}&per_page={pagination.per_page}>; rel="last"') + if pagination.page > 1: + links.append(f'<{base}?page={pagination.page - 1}&per_page={pagination.per_page}>; rel="prev"') + if pagination.page < total_pages: + links.append(f'<{base}?page={pagination.page + 1}&per_page={pagination.per_page}>; rel="next"') + + response.headers["Link"] = ", ".join(links) + response.headers["X-Total-Count"] = str(total) + + return PaginatedResponse( + data=orders, + meta={"total": total, "page": pagination.page, "perPage": pagination.per_page}, + ) +``` + +## Filtering and Sorting + +```python +from enum import Enum +from typing import Optional +from fastapi import Query + +class OrderStatus(str, Enum): + pending = "pending" + confirmed = "confirmed" + shipped = "shipped" + cancelled = "cancelled" + +class OrderSortField(str, Enum): + created_at = "created_at" + total = "total" + status = "status" + +@router.get("/orders") +async def list_orders( + status: Optional[OrderStatus] = Query(None), + customer_id: Optional[UUID4] = Query(None), + sort: OrderSortField = Query(OrderSortField.created_at), + order: Literal["asc", "desc"] = Query("desc"), + pagination: Annotated[PaginationParams, Query()] = ..., +) -> PaginatedResponse[OrderSummary]: + filters = OrderFilters(status=status, customer_id=customer_id) + ... +``` + +## Authentication Middleware (JWT) + +```python +from fastapi import Depends, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import jwt + +security = HTTPBearer() + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> AuthenticatedUser: + try: + payload = jwt.decode( + credentials.credentials, + settings.JWT_PUBLIC_KEY, + algorithms=["RS256"], + audience=settings.JWT_AUDIENCE, + ) + return AuthenticatedUser(id=payload["sub"], roles=payload.get("roles", [])) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token has expired.") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token.") + +# Apply to protected routes +@router.get("/orders") +async def list_orders( + current_user: AuthenticatedUser = Depends(get_current_user), +) -> PaginatedResponse[OrderSummary]: + ... +``` + +## Request ID Middleware + +```python +import uuid +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +class RequestIdMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + upstream_request_id = request.headers.get("X-Request-ID") # validate/log separately if needed + request_id = str(uuid.uuid4()) + request.state.request_id = request_id + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + +app.add_middleware(RequestIdMiddleware) +``` + +## Task-Based Endpoint + +```python +@router.post("/orders/{order_id}/cancel") +async def cancel_order( + order_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), +) -> OrderResponse: + # Raises domain exceptions → caught by exception handlers above + order = await order_service.cancel(order_id, requested_by=current_user.id) + return OrderResponse.from_domain(order) +``` diff --git a/skills/rest-api-best-practices/references/request-response-design.md b/skills/rest-api-best-practices/references/request-response-design.md new file mode 100644 index 0000000..2a35d77 --- /dev/null +++ b/skills/rest-api-best-practices/references/request-response-design.md @@ -0,0 +1,227 @@ +# Request and Response Design + +## Response Shape + +Choose one field naming convention and never mix: +- **camelCase**: `userId`, `createdAt` — standard for JavaScript/TypeScript APIs +- **snake_case**: `user_id`, `created_at` — standard for Python APIs + +Return only what the consumer needs. Do not leak internal identifiers, database columns, or null fields that carry no meaning for the caller. + +```json +// Good — clean, purposeful shape +{ + "id": "usr_01J8X", + "name": "Ana García", + "email": "ana@example.com", + "createdAt": "2024-01-15T10:30:00Z" +} + +// Bad — leaks internals, inconsistent naming, null noise +{ + "user_id": 42, + "internal_ref": "SQL_123", + "userName": "Ana García", + "email": "ana@example.com", + "deletedAt": null, + "updated_at": null, + "v": 3 +} +``` + +## Collection Responses + +Two valid approaches — pick one and apply it consistently: + +### Envelope wrapper (preferred when metadata is needed) + +```json +{ + "data": [ + { "id": "usr_01", "name": "Ana García" }, + { "id": "usr_02", "name": "Carlos López" } + ], + "meta": { + "total": 142, + "page": 2, + "perPage": 25 + } +} +``` + +### Bare array + headers (simpler for clients that don't need metadata) + +``` +HTTP/1.1 200 OK +Content-Type: application/json +X-Total-Count: 142 +Link: <...>; rel="next", <...>; rel="prev" + +[ + { "id": "usr_01", "name": "Ana García" }, + { "id": "usr_02", "name": "Carlos López" } +] +``` + +## Filtering + +Use query parameters. Document exactly which filters are supported and reject unknown ones with `400 Bad Request`. + +``` +GET /articles?status=published +GET /articles?authorId=usr_01&status=published +GET /articles?publishedAfter=2024-01-01&publishedBefore=2024-12-31 +GET /articles?tags=ddd,architecture +``` + +Do not silently ignore unknown filters — the caller expects them to work, and silent failure leads to incorrect results. + +## Sorting + +``` +# Explicit direction parameter +GET /articles?sort=publishedAt&order=asc +GET /articles?sort=publishedAt&order=desc + +# Prefix convention (- = descending) +GET /articles?sort=-publishedAt +GET /articles?sort=-publishedAt,title ← multiple fields +``` + +Always document: +- Which fields are sortable (not all fields should be sortable) +- What the default sort order is when `sort` is omitted + +## Pagination + +### Offset-based (page number) + +``` +GET /articles?page=2&perPage=25 +GET /articles?offset=25&limit=25 +``` + +Good for: small bounded collections, random access (jump to page 5), stable sorted results. +Bad for: large datasets, real-time data (page boundaries shift as new items are inserted). + +Response headers: +``` +Link: ; rel="first", + ; rel="prev", + ; rel="next", + ; rel="last" +X-Total-Count: 142 +``` + +The `Link` header with `rel=first|prev|next|last` lets clients navigate without constructing URLs. Consumers should use these links rather than building pagination URLs themselves. + +### Cursor-based (keyset pagination) + +``` +GET /articles?limit=25 ← first page +GET /articles?cursor=eyJpZCI6MTI1fQ&limit=25 ← subsequent pages +``` + +Good for: large datasets, infinite scroll, real-time feeds, high-write collections. +Bad for: random access, jumping to arbitrary pages. + +Response: +```json +{ + "data": [...], + "nextCursor": "eyJpZCI6MTUwfQ", + "hasMore": true +} +``` + +The cursor encodes the position in the result set (typically the ID or a composite key of the last item). It is opaque to the client — never document its format as part of the contract. + +### Rules for both approaches + +- Never return an unbounded collection. Require a bounded limit or apply a documented bounded default. +- Define and document the maximum page size (e.g., max `perPage=100`). +- Use a deterministic total order; cursor pagination must include every sort key plus a unique tie-breaker. +- Decode and validate opaque cursors. Sign them when tampering can alter authorization or query scope. +- Apply the same pagination shape consistently across all collection endpoints. +- Return an empty array (not 404) when a collection exists but has no items. + +## Timestamps + +Always use ISO 8601 in UTC: + +```json +{ + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-03-22T14:00:00Z" +} +``` + +Never use Unix timestamps (ambiguous), locale-formatted dates, or dates without timezone information. + +## Null vs. Absent Fields + +Choose one strategy and apply it everywhere: + +| Strategy | Payload | Consumer impact | +|----------|---------|----------------| +| Omit absent fields | Smaller | Must handle missing keys | +| Include null explicitly | Predictable shape | Easier deserialization | + +Document the chosen strategy. Never mix both randomly — a client cannot know whether a missing field means null or an API bug. + +## Partial Responses (Field Selection) + +Allow clients to request only the fields they need: + +``` +GET /users?fields=id,name,email +GET /orders/{id}?fields=id,status,total +``` + +Reduces payload size. Especially valuable for mobile clients or aggregation layers that call many APIs. Not required for all APIs — add only when there is clear consumer demand. + +## Response Structure Depth + +Keep response objects relatively flat. Deep nesting leaks the internal data model, makes responses harder to parse, and couples clients to the internal structure of related entities. + +```json +// Good — flat references +{ + "id": "ord_456", + "customerId": "usr_123", + "customerName": "Ana García", + "organizationId": "org_789", + "organizationName": "Tech Corp" +} + +// Bad — deep nesting leaks internal model +{ + "id": "ord_456", + "customer": { + "id": "usr_123", + "name": "Ana García", + "organization": { + "id": "org_789", + "name": "Tech Corp", + "settings": { + "timezone": "UTC", + "billing": { "plan": "pro" } + } + } + } +} +``` + +More than two levels of nesting often signals a resource modeling problem. Consider: +- Returning IDs and letting clients fetch related resources on demand. +- Flattening the most-used fields into the parent object. +- Introducing a dedicated composite endpoint for the specific client need. + +## Identifiers + +Use opaque string IDs (`usr_01J8X`, `ord_abc123`) rather than raw database integer IDs: +- Hides internal sequencing and scale signals +- Makes IDs non-guessable +- Decouples the API from the storage implementation + +When exposing UUIDs, use the standard dashed format: `f47ac10b-58cc-4372-a567-0e02b2c3d479`. diff --git a/skills/rest-api-best-practices/references/rust-examples.md b/skills/rest-api-best-practices/references/rust-examples.md new file mode 100644 index 0000000..f9cc865 --- /dev/null +++ b/skills/rest-api-best-practices/references/rust-examples.md @@ -0,0 +1,275 @@ +# Rust Examples + +Examples using axum (Tokio ecosystem), the most common modern Rust web framework. + +## Route Definition and URL Structure + +```rust +use axum::{Router, routing::{get, post, patch, delete}}; + +fn router(state: AppState) -> Router { + Router::new() + // collection + resource + .route("/v1/orders", get(list_orders).post(create_order)) + .route("/v1/orders/:id", get(get_order).patch(update_order).delete(delete_order)) + // sub-collection + .route("/v1/orders/:id/items", get(list_order_items)) + // task-based URLs — domain operations + .route("/v1/orders/:id/cancel", post(cancel_order)) + .route("/v1/orders/:id/ship", post(ship_order)) + .with_state(state) +} +``` + +## Status Codes in Responses + +```rust +use axum::{http::StatusCode, response::IntoResponse, Json}; +use axum::http::header::LOCATION; + +// 201 Created + Location header +async fn create_order( + State(svc): State, + Json(body): Json, +) -> Result { + let order = svc.create(body).await?; + let location = format!("/v1/orders/{}", order.id); + Ok(( + StatusCode::CREATED, + [(LOCATION, location)], + Json(OrderResponse::from(order)), + )) +} + +// 204 No Content +async fn delete_order( + State(svc): State, + Path(id): Path, +) -> Result { + svc.delete(&id).await?; + Ok(StatusCode::NO_CONTENT) +} +``` + +## Request Validation + +```rust +use serde::Deserialize; +use validator::Validate; // `validator` crate + +#[derive(Deserialize, Validate)] +struct OrderItemRequest { + #[validate(length(equal = 36))] + product_id: String, + #[validate(range(min = 1))] + quantity: u32, +} + +#[derive(Deserialize, Validate)] +struct CreateOrderRequest { + #[validate(length(equal = 36))] + customer_id: String, + #[validate(length(min = 1), nested)] + items: Vec, +} + +async fn create_order( + Json(body): Json, +) -> Result { + body.validate().map_err(ApiError::Validation)?; // -> 422 + // ... + Ok(StatusCode::CREATED) +} +``` + +## Error Response Format (RFC 9457) + +```rust +use axum::{http::{header, StatusCode}, response::{IntoResponse, Response}, Json}; +use serde::Serialize; + +#[derive(Serialize)] +struct ProblemDetails { + #[serde(rename = "type")] + type_: String, + title: String, + status: u16, + detail: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + errors: Vec, +} + +#[derive(Serialize)] +struct FieldError { field: String, message: String } + +fn problem(status: StatusCode, code: &str, detail: &str) -> ProblemDetails { + let slug = code.to_lowercase().replace('_', "-"); + ProblemDetails { + type_: format!("https://api.example.com/errors/{slug}"), + title: code.replace('_', " "), + status: status.as_u16(), + detail: detail.to_string(), + errors: Vec::new(), + } +} +``` + +## Domain Error Translation + +```rust +// Domain errors as an enum — no HTTP knowledge +#[derive(thiserror::Error, Debug)] +enum DomainError { + #[error("order {0} not found")] + OrderNotFound(String), + #[error("order already cancelled")] + OrderAlreadyCancelled, + #[error("stock short by {0}")] + InsufficientStock(u32), +} + +// API error wrapper that knows how to become an HTTP response +enum ApiError { + Domain(DomainError), + Validation(validator::ValidationErrors), + Unexpected { correlation_id: String, source: anyhow::Error }, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, code, detail) = match self { + ApiError::Domain(DomainError::OrderNotFound(_)) => + (StatusCode::NOT_FOUND, "ORDER_NOT_FOUND", "Order not found.".into()), + ApiError::Domain(DomainError::OrderAlreadyCancelled) => + (StatusCode::CONFLICT, "ORDER_ALREADY_CANCELLED", "This order has already been cancelled.".into()), + ApiError::Domain(DomainError::InsufficientStock(n)) => + (StatusCode::UNPROCESSABLE_ENTITY, "INSUFFICIENT_STOCK", format!("Stock is short by {n} units.")), + ApiError::Validation(_) => + (StatusCode::UNPROCESSABLE_ENTITY, "VALIDATION_FAILED", "The request body failed validation.".into()), + ApiError::Unexpected { correlation_id, source } => { + safe_exception_logger::capture(&source, &correlation_id); + (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "An unexpected error occurred.".into()) + }, + }; + let body = problem(status, code, &detail); + (status, [(header::CONTENT_TYPE, "application/problem+json")], Json(body)).into_response() + } +} + +// Lets `?` convert domain errors automatically +impl From for ApiError { + fn from(e: DomainError) -> Self { ApiError::Domain(e) } +} +``` + +## Pagination + +```rust +use serde::Deserialize; + +#[derive(Deserialize)] +struct Pagination { + #[serde(default = "default_page")] + page: u32, + #[serde(default = "default_per_page")] + per_page: u32, +} +fn default_page() -> u32 { 1 } +fn default_per_page() -> u32 { 25 } + +async fn list_orders( + State(repo): State, + Query(p): Query, +) -> Result { + let per_page = p.per_page.min(100); + let offset = (p.page.saturating_sub(1)) * per_page; + + let (orders, total) = repo.find_many(offset, per_page).await?; + let total_pages = (total + per_page - 1) / per_page; + let base = "/v1/orders"; + + let mut links = vec![ + format!("<{base}?page=1&per_page={per_page}>; rel=\"first\""), + format!("<{base}?page={total_pages}&per_page={per_page}>; rel=\"last\""), + ]; + if p.page > 1 { + links.push(format!("<{base}?page={}&per_page={per_page}>; rel=\"prev\"", p.page - 1)); + } + if p.page < total_pages { + links.push(format!("<{base}?page={}&per_page={per_page}>; rel=\"next\"", p.page + 1)); + } + + Ok(( + StatusCode::OK, + [("Link", links.join(", ")), ("X-Total-Count", total.to_string())], + Json(orders), + )) +} +``` + +## Authentication Middleware (JWT) + +```rust +use axum::{extract::Request, middleware::Next, response::Response}; +use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm}; + +async fn auth(mut req: Request, next: Next) -> Result { + let token = req + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .ok_or(ApiError::unauthorized("Bearer token required."))?; + + let data = decode::( + token, + &DecodingKey::from_rsa_pem(JWT_PUBLIC_KEY).unwrap(), + &Validation::new(Algorithm::RS256), + ).map_err(|_| ApiError::unauthorized("Invalid or expired token."))?; + + req.extensions_mut().insert(AuthUser { id: data.claims.sub }); + Ok(next.run(req).await) +} + +// Apply: Router::new().route(...).layer(middleware::from_fn(auth)) +``` + +## Request ID Middleware + +```rust +use axum::{extract::Request, middleware::Next, response::Response}; +use uuid::Uuid; + +#[derive(Clone)] +struct RequestId(String); + +async fn request_id(mut req: Request, next: Next) -> Response { + let upstream_id = req.headers().get("X-Request-ID"); // validate/log separately if needed + let id = Uuid::new_v4().to_string(); + req.extensions_mut().insert(RequestId(id.clone())); + + let mut res = next.run(req).await; + res.headers_mut().insert("X-Request-ID", id.parse().unwrap()); + res +} +``` + +## Task-Based Endpoint + +```rust +async fn cancel_order( + State(svc): State, + Extension(user): Extension, + Path(id): Path, +) -> Result { + // svc.cancel returns Result; `?` maps via From + let order = svc.cancel(&id, &user.id).await?; + Ok((StatusCode::OK, Json(OrderResponse::from(order)))) +} +``` + +## What to Notice + +- `Result` + `IntoResponse` centralizes error-to-HTTP translation — the domain stays HTTP-free. +- `?` propagates domain errors and converts them via `From`, mirroring the middleware error handler in other languages. +- The type system forces every error path to be handled — there is no unhandled-exception escape hatch. diff --git a/skills/rest-api-best-practices/references/security.md b/skills/rest-api-best-practices/references/security.md new file mode 100644 index 0000000..6973419 --- /dev/null +++ b/skills/rest-api-best-practices/references/security.md @@ -0,0 +1,127 @@ +# Security + +## HTTPS + +Every API must run over HTTPS. No exceptions. + +- Redirect HTTP to HTTPS with 301. +- Enforce HSTS: `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` +- Never put secrets or tokens in URLs — they appear in server logs, browser history, and proxy caches. + +## Authentication + +### Bearer Token (JWT) + +``` +Authorization: Bearer eyJhbGciOiJSUzI1NiJ9... +``` + +- **Stateless** — the server validates the signature; no session lookup needed. +- Include only non-sensitive claims in the payload (`sub`, `iss`, `aud`, `exp`, `roles`). +- Set short expiry (`exp`) — typically 15 minutes for access tokens. +- Provide a refresh token flow for long sessions. +- Use **RS256** (asymmetric) over HS256 (symmetric) in multi-service environments — the public key can be distributed without sharing the secret. +- Always validate: `iss` (issuer), `aud` (audience), `exp` (expiry), `nbf` (not before). + +### API Keys + +``` +X-API-Key: sk_live_abc123... +``` + +- Send in a **header**, never in a query parameter (query params appear in server logs and browser history). +- Scope keys to specific permissions (read-only, write, admin). +- Allow key rotation without downtime. +- **Hash keys at rest** using a slow hash (bcrypt, Argon2) — treat them as passwords. +- Log the key ID (not the key itself) in access logs. + +### OAuth 2.0 + +Use for delegated access (third-party integrations, mobile apps, SPAs): + +| Flow | When to use | +|------|-------------| +| **Client Credentials** | Machine-to-machine; no user involved | +| **Authorization Code + PKCE** | User-facing browser and mobile applications | +| ~~Implicit~~ | Deprecated — do not use | +| ~~Password~~ | Deprecated — do not use | + +Store access tokens securely: +- **Browser**: `HttpOnly` cookies (not `localStorage` — XSS exposed) +- **Mobile**: OS secure storage (Keychain, Keystore) +- **Server**: memory or encrypted at-rest storage + +## Authorization + +- **Authenticate first, authorize second** — always in this order. +- Return `401 Unauthorized` when credentials are missing or invalid. +- Return `403 Forbidden` when credentials are valid but access is denied. +- Apply authorization at the **resource level**, not just at the route level. A user who can list orders should not necessarily be able to view another user's order. +- Avoid leaking resource existence to unauthorized callers: when a user is authenticated but not authorized, return `403` for protected resources — not `404`. Returning `404` to hide the resource is only appropriate for truly sensitive data where even the existence of the resource must be hidden. + +## Rate Limiting + +Return `429 Too Many Requests` when limits are exceeded: + +``` +HTTP/1.1 429 Too Many Requests +X-RateLimit-Limit: 1000 +X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1711929600 +Retry-After: 60 +Content-Type: application/json + +{ + "type": "https://api.example.com/errors/rate-limit-exceeded", + "title": "Rate Limit Exceeded", + "status": 429, + "detail": "You have exceeded 1000 requests per hour. Retry after 60 seconds." +} +``` + +Rules: +- Apply limits per **API key** or **authenticated user**, not per IP alone (IPs can be shared or rotated). +- Use **sliding window** or **token bucket** algorithms for smooth enforcement. +- Include `Retry-After` so clients know when to retry rather than hammering the API. +- Document rate limits in API documentation. +- Use different limits for different tiers (free, pro, enterprise). + +## CORS + +For browser-facing APIs: + +``` +Access-Control-Allow-Origin: https://app.example.com +Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS +Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key +Access-Control-Max-Age: 86400 +``` + +- **Never set `Access-Control-Allow-Origin: *` for authenticated APIs** — this allows any site to make credentialed requests. +- Maintain an explicit allowlist of trusted origins. +- Respond to OPTIONS preflight requests with `204 No Content` and the CORS headers. +- Set `Access-Control-Max-Age` to cache preflight responses and reduce OPTIONS round trips. + +## Input Validation + +- Validate **all input at the API boundary** — never trust client data. +- Use **allowlists** for acceptable values, not denylists. +- Set a maximum request body size to prevent resource exhaustion: `Content-Length` checks or framework body size limits. +- Sanitize before logging — never log raw user input that could contain PII, passwords, or injection payloads. + +## Sensitive Data in Responses + +- Never return passwords, private keys, or full payment card numbers. +- Mask sensitive fields in logs (e.g., `"email": "a***@example.com"`). +- Remove or mask fields the caller is not authorized to see — do not rely on client-side filtering. + +## Security Headers + +Include these on all API responses: + +``` +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Content-Security-Policy: default-src 'none' +Referrer-Policy: no-referrer +``` diff --git a/skills/rest-api-best-practices/references/testing-apis.md b/skills/rest-api-best-practices/references/testing-apis.md new file mode 100644 index 0000000..9068b5b --- /dev/null +++ b/skills/rest-api-best-practices/references/testing-apis.md @@ -0,0 +1,105 @@ +# Testing APIs + +A REST API is a contract. Tests exist to prove the contract holds — across happy paths, failures, edge cases, and changes over time. Test at multiple levels; do not rely on manual checks or end-to-end tests alone. + +## The API Test Pyramid + +``` + /\ E2E / Journey tests (few) + / \ — full flows across multiple endpoints + /----\ Integration / Contract tests (some) + / \ — real HTTP, real routing, mocked externals + /--------\ Unit tests (many) + — handlers, validators, mappers, domain logic +``` + +- **Unit (many, fast)**: validators, error mappers, serializers, domain services. No HTTP, no DB. +- **Integration / contract (some)**: spin up the app, hit real routes over HTTP, assert status codes, headers, and body shapes. Mock third-party services. +- **E2E / journey (few)**: exercise complete business flows (register → create order → cancel) against a running system. + +Most coverage lives in the bottom two layers. E2E tests are valuable but slow and brittle — keep them few and focused on critical journeys. + +## What to Test Beyond the Happy Path + +The happy path is the easy 20%. Reliability comes from testing the rest: + +| Category | Examples | +|----------|----------| +| Validation | Missing required fields, wrong types, out-of-range values, all errors returned at once | +| Authentication | No token, expired token, malformed token → 401 | +| Authorization | Valid token but no permission → 403; accessing another user's resource | +| Not found | Unknown ID → 404 | +| Conflict | Duplicate create → 409; stale `If-Match` → 412 | +| Business rules | Cancelling a shipped order → 409/422 | +| Rate limiting | Exceeding the limit → 429 with `Retry-After` | +| Idempotency | Same `Idempotency-Key` twice → one effect, same response | +| Pagination | First/last page, empty collection, over-max page size | +| Content negotiation | Unsupported `Accept` → 406; wrong `Content-Type` → 415 | + +## Status Code and Header Assertions + +Assert the full response contract, not just the body: + +```typescript +const res = await request(app) + .post("/v1/orders") + .set("Authorization", `Bearer ${token}`) + .send(validOrderPayload); + +expect(res.status).toBe(201); +expect(res.headers.location).toMatch(/^\/v1\/orders\/ord_/); +expect(res.body).toMatchObject({ status: "pending" }); + +// Error case — assert the RFC 9457 Problem Details envelope +const bad = await request(app).post("/v1/orders").send({}); +expect(bad.status).toBe(422); +expect(bad.headers["content-type"]).toMatch(/^application\/problem\+json/); +expect(bad.body.type).toContain("validation-failed"); +expect(bad.body.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ field: "items" })]), +); +``` + +For every declared application failure, test status, `application/problem+json`, stable API-owned `type`, and explicitly safe details. Add an unknown-failure case that proves a generic 500 is returned and the failure is logged with a correlation ID. Include negative assertions that raw exception messages, IDs, rejected content, SQL, and stack traces are absent. + +## Contract Testing + +A contract test verifies the API conforms to its published specification (OpenAPI). It catches drift between the spec, the docs, and the implementation. + +- **Schema validation**: validate every response against the OpenAPI schema in CI. Tools: Schemathesis (Python), Dredd, openapi-backend, express-openapi-validator. +- **Property-based / fuzz**: Schemathesis generates inputs from the spec and asserts the server never violates it (no 500s, responses match declared schemas). +- **Consumer-driven contracts (Pact)**: when other teams consume your API, the consumer publishes the interactions it relies on; the provider verifies it satisfies them before deploy. This prevents breaking changes that the provider's own tests would miss. + +```bash +# Example: fuzz the API against its OpenAPI spec +schemathesis run openapi.yaml --base-url http://localhost:3000 --checks all +``` + +## Tooling + +| Tool | Use | +|------|-----| +| Postman / Newman | Manual exploration + automated collection runs in CI | +| supertest (Node), httpx (Python), RestAssured (Java) | In-process integration tests | +| Schemathesis, Dredd | OpenAPI contract / fuzz testing | +| Pact | Consumer-driven contracts across teams | +| k6, Locust, Gatling | Load and performance testing | + +## Test Data and Isolation + +- Each test sets up and tears down its own data — never depend on test execution order. +- Use a dedicated test database; reset state between runs (transactional rollback or truncation). +- Mock third-party HTTP (payment, email) at the boundary — never call real external services in tests. +- Make tests deterministic: freeze time, seed randomness, use fixed IDs (Object Mother / factory helpers). + +## Keep Tests Aligned with the Contract + +- Generate request/response examples in docs from the same fixtures the tests use, so docs cannot drift from reality. +- Run contract validation in CI and fail the build on drift. +- When you intentionally change the contract, the failing contract test is the signal to bump the version. + +## Related References + +- `documentation.md` — OpenAPI spec as the source of truth for contract tests. +- `error-handling.md` — the error shapes your tests assert against. +- `http-semantics.md` — status codes and headers to verify. diff --git a/skills/rest-api-best-practices/references/typescript-examples.md b/skills/rest-api-best-practices/references/typescript-examples.md new file mode 100644 index 0000000..1a75ca7 --- /dev/null +++ b/skills/rest-api-best-practices/references/typescript-examples.md @@ -0,0 +1,332 @@ +# TypeScript Examples + +Examples using Express and NestJS. Patterns are the same; the framework determines the syntax. + +## URL Structure and Route Definition + +```typescript +// Express — routes map to resource actions +import express from "express"; +const router = express.Router(); + +router.get("/orders", listOrders); // collection +router.post("/orders", createOrder); // create +router.get("/orders/:id", getOrder); // one resource +router.patch("/orders/:id", updateOrder); // partial update +router.delete("/orders/:id", deleteOrder); // remove + +// Sub-collection +router.get("/orders/:id/items", listOrderItems); + +// Task-based URLs — domain operations, not data mutations +router.post("/orders/:id/cancel", cancelOrder); +router.post("/orders/:id/ship", shipOrder); + +// NestJS — same structure via decorators +@Controller("orders") +export class OrdersController { + @Get() list() {} + @Post() create() {} + @Get(":id") findOne() {} + @Patch(":id") update() {} + @Delete(":id") remove() {} + @Post(":id/cancel") cancel() {} + @Post(":id/ship") ship() {} +} +``` + +## Status Codes in Responses + +```typescript +// 200 OK — successful GET +res.status(200).json(order); + +// 201 Created — successful POST with Location header +res.status(201) + .header("Location", `/orders/${order.id}`) + .json(order); + +// 204 No Content — DELETE or PUT with no body +res.status(204).send(); + +// 202 Accepted — async operation triggered +res.status(202).json({ message: "Order cancellation queued.", jobId: job.id }); + +// NestJS via decorators +@Post() +@HttpCode(201) +@Header("Location", ...) +create(@Body() dto: CreateOrderDto) { ... } +``` + +## Request Validation (Zod) + +```typescript +import { z } from "zod"; + +const CreateOrderSchema = z.object({ + customerId: z.string().uuid(), + items: z.array(z.object({ + productId: z.string().uuid(), + quantity: z.number().int().positive(), + })).min(1), + shippingAddress: z.object({ + street: z.string().min(1), + city: z.string().min(1), + postalCode: z.string().regex(/^\d{5}$/), + country: z.string().length(2), + }), +}); + +async function createOrder(req: Request, res: Response) { + const result = CreateOrderSchema.safeParse(req.body); + if (!result.success) { + return res.status(422).type("application/problem+json").json(validationProblem(result.error)); + } + // result.data is fully typed + const order = await orderService.create(result.data); + res.status(201).header("Location", `/orders/${order.id}`).json(order); +} + +// NestJS — class-validator + class-transformer +class CreateOrderDto { + @IsUUID() + customerId: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OrderItemDto) + items: OrderItemDto[]; +} +``` + +## Error Response Format (RFC 9457) + +```typescript +interface ProblemDetails { + type: string; + title: string; + status: number; + detail: string; + errors?: Array<{ field: string; message: string }>; +} + +function problem(status: number, code: string, detail: string): ProblemDetails { + return { + type: `https://api.example.com/errors/${code.toLowerCase().replace(/_/g, "-")}`, + title: code.replace(/_/g, " "), + status, + detail, + }; +} + +const publicValidationMessages: Record = { + invalid_type: "Invalid value type.", + too_small: "Value is below the allowed minimum.", + too_big: "Value exceeds the allowed maximum.", +}; + +function validationProblem(error: ZodError): ProblemDetails { + return { + type: "https://api.example.com/errors/validation-failed", + title: "Validation Failed", + status: 422, + detail: "The request body contains fields that failed validation.", + errors: error.errors.map(e => ({ + field: e.path.join("."), + message: publicValidationMessages[e.code] ?? "Invalid value.", + })), + }; +} +``` + +## Domain Error Translation Middleware + +```typescript +// Domain errors — no HTTP knowledge +class OrderNotFound extends Error { + constructor(id: string) { super(`Order ${id} not found`); } +} +class OrderAlreadyCancelled extends Error {} +class InsufficientStock extends Error { + constructor(public readonly shortfall: number) { super(); } +} + +// Express error middleware — one place handles all errors +import { Request, Response, NextFunction } from "express"; + +app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => { + if (err instanceof OrderNotFound) + return res.status(404).type("application/problem+json").json(problem(404, "ORDER_NOT_FOUND", "Order not found.")); + if (err instanceof OrderAlreadyCancelled) + return res.status(409).type("application/problem+json").json(problem(409, "ORDER_ALREADY_CANCELLED", "This order has already been cancelled.")); + if (err instanceof InsufficientStock) + return res.status(422).type("application/problem+json").json(problem(422, "INSUFFICIENT_STOCK", `Stock is short by ${err.shortfall} units.`)); + if (err instanceof ZodError) + return res.status(422).type("application/problem+json").json(validationProblem(err)); + + const requestId = res.locals.requestId as string; // generated by server middleware + safeExceptionLogger.capture(err, { requestId, path: req.path }); + return res.status(500).type("application/problem+json").json(problem(500, "INTERNAL_ERROR", "An unexpected error occurred.")); +}); +``` + +## Pagination + +```typescript +const singlePositiveInt = (max: number) => z.preprocess( + (raw) => typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw, + z.number().int().safe().positive().max(max), +); + +// Strict public query contract: unknown, repeated, fractional, and excessive values are rejected. +const ListOrdersQuerySchema = z.object({ + page: singlePositiveInt(100_000).default(1), + perPage: singlePositiveInt(100).default(25), + status: z.enum(["pending", "confirmed", "shipped", "cancelled"]).optional(), + customerId: z.string().uuid().optional(), + createdAfter: z.string().datetime().optional(), + createdBefore: z.string().datetime().optional(), + sort: z.enum(["createdAt", "total", "status"]).default("createdAt"), + order: z.enum(["asc", "desc"]).default("desc"), +}).strict(); + +async function listOrders(req: Request, res: Response) { + const query = ListOrdersQuerySchema.parse(req.query); + const { page, perPage, sort, order, ...filters } = query; + // HTTP pages are one-based; the query port uses a zero-based offset. + const offset = (page - 1) * perPage; + + const [orders, total] = await orderQueries.findMany({ + filters, + order: [ + { field: sort, direction: order }, + { field: "id", direction: order }, // stable unique tie-breaker + ], + offset, + limit: perPage, + }); + const totalPages = Math.ceil(total / perPage); + const base = req.originalUrl.split("?")[0]; + const linkTo = (targetPage: number) => { + const params = new URLSearchParams({ + ...Object.fromEntries(Object.entries({ ...filters, sort, order }).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)])), + page: String(targetPage), + perPage: String(perPage), + }); + return `${base}?${params}`; + }; + + const links: string[] = []; + if (totalPages > 0) { + links.push(`<${linkTo(1)}>; rel="first"`, `<${linkTo(totalPages)}>; rel="last"`); + if (page > 1) links.push(`<${linkTo(page - 1)}>; rel="prev"`); + if (page < totalPages) links.push(`<${linkTo(page + 1)}>; rel="next"`); + } + + res + .header("Link", links.join(", ")) + .header("X-Total-Count", String(total)) + .status(200) + .json(orders); +} +``` + +Filtering, sorting, and pagination are one contract so every collection query remains bounded and navigation preserves the active query. + +## Authentication Middleware (JWT) + +```typescript +import jwt from "jsonwebtoken"; + +function authenticate(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + return res.status(401).type("application/problem+json").json(problem(401, "UNAUTHORIZED", "Bearer token required.")); + } + + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, process.env.JWT_PUBLIC_KEY!, { algorithms: ["RS256"] }); + req.user = payload as AuthenticatedUser; + next(); + } catch { + return res.status(401).type("application/problem+json").json(problem(401, "UNAUTHORIZED", "Invalid or expired token.")); + } +} + +// Apply to protected routes +router.use(authenticate); +router.get("/orders", listOrders); +``` + +## Task-Based Endpoint + +```typescript +// POST /orders/:id/cancel — domain operation, not a PATCH +async function cancelOrder(req: Request, res: Response, next: NextFunction) { + try { + const order = await orderService.cancel(req.params.id, req.user!.id); + res.status(200).json(order); + } catch (err) { + next(err); // passed to error middleware + } +} + +// Service — enforces business rules +class OrderService { + async cancel(orderId: string, requestedBy: string): Promise { + const order = await this.orderRepo.findById(orderId); + if (!order) throw new OrderNotFound(orderId); + if (order.status === "shipped") throw new OrderAlreadyCancelled(); + order.cancel(requestedBy); + await this.orderRepo.save(order); + await this.eventBus.publish(order.pullDomainEvents()); + return order; + } +} +``` + +## NestJS Full Example + +```typescript +@Controller("v1/orders") +@UseGuards(JwtAuthGuard) +export class OrdersController { + constructor(private readonly orderService: OrderService) {} + + @Get() + async list(@Query() query: ListOrdersDto): Promise> { + return this.orderService.list(query); + } + + @Post() + @HttpCode(201) + async create( + @Body() dto: CreateOrderDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const order = await this.orderService.create(dto); + res.header("Location", `/v1/orders/${order.id}`); + return order; + } + + @Post(":id/cancel") + async cancel( + @Param("id") id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.orderService.cancel(id, user.id); + } +} + +// NestJS exception filter — translates domain errors globally +@Catch() +export class DomainExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const res = host.switchToHttp().getResponse(); + const body = toProblemDetails(exception); + res.status(body.status).type("application/problem+json").json(body); + } +} +``` diff --git a/skills/rest-api-best-practices/references/url-design.md b/skills/rest-api-best-practices/references/url-design.md new file mode 100644 index 0000000..7daba0a --- /dev/null +++ b/skills/rest-api-best-practices/references/url-design.md @@ -0,0 +1,181 @@ +# URL Design + +## Core Rule + +URLs identify resources. HTTP methods express what to do with them. Never mix both responsibilities into the URL. + +``` +# Wrong — verbs in URL +GET /getUser/123 +POST /createOrder +POST /deleteItem/5 + +# Right — method is the verb +GET /users/123 +POST /orders +DELETE /items/5 +``` + +## Naming Conventions + +- **Nouns, plural**: `/users`, `/orders`, `/line-items` +- **Lowercase**: `/user-profiles` not `/UserProfiles` +- **Kebab-case** for multi-word: `/order-items` not `/orderItems` or `/order_items` +- **No trailing slash**: `/users` not `/users/` +- **No file extensions**: `/users` not `/users.json` +- **No version in resource name**: `/v1/users` not `/users-v1` + +## URL Structure + +``` +/collection → all resources in a collection +/collection/{id} → one specific resource +/collection/{id}/sub-collection → sub-collection belonging to one resource +``` + +Keep nesting at most two levels deep. Deeper nesting couples the URL to internal relationships and makes paths fragile. + +``` +# OK — two levels +GET /orders/{orderId}/items + +# Avoid — three levels +GET /users/{userId}/orders/{orderId}/items/{itemId} + +# Better — expose the resource directly +GET /items/{itemId} +``` + +When the sub-resource has its own identity and can be addressed independently, promote it to a top-level resource. + +## Query Parameters + +Use query parameters for everything that shapes the collection response but does not identify the resource: + +| Purpose | Example | +|---------|---------| +| Filtering | `GET /articles?status=published&category=tech` | +| Sorting | `GET /articles?sort=publishedAt&order=desc` | +| Pagination | `GET /articles?page=2&perPage=25` | +| Search | `GET /articles?q=domain+driven+design` | +| Field selection | `GET /users?fields=id,name,email` | + +Never put filter logic in the path segment: `/articles/published` is a smell if `published` is a filter — not a meaningful sub-resource that can be addressed independently. + +## Versioning + +### URL versioning (recommended default) + +``` +/v1/users +/v2/users +``` + +Visible, easy to route, easy to deprecate, testable directly in a browser. The most practical strategy for most teams. + +### Header versioning + +``` +Accept: application/vnd.myapi.v1+json +``` + +Cleaner URLs, but harder to discover and test. Use when the team needs to shield clients from URL changes or when managing multiple representations of the same resource. + +### What constitutes a breaking change + +A breaking change requires a new version: +- Removing or renaming a field +- Changing a field's type (e.g., string → number) +- Changing the semantics of a status code +- Removing an endpoint +- Changing authentication requirements + +**Adding optional fields or endpoints is not a breaking change.** + +### Deprecation headers + +Signal that a version is going away before removing it: + +``` +Deprecation: Tue, 01 Jan 2026 00:00:00 GMT +Sunset: Tue, 01 Jul 2026 00:00:00 GMT +Link: ; rel="successor-version" +``` + +Support at least one prior major version during the deprecation window. + +## Backward Compatibility + +### Add, don't remove + +When evolving an API, prefer adding new fields or endpoints over removing or changing existing ones. Clients that ignore unknown fields won't break; clients that depend on a removed field will. + +Safe changes (no version bump needed): +- Adding a new optional field to a response +- Adding a new optional query parameter +- Adding a new endpoint +- Adding a new HTTP method to an existing endpoint + +Breaking changes (require a version bump): +- Removing a field +- Renaming a field +- Changing a field's type or format +- Removing an endpoint +- Changing a status code's semantics +- Making a previously optional field required + +### Deprecation process + +When removal is unavoidable, announce it early and support both behaviors in parallel: + +1. Announce the deprecation with a timeline. +2. Include deprecation warnings in responses so clients notice before removal. +3. Support old and new versions simultaneously during the deprecation window. + +``` +# Deprecation header (RFC 8594) +Deprecation: Sat, 01 Jun 2025 00:00:00 GMT +Sunset: Sat, 01 Dec 2025 00:00:00 GMT +Link: ; rel="successor-version" + +# Custom warning header (visible in logs and monitoring) +X-API-Warn: This endpoint is deprecated and will be removed on 2025-12-01. Migrate to /v2/users. +``` + +## Task-Based URLs + +When a domain operation does not map cleanly to CRUD, use a task URL. The pattern is: + +``` +POST /collection/{id}/action +``` + +Examples: +``` +POST /orders/{id}/cancel +POST /orders/{id}/ship +POST /accounts/{id}/activate +POST /accounts/{id}/deactivate +POST /invoices/{id}/send +POST /payments/{id}/refund +POST /users/{id}/verify-email +``` + +These are POST because they trigger a state transition — they are not safe or idempotent in the general case. + +The URL names the business action in domain language, not the data field that changes. + +``` +# CRUD smell — encoding business action as a data mutation +PATCH /orders/{id} +{ "status": "cancelled" } + +# Task-based — explicit domain operation +POST /orders/{id}/cancel +``` + +Benefits: +- Domain rules live on the server (`cancel` enforces that shipped orders cannot be cancelled). +- Clients express intent, not internal state changes. +- The API surface reads like the ubiquitous language. +- Each action can be versioned and evolved independently. diff --git a/tests/inventory.test.mjs b/tests/inventory.test.mjs index 38956b3..b69cb1a 100644 --- a/tests/inventory.test.mjs +++ b/tests/inventory.test.mjs @@ -31,11 +31,12 @@ test('live skills are unique and include the three original packages plus BotKit assert.ok(names.includes('setup-bot')); assert.ok(names.includes('retro')); assert.ok(names.includes('simple-as-writing')); + assert.ok(names.includes('rest-api-best-practices')); assert.ok(names.includes('tdd')); assert.ok(names.includes('matt-tdd')); assert.ok(names.includes('teach')); assert.ok(names.includes('matt-teach')); - assert.equal(names.length, 96); + assert.equal(names.length, 97); }); test('pinned original sources remain present', async () => {