feat: improved the Claude Kit as a plugin

This commit is contained in:
duthaho
2026-04-19 14:09:14 +07:00
parent 3103a8da1b
commit d1a6d2a2bc
186 changed files with 771 additions and 1691 deletions
+359
View File
@@ -0,0 +1,359 @@
---
name: openapi
description: Use when designing, documenting, or generating REST APIs with OpenAPI 3.1 — including error contracts, pagination, versioning, auth schemes, request/response schemas, webhooks, or code-gen pipelines for FastAPI, Express, or NestJS. Also use when migrating a spec from OpenAPI 3.0 to 3.1.
---
# OpenAPI 3.1 & REST API Design
## Overview
A design-first reference for REST APIs developers want to use. Standardizes on **RFC 9457 Problem Details**, **camelCase JSON**, **cursor pagination**, and **URL-path versioning** — the 2026 consensus across Google, Microsoft, and the OpenAPI 3.1 ecosystem.
## When to Use
- Designing or documenting a new REST API
- Generating clients/servers from a spec (FastAPI, Express, NestJS, etc.)
- Establishing error, pagination, versioning, or auth conventions for a service
- Generating API endpoints, models, and tests from a resource specification
- Migrating a spec from OpenAPI 3.0 → 3.1
- Setting up lint/governance in CI
## When NOT to Use
- GraphQL APIs (different spec format)
- Internal scripts or CLI tools with no HTTP surface
- RPC-style services (gRPC, tRPC)
---
## Quick Reference
| I need... | Go to |
|-----------|-------|
| Starter spec to copy and adapt | [templates/openapi-3.1-starter.yaml](templates/openapi-3.1-starter.yaml) |
| Which HTTP status code? | [references/http-status-codes.md](references/http-status-codes.md) |
| URL naming & CRUD mapping | [references/rest-naming.md](references/rest-naming.md) |
| Linting, CI, docs, client gen, mock servers | [references/api-governance.md](references/api-governance.md) |
| Idempotency, rate limiting, ETag, webhook signing, async jobs | [references/production-patterns.md](references/production-patterns.md) |
| Error contract (Problem Details) | § Errors below |
| Pagination pattern | § Pagination below |
| Auth scheme | § Authentication below |
| OpenAPI 3.0 → 3.1 gotchas | § Migration Flags below |
---
## Conventions — the defaults this skill teaches
Pick different conventions only with explicit reason, and apply them **consistently across the whole API**. Mixed conventions within one API are the #1 integration pain point.
| Decision | Default | Why |
|----------|---------|-----|
| Error format | `application/problem+json` (**RFC 9457**) | 2023 successor to RFC 7807. Standard fields, machine-readable `type` URI, native support in Spring / .NET / FastAPI. |
| JSON field casing | **camelCase** | Matches the JS/TS ecosystem (the largest API-consumer population) and Google / Microsoft guidelines. |
| URL segment casing | lowercase plural **kebab-case** (`/user-profiles`) | RFC 3986 friendly, cache-friendly, unambiguous. |
| Query parameter casing | **camelCase** (`pageSize`, `createdAfter`) | Mirrors the JSON body — consumers reason about one casing, not two. |
| HTTP header casing | `Kebab-Case` (`X-Request-Id`, `Idempotency-Key`) | HTTP convention. |
| Pagination | **Cursor** for growable lists; offset only for small bounded sets | Cursor is stable under concurrent inserts/deletes and O(1) per page. |
| Versioning (public) | URL path (`/v1`, `/v2`) | Explicit, routable, CDN/cache-friendly. |
| Versioning (internal) | Date header (`X-Api-Version: 2026-06-01`) | Fine-grained evolution without URL churn. |
| ID format in JSON | `string` — UUID or prefixed slug (`usr_abc123`) | Avoids JS number precision loss; prefixed IDs aid debugging. |
| Timestamps | ISO 8601 / RFC 3339 string | Universal, sortable, timezone-aware. |
---
## Spec Structure
Skeleton of a well-organized OpenAPI 3.1 document. Split large specs with `$ref` and bundle with `redocly cli bundle` for tooling.
```yaml
openapi: 3.1.0
info:
title: Acme API
version: 2.0.0
contact: { name: API Support, email: api@acme.dev }
license: { name: MIT, identifier: MIT } # 3.1 SPDX identifier
servers:
- url: https://api.acme.dev/v2
- url: https://staging-api.acme.dev/v2
tags:
- { name: Users, description: User management }
- { name: Orders, description: Order lifecycle }
paths:
/users: { $ref: './paths/users.yaml' }
/users/{userId}: { $ref: './paths/users-by-id.yaml' }
components:
schemas: { $ref: './components/schemas/_index.yaml' }
securitySchemes:
BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
security:
- BearerAuth: []
webhooks:
orderCompleted: { $ref: './webhooks/order-completed.yaml' }
```
Recommended file layout:
```
spec/
├── openapi.yaml
├── paths/ # One file per resource
├── components/
│ └── schemas/ # Shared schemas
└── webhooks/ # Event payloads
```
For a complete runnable starter — CRUD, auth, cursor pagination, Problem Details, idempotency, rate-limit headers, and ETag concurrency — see [templates/openapi-3.1-starter.yaml](templates/openapi-3.1-starter.yaml).
---
## Path & Operation Patterns
- **Plural collections:** `/users`, `/orders`
- **Path params for identity:** `/users/{userId}`
- **Query params for filter / sort / paginate / expand**
- **Max 2 levels of nesting** — deeper is a smell, flatten it
- **Always set `operationId`** — code generators use it as the method name
- **Always set `tags` and `summary`** — docs UIs and linters require them
Full CRUD mapping table and URL rules: [references/rest-naming.md](references/rest-naming.md).
---
## Request Bodies
Use `additionalProperties: false` on request schemas so typos in payloads fail validation early instead of being silently dropped.
```yaml
CreateUserRequest:
type: object
required: [email, name]
additionalProperties: false
properties:
email: { type: string, format: email, maxLength: 254 }
name: { type: string, minLength: 1, maxLength: 100 }
role: { type: string, enum: [admin, member, viewer], default: member }
```
Framework mirrors:
- **FastAPI:** `pydantic.BaseModel` with `model_config = {"extra": "forbid"}`
- **Express + Zod:** `z.object({...}).strict()`
- **NestJS:** `class-validator` + `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
For uploads, use `multipart/form-data` with `type: string, format: binary` and document the accepted MIME types via `encoding.<field>.contentType`.
---
## Errors (Problem Details)
Return `application/problem+json` per **RFC 9457** for every 4xx / 5xx response. Define one shared `ProblemDetails` schema and reuse it across the spec via `$ref`.
```yaml
components:
schemas:
ProblemDetails:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri
description: URI reference identifying the problem type.
example: https://api.acme.dev/problems/validation-error
title: { type: string, example: "Validation failed" }
status: { type: integer, example: 422 }
detail: { type: string, example: "Field 'email' must be a valid email address." }
instance: { type: string, format: uri }
errors:
type: array
description: Field-level validation errors (extension member).
items:
type: object
required: [field, message]
properties:
field: { type: string, example: email }
message: { type: string }
code: { type: string, example: invalidFormat }
responses:
ValidationError:
description: Request validation failed
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
```
**Make `type` a real documentation URL.** The whole point of RFC 9457 is that `type` uniquely identifies the problem class and links to a human explanation. Using `about:blank` (the spec default) throws away 90% of the value.
**400 vs 422 — the line that matters:**
- `400` → malformed syntax. The request body is not valid JSON, a required field is missing, or a field has the wrong type. Detected by the parser/validator.
- `422` → semantically valid but violates business rules. Email already exists, state transition illegal, quota exceeded. Detected by application logic.
Full status-code catalog and decision flow: [references/http-status-codes.md](references/http-status-codes.md).
---
## Authentication
Pick one scheme, apply globally via top-level `security`, override per operation as needed.
```yaml
components:
securitySchemes:
BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
ApiKeyAuth: { type: apiKey, in: header, name: X-Api-Key }
OAuth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://auth.acme.dev/authorize
tokenUrl: https://auth.acme.dev/token
scopes:
"users:read": Read user profiles
"users:write": Create and update users
security:
- BearerAuth: []
paths:
/health:
get:
security: [] # Public endpoint — override global
responses: { '200': { description: Healthy } }
/users:
get:
security:
- OAuth2: ["users:read"]
```
---
## Pagination
**Default to cursor pagination** for any list that can grow. It is stable under concurrent inserts/deletes and O(1) per page regardless of depth.
```yaml
components:
parameters:
Cursor:
name: cursor
in: query
description: Opaque cursor from a previous response.
schema: { type: string }
Limit:
name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
schemas:
UserListResponse:
type: object
required: [data, pagination]
properties:
data:
type: array
items: { $ref: '#/components/schemas/User' }
pagination:
type: object
required: [hasMore]
properties:
nextCursor: { type: [string, "null"] } # JSON Schema 2020-12 null union
hasMore: { type: boolean }
```
**Use offset pagination only** for small, bounded collections (< ~10k rows) where users need to jump to specific page numbers. Offset drifts when rows are inserted/deleted between requests and scales O(n) in the skipped-row count.
**Always enforce a max `limit`.** "We'll bound it later" never happens before the incident.
---
## Versioning
**Public APIs → URL path** (`/v1`, `/v2`). Simple, explicit, routable by CDN/gateway. This is the current mainstream choice (Google, Stripe, GitHub).
**Internal APIs → Date header** (`X-Api-Version: 2026-06-01`). Fine-grained, no URL churn, easier to deprecate individual fields.
Bump the major version when you break backward compatibility: remove a field, change a type, change an error shape. Everything additive (new fields, new endpoints) stays on the same version.
**Never ship without a version.** Adding one later is painful.
---
## Webhooks
OpenAPI 3.1 introduces top-level `webhooks` for outbound events — a first-class replacement for the old `callbacks` workaround.
```yaml
webhooks:
orderCompleted:
post:
operationId: onOrderCompleted
summary: Fired when an order reaches "completed" state.
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/OrderCompletedEvent' }
responses:
'2XX': { description: Webhook received successfully. }
components:
schemas:
WebhookEventBase:
type: object
required: [id, type, createdAt]
properties:
id: { type: string, format: uuid }
type: { type: string }
createdAt: { type: string, format: date-time }
OrderCompletedEvent:
allOf:
- $ref: '#/components/schemas/WebhookEventBase'
- type: object
properties:
type: { const: order.completed }
data:
type: object
properties:
orderId: { type: string, format: uuid }
total: { type: number, format: double }
```
Consumers should respond with any 2xx within a few seconds; you retry non-2xx with exponential backoff. **Sign payloads** (HMAC over timestamp + body) so consumers can verify authenticity and reject replays — full pattern with working signing/verification code in [references/production-patterns.md](references/production-patterns.md).
---
## OpenAPI 3.0 → 3.1 Migration Flags
The most common mistakes when moving a spec from 3.0 to 3.1:
| 3.0 (wrong in 3.1) | 3.1 (correct) |
|--------------------|---------------|
| `nullable: true` | `type: [string, "null"]``nullable` is **silently ignored** in 3.1 |
| `example: ...` on schema | `examples: [...]` — now an array |
| `exclusiveMinimum: true` + `minimum: 0` | `exclusiveMinimum: 0` — now a numeric value |
| `$ref` cannot have sibling keywords | `$ref` can sit next to `description`, `summary`, etc. |
| `type: integer, format: int64` for long IDs | `type: string` — JS `Number` loses precision above 2^53 |
3.1 is full **JSON Schema 2020-12**: use `oneOf` + `discriminator` for polymorphism, and `if` / `then` / `else` for conditional validation.
---
## Common Pitfalls
1. **Undocumented error responses.** Every operation should list its possible 4xx/5xx codes. At minimum: `400`, `401`, `403`, `404`, `422`, `500`. Consumers cannot handle errors they don't know about.
2. **Inline schemas instead of `$ref`.** Kills SDK quality — generators produce names like `UsersPost200Response`. Put shared shapes under `components/schemas`.
3. **Missing `operationId`.** Generators fall back to `userGet1`, `userGet2`. Every operation needs an `operationId`.
4. **Unbounded list endpoints.** No `limit` cap = OOM or timeout the day traffic doubles.
5. **Mixed casing.** camelCase and snake_case inside the same API confuses every consumer. Enforce with `spectral` or `vacuum`.
6. **`nullable: true` in a 3.1 document.** Silently ignored. Use the JSON Schema null union (`type: [T, "null"]`).
7. **Ambiguous `oneOf` without `discriminator`.** Clients cannot reliably deserialize polymorphic payloads.
8. **Deeply nested URLs.** `/users/{uid}/orders/{oid}/items/{iid}/notes` is fragile and uncacheable. Flatten once the parent relationship is established.
9. **Using `about:blank` for Problem Details `type`.** Wastes the main benefit of RFC 9457 — link to your real docs.
10. **No linter in CI.** Specs drift. Run `redocly lint`, `spectral lint`, or `vacuum` on every PR from day one. For a ready-to-copy Spectral ruleset, GitHub Actions workflow, and a breaking-change diff step, see [references/api-governance.md](references/api-governance.md).
11. **No idempotency on side-effecting POSTs.** A lost response = duplicate charge / duplicate email. Accept an `Idempotency-Key` header and store the *response* keyed by it. Full pattern: [references/production-patterns.md](references/production-patterns.md).
---
## Related Skills
- `api-client` — consuming and generating API clients from specs
- `error-handling` — consistent error handling in consumer code
- `fastapi` — FastAPI's built-in OpenAPI generation
+274
View File
@@ -0,0 +1,274 @@
# API Governance & Tooling
Linters, docs generators, client generators, mock servers, and contract testing tools for an OpenAPI-based workflow. Focus on what is actually used in 2026.
---
## Why governance matters
Specs drift. A hand-written spec that is not linted in CI will accumulate missing `operationId`s, inline schemas, undocumented errors, and inconsistent naming within weeks. A generated spec (from FastAPI/NestJS) will drift toward whatever the framework emits, which is rarely idiomatic.
**Minimum bar for any new API:**
1. Spec lives in version control
2. A linter runs on every PR
3. Docs regenerate on merge to main
4. At least one generated client (or contract test) catches breaking changes
---
## Linters
All three below consume the same Spectral-compatible rule format.
| Tool | Language | Speed | When to pick |
|------|----------|-------|--------------|
| **Spectral** (Stoplight) | Node | Moderate | Default choice. Largest rule ecosystem, first-party Redocly + Zalando rulesets. |
| **Redocly CLI** | Node | Moderate | Pick if you also want `bundle`, `split`, and `build-docs` in one tool. Ships its own opinionated ruleset. |
| **Vacuum** (daveshanley) | Go | 1020× faster | Pick for large specs (500+ operations) or monorepo CI where seconds matter. Drop-in Spectral rule compatibility. |
### Minimum Spectral ruleset
Save as `.spectral.yaml` in the spec directory:
```yaml
extends:
- spectral:oas # Base OpenAPI rules
- spectral:asyncapi # If you mix in AsyncAPI
rules:
# Every operation must be identified, tagged, and summarized
operation-operationId: error
operation-operationId-unique: error
operation-tags: error
operation-summary: error
operation-description: warn
# Schemas must be referenced, not inlined
no-inline-schemas:
description: Request/response bodies must $ref a named schema.
severity: error
given: "$.paths.*.*.requestBody.content.*.schema"
then:
field: "$ref"
function: truthy
# No 3.0-isms in a 3.1 document
no-nullable:
description: "Use type: [T, 'null'] instead of nullable: true in 3.1."
severity: error
given: "$..nullable"
then:
function: falsy
# Enforce camelCase on JSON properties
camel-case-properties:
description: Property names must be camelCase.
severity: error
given: "$.components.schemas..properties[*]~"
then:
function: pattern
functionOptions:
match: "^[a-z][a-zA-Z0-9]*$"
# Every operation declares at least one 4xx and one 5xx response
operation-4xx-response:
severity: error
given: "$.paths.*.*.responses"
then:
function: schema
functionOptions:
schema:
type: object
patternProperties:
"^4\\d\\d$": {}
minProperties: 1
# Error bodies use application/problem+json
error-uses-problem-json:
description: 4xx/5xx responses must use application/problem+json.
severity: warn
given: "$.paths.*.*.responses[?(@property.match(/^[45]\\d\\d$/))].content"
then:
field: "application/problem+json"
function: truthy
```
### GitHub Actions CI snippet
```yaml
# .github/workflows/api-spec.yml
name: API spec
on:
pull_request:
paths: ['spec/**']
push:
branches: [main]
paths: ['spec/**']
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Bundle spec
run: npx @redocly/cli@latest bundle spec/openapi.yaml -o spec/openapi.bundled.yaml
- name: Lint with Spectral
run: npx @stoplight/spectral-cli@latest lint spec/openapi.bundled.yaml --fail-severity=error
- name: Detect breaking changes vs main
if: github.event_name == 'pull_request'
run: |
git fetch origin main
npx @redocly/cli@latest diff origin/main:spec/openapi.yaml spec/openapi.yaml --fail-on=breaking
```
The `diff --fail-on=breaking` step blocks PRs that remove fields, change types, or rename operations — the most common accidental breakages.
---
## Docs Generators
| Tool | Style | When to pick |
|------|-------|--------------|
| **Scalar** | Modern three-column with built-in REST client | Default choice for new projects. Fast, polished, open source. |
| **Redoc** | Classic three-column reference (Stripe-like) | Pick when you want the most battle-tested static docs. Works offline. |
| **Redocly Portal** | Hosted docs with analytics, try-it, versioning | Pick when you need a revenue-class docs portal. Paid. |
| **Swagger UI** | Interactive try-it | Pick only for internal/debug dashboards. Aesthetics lag behind Scalar/Redoc. |
### Scalar (recommended default)
```html
<!-- docs.html -->
<!doctype html>
<html>
<head><title>My API</title></head>
<body>
<script id="api-reference" data-url="/openapi.yaml"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>
```
### Redoc (static build)
```bash
npx @redocly/cli build-docs spec/openapi.yaml -o dist/api.html
```
Deploy the single HTML file to any static host (Cloudflare Pages, S3, GitHub Pages).
---
## Client Generation
| Tool | Targets | When to pick |
|------|---------|--------------|
| **Kubb** | TypeScript (Zod, TanStack Query, SWR, MSW, Axios, Fetch) | Default for 2026 frontend. Plugin-based, generates exactly what you want, no framework bloat. |
| **Orval** | TypeScript (React Query, SWR, Zod, MSW, Axios) | Close alternative to Kubb. Pick if you prefer a single-config approach. |
| **openapi-generator** | 30+ languages (Python, Go, Java, Kotlin, Ruby, Rust, etc.) | Default for non-TypeScript languages. The workhorse, but generated code is heavier. |
| **openapi-ts** (hey-api) | TypeScript only, lightweight | Pick when you want a minimal fetch wrapper with full types and zero framework coupling. |
### Kubb starter (TypeScript + TanStack Query + Zod)
```ts
// kubb.config.ts
import { defineConfig } from '@kubb/core'
import { pluginOas } from '@kubb/plugin-oas'
import { pluginTs } from '@kubb/plugin-ts'
import { pluginZod } from '@kubb/plugin-zod'
import { pluginClient } from '@kubb/plugin-client'
import { pluginReactQuery } from '@kubb/plugin-react-query'
export default defineConfig({
input: { path: './spec/openapi.yaml' },
output: { path: './src/api/generated', clean: true },
plugins: [
pluginOas(),
pluginTs(),
pluginZod(),
pluginClient({ importPath: '../client.ts' }),
pluginReactQuery(),
],
})
```
### openapi-generator (Python / Go / Java / etc.)
```bash
npx @openapitools/openapi-generator-cli generate \
-i spec/openapi.yaml \
-g python \
-o clients/python \
--additional-properties=packageName=acme_client,library=asyncio
```
---
## Mock Servers
| Tool | When to pick |
|------|--------------|
| **Prism** (Stoplight) | Run a mock server directly from your spec. Validates requests against the schema and returns examples. Best for frontend dev against an unfinished backend. |
| **MSW** (Mock Service Worker) | Runs in the browser/Node for testing client code. Pair with Kubb's `@kubb/plugin-msw` to generate handlers from the spec. |
### Prism starter
```bash
npx @stoplight/prism-cli mock spec/openapi.yaml --port 4010
# Server at http://127.0.0.1:4010 responds based on the spec examples
```
Add `--errors` to make Prism return the declared error responses when the request is invalid, useful for exercising error paths.
---
## Contract Testing
Tools that verify the running implementation still matches the spec.
| Tool | Approach | When to pick |
|------|----------|--------------|
| **Schemathesis** | Property-based fuzzing driven by the spec | Best signal per line of setup. Catches unhandled edge cases the developer never thought to test. |
| **Dredd** | Replays documented examples against the server | Simple smoke-test. Good for regression on happy paths. |
| **Pact** | Consumer-driven contracts (not spec-first) | Pick when consumers write the contracts rather than deriving from the server's OpenAPI. |
### Schemathesis in CI
```bash
pipx install schemathesis
schemathesis run spec/openapi.yaml \
--base-url=http://localhost:3000/v1 \
--checks=all \
--hypothesis-max-examples=50
```
Runs ~50 generated requests per operation and checks: status code validity, response schema conformance, `Content-Type` match, and absence of server errors (5xx).
---
## Governance checklist
Before calling an API "production-ready":
- [ ] Spec is in version control alongside the code
- [ ] Spec is bundled (`redocly bundle`) and the bundled artifact is linted
- [ ] Spectral (or equivalent) runs on every PR and blocks on errors
- [ ] A breaking-change check runs on every PR (`redocly diff` or `oasdiff`)
- [ ] Every operation has `operationId`, `tags`, `summary`, at least one `4xx` and at least one `5xx` response
- [ ] Docs are regenerated on merge to main (Scalar, Redoc, or portal)
- [ ] At least one generated client is compiled in CI — proves the spec is consumable
- [ ] Contract tests run against a deployed preview before merge
- [ ] A mock server (Prism) is available for consumer development
---
## Related
- [rest-naming.md](rest-naming.md) — URL and naming conventions
- [http-status-codes.md](http-status-codes.md) — status code selection
- [production-patterns.md](production-patterns.md) — idempotency, rate limiting, ETags, webhook signing
- [openapi.tools](https://openapi.tools/) — community catalog of all OpenAPI tools
@@ -0,0 +1,191 @@
# HTTP Status Codes for REST APIs
Quick reference for selecting the correct HTTP status code in REST API responses.
---
## 2xx Success
| Code | Name | When to Use |
|------|------|-------------|
| `200` | OK | General success. GET returns data, PUT/PATCH returns updated resource. |
| `201` | Created | POST successfully created a resource. Include `Location` header. |
| `202` | Accepted | Request accepted for async processing. Return a job/task ID. |
| `204` | No Content | DELETE success or PUT/PATCH with no response body needed. |
**Guidelines:**
- `200` is the default success response for GET, PUT, PATCH
- `201` must be used when a new resource is created (POST)
- `204` is preferred for DELETE (no body to return)
- `202` signals "we got it, processing later" -- return a status URL
```json
// 201 Created response
{
"id": "usr_abc123",
"name": "Jane Doe",
"createdAt": "2026-01-15T10:30:00Z"
}
// Header: Location: /v1/users/usr_abc123
```
---
## 3xx Redirection
| Code | Name | When to Use |
|------|------|-------------|
| `301` | Moved Permanently | Resource URL changed permanently. Clients should update bookmarks. |
| `302` | Found | Temporary redirect. Original URL still valid. |
| `304` | Not Modified | Conditional GET -- resource unchanged since `If-None-Match`/`If-Modified-Since`. |
| `307` | Temporary Redirect | Like 302 but preserves HTTP method. Use for API redirects. |
| `308` | Permanent Redirect | Like 301 but preserves HTTP method. |
**Guidelines:**
- Prefer `307`/`308` over `302`/`301` in APIs (method preservation)
- `304` reduces bandwidth when clients cache responses
- Always include `Location` header with redirect responses
---
## 4xx Client Errors
| Code | Name | When to Use |
|------|------|-------------|
| `400` | Bad Request | Malformed syntax, invalid JSON, failed validation. |
| `401` | Unauthorized | Missing or invalid authentication credentials. |
| `403` | Forbidden | Authenticated but lacks permission for this resource. |
| `404` | Not Found | Resource does not exist at this URL. |
| `405` | Method Not Allowed | HTTP method not supported on this endpoint. |
| `409` | Conflict | Request conflicts with current state (duplicate, version mismatch). |
| `410` | Gone | Resource existed but has been permanently deleted. |
| `415` | Unsupported Media Type | Content-Type header not supported. |
| `422` | Unprocessable Entity | Valid JSON but semantically invalid (business rule violation). |
| `429` | Too Many Requests | Rate limit exceeded. Include `Retry-After` header. |
**Guidelines:**
- `400` for **structural** issues (bad JSON, missing required field, wrong type) — caught by the parser/validator.
- `422` for **semantic** failures (email already taken, invalid state transition, quota exceeded) — caught by application logic.
- `401` means "who are you?" — `403` means "I know who you are, but no".
- `409` for optimistic-locking failures and unique-constraint violations.
- `412` for `If-Match` / `If-Unmodified-Since` precondition failures (ETag concurrency).
- `429` must include `Retry-After` with seconds until retry.
**400 vs 422 decision rule:** If the request body failed to parse or a required field is missing, return `400`. If the body parsed fine and every field has the right type but the combination violates a business rule, return `422`.
All error bodies use `application/problem+json` per **RFC 9457**.
```json
// 422 Unprocessable Entity
// Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "Request validation failed.",
"errors": [
{ "field": "email", "message": "Email already registered", "code": "conflict" },
{ "field": "age", "message": "Must be 18 or older", "code": "outOfRange" }
]
}
```
```json
// 429 Too Many Requests
// Headers: Retry-After: 60
// Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/rate-limited",
"title": "Too many requests",
"status": 429,
"detail": "Rate limit exceeded. Retry after 60s."
}
```
---
## 5xx Server Errors
| Code | Name | When to Use |
|------|------|-------------|
| `500` | Internal Server Error | Unhandled exception. Generic server failure. |
| `501` | Not Implemented | Endpoint exists but functionality not built yet. |
| `502` | Bad Gateway | Upstream service returned invalid response. |
| `503` | Service Unavailable | Server overloaded or in maintenance. Include `Retry-After`. |
| `504` | Gateway Timeout | Upstream service did not respond in time. |
**Guidelines:**
- `500` should never expose stack traces in production
- `503` should include `Retry-After` header and a maintenance message
- Log all 5xx errors with request context for debugging
- Return a consistent error body format for all 5xx responses
```json
// 500 Internal Server Error (production)
// Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/internal-error",
"title": "Internal server error",
"status": 500,
"detail": "An unexpected error occurred. Please try again.",
"instance": "/v1/users/usr_abc123",
"requestId": "req_7f3a9b2c"
}
```
Extension members (`requestId`, `traceId`, etc.) are encouraged by RFC 9457 — include anything that helps the caller report the bug.
---
## Decision Flowchart
```
Request received
|
+-- Is it valid syntax? -- NO --> 400 Bad Request
|
+-- Is caller authenticated? -- NO --> 401 Unauthorized
|
+-- Is caller authorized? -- NO --> 403 Forbidden
|
+-- Does resource exist? -- NO --> 404 Not Found
|
+-- Is it rate-limited? -- YES --> 429 Too Many Requests
|
+-- If-Match / If-Unmodified-Since fails? -- YES --> 412 Precondition Failed
|
+-- Does it pass business rules? -- NO --> 422 Unprocessable Entity
|
+-- Any conflicts? -- YES --> 409 Conflict
|
+-- Server error? -- YES --> 500 Internal Server Error
|
+-- Success!
GET --> 200 OK
POST --> 201 Created
PUT --> 200 OK
PATCH --> 200 OK
DELETE --> 204 No Content
```
---
## Standard Error Response Format — RFC 9457 Problem Details
Every error response uses the `application/problem+json` media type with this shape:
```json
{
"type": "https://api.example.com/problems/<problem-slug>",
"title": "Short human-readable summary",
"status": 422,
"detail": "Human-readable explanation for this occurrence.",
"instance": "/v1/users/usr_abc123",
"errors": [ /* optional: field-level validation breakdown */ ],
"requestId": "req_..."
}
```
Required fields: `type`, `title`, `status`. Everything else is optional but strongly recommended. The `type` URI should resolve to a real documentation page — that is the core benefit of RFC 9457 over ad-hoc envelopes.
*Reference: [RFC 9110 — HTTP Semantics](https://httpwg.org/specs/rfc9110.html), [RFC 9457 — Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)*
@@ -0,0 +1,417 @@
# Production-Grade API Patterns
Four patterns that separate hobby APIs from APIs developers actually trust in production: **idempotency keys**, **rate limiting**, **optimistic concurrency (ETag)**, and **webhook signing**. Plus the **async/202** pattern for long-running work.
Each section has: what it is, why it matters, the HTTP contract, and server-side pseudocode.
---
## 1. Idempotency Keys
**Problem.** A client calls `POST /payments` to charge $50. The request succeeds on the server but the response is lost to a network blip. The client retries. Without idempotency, the customer is charged twice.
**Solution.** The client generates a UUID per logical operation and sends it as an `Idempotency-Key` header. The server stores the full response keyed by `(idempotencyKey, userId)` for a TTL window (Stripe uses 24h). Any retry with the same key returns the stored response — no re-execution.
**Key insight — store the *response*, not the request.** Replaying the operation on retry defeats the purpose. You must serve the exact bytes the original call produced, including the status code, headers, and body. Otherwise a second worker racing the first will see inconsistent state.
### HTTP contract
```
POST /v1/payments
Idempotency-Key: 0f6f7a7d-1c6a-4a1e-9d1a-4f2a1b3c4d5e
Authorization: Bearer <token>
Content-Type: application/json
{ "amount": 5000, "currency": "usd", "customerId": "cus_abc" }
```
Server responses:
- **First call:** process normally, store `(key, response)`, return `201 Created` + `Payment` body.
- **Retry (same key, same body):** return the stored response verbatim. Include header `Idempotent-Replayed: true` so clients can log the replay.
- **Retry (same key, different body):** return `422 Unprocessable Entity` with `type: .../problems/idempotency-conflict`. The key was reused for a different payload — that is a client bug.
- **Retry during in-flight processing:** return `409 Conflict` so the client backs off and retries later. Acquire a lock on `(key)` before starting work.
### Server-side pseudocode
```python
async def create_payment(req: Request, key: str | None, user: User):
if key is None:
# Idempotency optional, but log the risk.
return await process_payment(req, user)
record = await idempotency_store.get(key, user.id)
if record:
if record.request_hash != hash(req.body):
raise ProblemDetail(422, "idempotency-conflict",
"Key reused with a different request body.")
if record.status == "in_progress":
raise ProblemDetail(409, "idempotency-in-progress",
"Original request still processing.")
return replay(record.response) # exact bytes
# Claim the key atomically — losing this race returns 409
claimed = await idempotency_store.claim(key, user.id, hash(req.body))
if not claimed:
raise ProblemDetail(409, "idempotency-in-progress",
"Original request still processing.")
try:
response = await process_payment(req, user)
await idempotency_store.save(key, user.id, response, ttl=24h)
return response
except Exception as e:
# Release the claim so the client can retry cleanly.
await idempotency_store.release(key, user.id)
raise
```
**Storage:** Redis with a 24h TTL is the standard choice. Use a Redis transaction (`WATCH`/`MULTI`) or `SETNX` for the claim step to avoid races.
**Scope:** key by `(idempotencyKey, apiKeyId)` so keys from one tenant never collide with another.
**Apply to:** all `POST` and `PATCH` that create or mutate resources with side effects (billing, emails, external API calls). Pure `GET`/`HEAD` is already idempotent; `PUT` and `DELETE` are idempotent by HTTP semantics but still benefit from replay protection for in-flight retries.
---
## 2. Rate Limiting
**Problem.** One misbehaving client floods your API and degrades everyone else. Without limits, a single bug can take the service down.
**Solution.** Bound requests per client per time window, return `429 Too Many Requests` when exceeded, and publish headers on every response so well-behaved clients can self-throttle before they hit the wall.
### Algorithms
| Algorithm | Burst behavior | Memory per key | When to pick |
|-----------|---------------|----------------|--------------|
| **Fixed window** | Allows 2× burst at window boundary | O(1) | Simple, cheap. Acceptable for soft limits. |
| **Sliding window log** | Smooth | O(n) per key | Expensive but precise. Use for billing-grade metering. |
| **Sliding window counter** | Near-smooth | O(1) | **Default choice.** Good accuracy, constant memory. |
| **Token bucket** | Configurable burst | O(1) | Use when you want to allow small bursts but bound sustained rate. Common for customer-facing APIs. |
Redis + sliding-window-counter is the pragmatic default.
### HTTP contract
**Every successful response:**
```
X-RateLimit-Limit: 1000 # quota for this window
X-RateLimit-Remaining: 942 # how many calls left
X-RateLimit-Reset: 1767225600 # unix seconds when the window resets
```
**429 response:**
```
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 60
{
"type": "https://api.example.com/problems/rate-limited",
"title": "Too many requests",
"status": 429,
"detail": "Rate limit of 1000/hour exceeded. Retry after 60s."
}
```
**`Retry-After` is mandatory on 429.** Clients use it to schedule the retry. Use seconds (integer) rather than HTTP-date — simpler, no clock-skew bugs.
### Server-side pseudocode (Redis sliding-window counter)
```python
# Pseudocode — use a battle-tested library (redis-rate-limit, slowapi, limits)
# rather than hand-rolling this in production.
async def enforce_rate_limit(key: str, limit: int, window_seconds: int) -> RateLimitResult:
now = int(time.time())
window_start = now - (now % window_seconds)
prev_window_start = window_start - window_seconds
# Atomic increment + get prior-window count
with redis.pipeline(transaction=True) as p:
p.incr(f"rl:{key}:{window_start}")
p.expire(f"rl:{key}:{window_start}", window_seconds * 2)
p.get(f"rl:{key}:{prev_window_start}")
curr, _, prev = p.execute()
# Weight the prior window by how much of the current window has elapsed
elapsed_fraction = (now % window_seconds) / window_seconds
weighted = int((int(prev or 0)) * (1 - elapsed_fraction)) + int(curr)
remaining = max(0, limit - weighted)
reset_at = window_start + window_seconds
if weighted > limit:
return RateLimitResult(
allowed=False,
retry_after=reset_at - now,
limit=limit, remaining=0, reset_at=reset_at,
)
return RateLimitResult(
allowed=True,
limit=limit, remaining=remaining, reset_at=reset_at,
)
```
**Key strategy:** by authenticated principal (`userId` or `apiKeyId`), not by IP. IP-based limiting punishes users behind corporate NATs.
**Tiers:** different limits for anonymous / free / paid is common. Store the tier on the auth token and look up the limit at enforcement time — don't hard-code.
**Where to enforce:** at the edge (gateway/middleware) before hitting your application logic. An API gateway (Kong, Envoy, Cloudflare) handles this natively if you use one.
---
## 3. Optimistic Concurrency (ETag + If-Match)
**Problem.** Alice and Bob both load the same user record. Alice updates the email, Bob updates the name. If both `PATCH` without coordination, the second write silently overwrites the first ("lost update").
**Solution.** On `GET`, return an `ETag` header — an opaque version token. On `PATCH`, require the client to echo that ETag in `If-Match`. If the server's current ETag doesn't match, return `412 Precondition Failed` and the client must refetch and retry.
### ETag generation strategies
| Strategy | Pros | Cons |
|----------|------|------|
| Version counter (`v42`) | Trivial to compare | Needs a `version` column on every row |
| `updated_at` timestamp | No schema change | Millisecond precision may collide on bulk updates |
| Hash of body (`"a1b2c3"`) | Stateless | Recomputed on every GET |
| Database row version | Cheap, natural fit | ORM-dependent |
All work. Pick whatever matches your data layer.
**Weak vs strong:** `ETag: W/"..."` for weak (semantically equivalent), `ETag: "..."` for strong (byte-identical). Use strong ETags unless you need to support content negotiation.
### HTTP contract
```
GET /v1/users/usr_abc123 HTTP/1.1
Authorization: Bearer ...
HTTP/1.1 200 OK
ETag: "v42"
Content-Type: application/json
{ "id": "usr_abc123", "name": "Alice", "email": "alice@example.com", ... }
```
```
PATCH /v1/users/usr_abc123 HTTP/1.1
If-Match: "v42"
Content-Type: application/json
{ "email": "alice@new.example.com" }
```
**Success:**
```
HTTP/1.1 200 OK
ETag: "v43"
{ ... updated user ... }
```
**Conflict — Bob's write races Alice's:**
```
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/precondition-failed",
"title": "Precondition failed",
"status": 412,
"detail": "The resource was modified since you last fetched it. Re-fetch and retry."
}
```
### Server-side pseudocode
```python
async def update_user(user_id: str, body: dict, if_match: str | None):
current = await users.get(user_id)
if current is None:
raise ProblemDetail(404, "not-found", f"User '{user_id}' not found.")
if if_match is None:
raise ProblemDetail(428, "precondition-required",
"If-Match header is required for this operation.")
current_etag = f'"v{current.version}"'
if if_match != current_etag:
raise ProblemDetail(412, "precondition-failed",
"The resource was modified since you last fetched it.")
updated = await users.patch(user_id, body, expected_version=current.version)
return updated, f'"v{updated.version}"'
```
**`428 Precondition Required`** is the correct response when the server *requires* `If-Match` and the client didn't send one. RFC 6585.
**Also useful for GETs:** `If-None-Match: "v42"` lets clients skip the body and get `304 Not Modified` if nothing changed — cheap cache revalidation.
---
## 4. Webhook Signing (HMAC)
**Problem.** You deliver an event to a consumer URL. Without a signature, anyone who guesses the URL can forge events. Without a timestamp, an attacker who captures one valid payload can replay it forever.
**Solution.** Sign every webhook with HMAC-SHA256 over `timestamp + "." + body`, send both in a header, and require consumers to reject signatures older than 5 minutes.
### HTTP contract
```
POST https://consumer.example.com/webhooks/acme HTTP/1.1
Content-Type: application/json
Acme-Signature: t=1767225600,v1=5257a869e7...7f4b
Acme-Webhook-Id: evt_01HTZ4K5M8N9P0Q1R2S3T4V5W6
{
"id": "evt_01HTZ4K5M8N9P0Q1R2S3T4V5W6",
"type": "order.completed",
"createdAt": "2026-04-15T10:30:00Z",
"data": { "orderId": "ord_xyz", "total": 4999, "currency": "usd" }
}
```
**`t=` is the unix timestamp when the signature was generated.**
**`v1=` is the hex-encoded HMAC-SHA256 of `t + "." + rawBody` using the consumer's signing secret.**
The version prefix (`v1=`) lets you rotate signing schemes in the future without breaking existing consumers.
### Server-side signing
```python
import hmac, hashlib, time, json
def sign_webhook(raw_body: bytes, secret: str) -> str:
t = int(time.time())
payload = f"{t}.".encode() + raw_body
sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return f"t={t},v1={sig}"
async def deliver(endpoint: Endpoint, event: dict):
raw = json.dumps(event, separators=(",", ":")).encode()
signature = sign_webhook(raw, endpoint.signing_secret)
await http.post(
endpoint.url,
content=raw,
headers={
"Content-Type": "application/json",
"Acme-Signature": signature,
"Acme-Webhook-Id": event["id"],
},
)
```
### Consumer-side verification (for your docs)
```python
import hmac, hashlib, time
MAX_AGE_SECONDS = 300 # 5 minutes
def verify_webhook(raw_body: bytes, header: str, secret: str) -> dict:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
sig = parts["v1"]
# Replay protection — reject anything older than MAX_AGE
if abs(time.time() - t) > MAX_AGE_SECONDS:
raise SignatureError("Timestamp outside tolerance window.")
expected = hmac.new(
secret.encode(),
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
# Constant-time compare — prevents timing attacks
if not hmac.compare_digest(expected, sig):
raise SignatureError("Signature mismatch.")
return json.loads(raw_body)
```
**Three non-negotiables:**
1. **Sign `timestamp + body`, not just body.** Without the timestamp, replay protection is impossible.
2. **Use constant-time comparison (`hmac.compare_digest`).** Never `==`. Side-channel leaks.
3. **Verify against the raw body bytes**, not a parsed-and-reserialized version. JSON serializers don't roundtrip byte-for-byte.
### Retry and dedup
- Retry on any non-2xx response with exponential backoff: 1m, 5m, 15m, 1h, 6h, 24h (cap at ~24h total).
- Include a unique event `id` in every payload; consumers must dedupe on it (retries will re-send the same `id`).
- Consumer must respond within ~5s with any 2xx. Do the actual work in a background job.
---
## 5. Async Long-Running Operations (202 Accepted)
**Problem.** Generating a report takes 30 seconds. You can't hold an HTTP connection that long — load balancers kill it, clients time out.
**Solution.** Return `202 Accepted` immediately with a `Location` header pointing to a status resource. The client polls (or subscribes to a webhook) until the job completes.
### HTTP contract
```
POST /v1/reports HTTP/1.1
Content-Type: application/json
{ "type": "sales", "startDate": "2026-01-01", "endDate": "2026-03-31" }
```
```
HTTP/1.1 202 Accepted
Location: /v1/jobs/job_01HTZ9K5M8N9P0Q1R2S3T4V5W6
Retry-After: 5
{
"id": "job_01HTZ9K5M8N9P0Q1R2S3T4V5W6",
"status": "queued",
"createdAt": "2026-04-15T10:30:00Z"
}
```
```
GET /v1/jobs/job_01HTZ9K5M8N9P0Q1R2S3T4V5W6 HTTP/1.1
HTTP/1.1 200 OK
{
"id": "job_01HTZ9K5M8N9P0Q1R2S3T4V5W6",
"status": "completed",
"createdAt": "2026-04-15T10:30:00Z",
"completedAt":"2026-04-15T10:30:32Z",
"result": { "reportUrl": "https://cdn.example.com/reports/xyz.csv" }
}
```
**Job states:** `queued``running``completed` | `failed` | `cancelled`.
On `failed`, embed a `ProblemDetails` object in the job body under `error` so the client gets structured failure info without a separate endpoint.
**Webhook option:** let the client register a callback URL on the job creation (`"callbackUrl": "https://..."`) and deliver a webhook when the job terminates, following the signing pattern above. Saves polling and is what mature APIs offer as the default.
---
## Applying this in OpenAPI
The starter template [openapi-3.1-starter.yaml](../templates/openapi-3.1-starter.yaml) already demonstrates four of these patterns on the `/users` endpoints:
| Pattern | Where in the template |
|---------|----------------------|
| Idempotency keys | `POST /users``IdempotencyKeyHeader` parameter |
| Rate limit headers | `GET /users` responses → `X-RateLimit-*` header refs |
| ETag + If-Match | `GET` + `PATCH /users/{userId}``ETag` header, `If-Match` param, `412` response |
| Problem Details errors | All `4xx`/`5xx` responses use `application/problem+json` |
Copy the relevant `parameters`, `headers`, and `responses` blocks into your own spec.
---
## Related
- [http-status-codes.md](http-status-codes.md) — 202, 409, 412, 428, 429 selection rules
- [rest-naming.md](rest-naming.md) — URL conventions
- [api-governance.md](api-governance.md) — linting, docs, client gen, contract testing
- [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) — Problem Details for HTTP APIs
- [Stripe: Designing APIs with Idempotency](https://stripe.com/blog/idempotency) — the canonical write-up
+214
View File
@@ -0,0 +1,214 @@
# REST API Naming Conventions
Guidelines for consistent, predictable REST endpoint design.
---
## Core Rules
1. **Use plural nouns** for resource collections
2. **Use kebab-case** for multi-word URL segments (`/user-profiles`)
3. **Use path parameters** for identity, query parameters for filtering
4. **Never use verbs** in URLs — HTTP methods convey the action
5. **Use lowercase** in URL path segments
6. **Use camelCase** in JSON bodies and query parameter names (`pageSize`, `createdAfter`) — matches the JS/TS ecosystem
---
## Resource Naming
| Pattern | Example | Notes |
|---------|---------|-------|
| Collection | `/users` | Plural noun |
| Single resource | `/users/{id}` | Path parameter |
| Multi-word resource | `/order-items` | Kebab-case |
| Nested resource | `/users/{id}/orders` | Parent-child relationship |
| Deep nesting (avoid) | `/users/{id}/orders/{oid}/items` | Max 2 levels deep |
| Singleton sub-resource | `/users/{id}/profile` | One-to-one relationship |
### Good
```
GET /users
GET /users/123
POST /users
PUT /users/123
DELETE /users/123
GET /users/123/orders
GET /order-items
GET /user-profiles/123
```
### Bad
```
GET /getUsers # verb in URL
GET /user/123 # singular collection
GET /Users # uppercase
POST /users/create # redundant verb
GET /user_profiles # snake_case (use kebab-case in paths)
GET /userProfiles # camelCase (use kebab-case in paths)
DELETE /users/123/delete # verb in URL
```
---
## CRUD Mapping
| Action | Method | Endpoint | Request Body | Response |
|--------|--------|----------|-------------|----------|
| List | `GET` | `/resources` | None | `200` + array |
| Create | `POST` | `/resources` | Resource data | `201` + created |
| Read | `GET` | `/resources/{id}` | None | `200` + object |
| Update (full) | `PUT` | `/resources/{id}` | Full resource | `200` + updated |
| Update (partial) | `PATCH` | `/resources/{id}` | Partial data | `200` + updated |
| Delete | `DELETE` | `/resources/{id}` | None | `204` |
---
## Nested Resources
Use nesting to express clear parent-child relationships.
```
# User's orders (user owns orders)
GET /users/{userId}/orders
POST /users/{userId}/orders
# Order's line items
GET /orders/{orderId}/items
```
**When to nest vs. top-level:**
| Scenario | Approach | Example |
|----------|----------|---------|
| Resource only exists under parent | Nest | `/users/{id}/sessions` |
| Resource is independently accessible | Top-level with filter | `/orders?user_id=123` |
| Shallow relationship | Top-level | `/comments?post_id=456` |
**Rule of thumb:** Never nest more than 2 levels. Use query parameters or top-level endpoints instead.
```
# Too deep -- avoid
GET /users/{id}/orders/{oid}/items/{iid}/reviews
# Better alternatives
GET /order-items/{iid}/reviews
GET /reviews?order_item_id={iid}
```
---
## Query Parameters
### Filtering
```
GET /products?category=electronics&brand=acme
GET /users?status=active&role=admin
GET /orders?createdAfter=2026-01-01&createdBefore=2026-02-01
```
| Convention | Example |
|-----------|---------|
| Exact match | `?status=active` |
| Date range | `?createdAfter=2026-01-01` |
| Multiple values | `?status=active,pending` |
| Search | `?q=search+term` |
### Sorting
```
GET /products?sort=price # ascending (default)
GET /products?sort=-price # descending (prefix -)
GET /products?sort=-createdAt,name # multi-field
```
### Pagination
Prefer **cursor-based** for any list that can grow — it is stable under concurrent inserts/deletes and O(1) per page. Use **offset-based** only for small, bounded collections where users need to jump to a specific page number.
```
# Cursor-based (recommended default)
GET /products?cursor=eyJpZCI6MTAwfQ&limit=25
# Offset-based (only for bounded lists)
GET /products?page=2&limit=25
```
**Response envelope — cursor:**
```json
{
"data": [ /* ... */ ],
"pagination": {
"nextCursor": "eyJpZCI6MTI1fQ",
"hasMore": true
}
}
```
**Response envelope — offset:**
```json
{
"data": [ /* ... */ ],
"pagination": {
"page": 2,
"limit": 25,
"total": 150,
"totalPages": 6
}
}
```
### Field Selection
```
GET /users/123?fields=id,name,email
```
---
## Non-CRUD Actions
Some operations do not map cleanly to CRUD. Use sub-resources with a noun or POST with an action resource.
| Action | Approach | Example |
|--------|----------|---------|
| Send an email | POST to action resource | `POST /users/{id}/verification-email` |
| Archive | PATCH with status | `PATCH /orders/{id} { "status": "archived" }` |
| Bulk delete | POST to action | `POST /users/bulk-delete { "ids": [...] }` |
| Export | GET with format | `GET /reports/sales?format=csv` |
| Search (complex) | POST with body | `POST /products/search { "filters": {...} }` |
---
## Versioning
| Strategy | Example | Pros | Cons |
|----------|---------|------|------|
| URL path | `/api/v1/users` | Simple, explicit | URL pollution |
| Header | `Accept: application/vnd.api.v1+json` | Clean URLs | Hidden |
| Query param | `/users?version=1` | Easy to test | Caching issues |
**Recommended:** URL path versioning (`/api/v1/`) for public APIs due to simplicity.
---
## Summary Checklist
- [ ] Resources are plural nouns (`/users` not `/user`)
- [ ] URL path segments are lowercase kebab-case
- [ ] JSON fields and query params are camelCase
- [ ] No verbs in URLs
- [ ] Nesting limited to 2 levels
- [ ] Filtering uses query parameters
- [ ] Sorting supports `-field` for descending
- [ ] Cursor pagination on any list that can grow; offset only for small bounded lists
- [ ] Max `limit` enforced on every list endpoint
- [ ] API version in URL path for public APIs
- [ ] Error responses use `application/problem+json` (RFC 9457)
*Reference: [Google API Design Guide](https://cloud.google.com/apis/design), [Microsoft REST Guidelines](https://github.com/microsoft/api-guidelines)*
@@ -0,0 +1,377 @@
openapi: 3.1.0
info:
title: My API
description: >
Starter spec with 2026-era defaults — RFC 9457 Problem Details,
camelCase JSON, cursor pagination, idempotency keys, rate-limit headers,
and ETag optimistic concurrency. Replace example.com / acme with your own.
version: 1.0.0
contact:
name: API Support
email: support@example.com
license:
name: MIT
identifier: MIT
servers:
- url: http://localhost:3000/v1
description: Local development
- url: https://api.example.com/v1
description: Production
tags:
- name: Users
description: User management
- name: Health
description: Service health checks
security:
- bearerAuth: []
paths:
/health:
get:
tags: [Health]
summary: Health check
operationId: getHealth
security: []
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
type: object
required: [status]
properties:
status: { type: string, example: ok }
timestamp: { type: string, format: date-time }
/users:
get:
tags: [Users]
summary: List users (cursor-paginated)
operationId: listUsers
parameters:
- $ref: '#/components/parameters/CursorParam'
- $ref: '#/components/parameters/LimitParam'
- name: status
in: query
schema: { type: string, enum: [active, inactive] }
responses:
'200':
description: Paginated list of users
headers:
X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
content:
application/json:
schema: { $ref: '#/components/schemas/UserListResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { $ref: '#/components/responses/TooManyRequests' }
post:
tags: [Users]
summary: Create a user
operationId: createUser
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateUserRequest' }
responses:
'201':
description: User created
headers:
Location:
description: URL of the new resource.
schema: { type: string, format: uri }
X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { $ref: '#/components/responses/Conflict' }
'422': { $ref: '#/components/responses/ValidationError' }
'429': { $ref: '#/components/responses/TooManyRequests' }
/users/{userId}:
parameters:
- name: userId
in: path
required: true
schema: { type: string, example: usr_abc123 }
get:
tags: [Users]
summary: Get a user by ID
operationId: getUser
responses:
'200':
description: User details
headers:
ETag:
description: Opaque version token. Pass as If-Match when updating.
schema: { type: string }
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
tags: [Users]
summary: Partially update a user (optimistic concurrency via If-Match)
operationId: updateUser
parameters:
- name: If-Match
in: header
required: true
description: ETag from a prior GET. Prevents lost-update collisions.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/UpdateUserRequest' }
responses:
'200':
description: User updated
headers:
ETag:
description: New version token after the update.
schema: { type: string }
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'412': { $ref: '#/components/responses/PreconditionFailed' }
'422': { $ref: '#/components/responses/ValidationError' }
delete:
tags: [Users]
summary: Delete a user
operationId: deleteUser
responses:
'204': { description: User deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
apiKeyAuth:
type: apiKey
in: header
name: X-Api-Key
parameters:
CursorParam:
name: cursor
in: query
description: Opaque cursor returned by a previous response.
schema: { type: string }
LimitParam:
name: limit
in: query
description: Maximum items per page.
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
IdempotencyKeyHeader:
name: Idempotency-Key
in: header
required: false
description: >
Client-generated UUID. The server stores the *response* for 24h and
returns the same response for any retry with the same key, so network
errors on unsafe requests can be retried without duplicate side effects.
schema: { type: string, format: uuid }
headers:
XRateLimitLimit:
description: Request quota for the current window.
schema: { type: integer, example: 1000 }
XRateLimitRemaining:
description: Requests remaining in the current window.
schema: { type: integer, example: 942 }
XRateLimitReset:
description: Unix timestamp (seconds) when the quota resets.
schema: { type: integer, example: 1767225600 }
RetryAfterSeconds:
description: Seconds to wait before retrying.
schema: { type: integer, example: 60 }
schemas:
User:
type: object
required: [id, email, name, status, createdAt]
properties:
id: { type: string, example: usr_abc123 }
email: { type: string, format: email, example: jane@example.com }
name: { type: string, example: Jane Doe }
status: { type: string, enum: [active, inactive] }
createdAt: { type: string, format: date-time }
updatedAt: { type: [string, "null"], format: date-time }
CreateUserRequest:
type: object
required: [email, name]
additionalProperties: false
properties:
email: { type: string, format: email, maxLength: 254 }
name: { type: string, minLength: 1, maxLength: 100 }
role:
type: string
enum: [admin, member, viewer]
default: member
UpdateUserRequest:
type: object
additionalProperties: false
properties:
name: { type: string, minLength: 1, maxLength: 100 }
status: { type: string, enum: [active, inactive] }
UserListResponse:
type: object
required: [data, pagination]
properties:
data:
type: array
items: { $ref: '#/components/schemas/User' }
pagination: { $ref: '#/components/schemas/CursorPagination' }
CursorPagination:
type: object
required: [hasMore]
properties:
nextCursor: { type: [string, "null"] }
hasMore: { type: boolean }
ProblemDetails:
type: object
description: RFC 9457 Problem Details for HTTP APIs.
required: [type, title, status]
properties:
type:
type: string
format: uri
description: URI reference identifying the problem type (link to your docs).
example: https://api.example.com/problems/validation-error
title:
type: string
description: Short human-readable summary of the problem class.
example: Validation failed
status:
type: integer
description: HTTP status code.
example: 422
detail:
type: string
description: Human-readable explanation specific to this occurrence.
example: "Field 'email' must be a valid email address."
instance:
type: string
format: uri
description: URI identifying this specific occurrence.
example: /v1/users
errors:
type: array
description: Field-level validation errors (extension member).
items:
type: object
required: [field, message]
properties:
field: { type: string, example: email }
message: { type: string, example: Must be a valid email address. }
code: { type: string, example: invalidFormat }
responses:
BadRequest:
description: Malformed request
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
example:
type: https://api.example.com/problems/bad-request
title: Bad request
status: 400
detail: Request body could not be parsed as JSON.
Unauthorized:
description: Authentication required or invalid
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
example:
type: https://api.example.com/problems/unauthorized
title: Unauthorized
status: 401
detail: Missing or invalid bearer token.
NotFound:
description: Resource not found
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
example:
type: https://api.example.com/problems/not-found
title: Not found
status: 404
detail: User 'usr_abc123' does not exist.
Conflict:
description: Resource conflict (duplicate or state clash)
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
example:
type: https://api.example.com/problems/conflict
title: Conflict
status: 409
detail: A user with that email already exists.
PreconditionFailed:
description: If-Match header did not match current ETag (optimistic concurrency)
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
example:
type: https://api.example.com/problems/precondition-failed
title: Precondition failed
status: 412
detail: The resource was modified since you last fetched it. Re-fetch and retry.
ValidationError:
description: Request validation failed
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
example:
type: https://api.example.com/problems/validation-error
title: Validation failed
status: 422
errors:
- field: email
message: Must be a valid email address.
code: invalidFormat
TooManyRequests:
description: Rate limit exceeded
headers:
Retry-After: { $ref: '#/components/headers/RetryAfterSeconds' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/ProblemDetails' }
example:
type: https://api.example.com/problems/rate-limited
title: Too many requests
status: 429
detail: Rate limit exceeded. Retry after 60s.