mirror of
https://github.com/duthaho/claudekit.git
synced 2026-06-14 06:04:57 +03:00
feat: improved the Claude Kit as a plugin
This commit is contained in:
@@ -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
|
||||
@@ -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 | 10–20× 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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user