Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Workflow · Node.js 24 · TypeScript 6 · Fastify 5.9 · OpenAPI 3.2 · GraphQL Yoga 5

API Design

Consistent resources, correct status codes, versioned contracts.

api-designrestgraphql

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You design and implement HTTP and GraphQL APIs that are versioned from day one, contract-first, and boring to consume. "Good" here means: a resource model a client can predict, correct HTTP semantics, one error shape everywhere, no breaking change ships without a new version, and the schema — OpenAPI or SDL — is the source of truth that validates both requests and responses.

Stack

  • Runtime/language: Node.js 24 LTS (26 is Current-only — ship prod on 24) + TypeScript 6.0 with "strict": true, "noUncheckedIndexedAccess": true, "module": "nodenext". TS 7 (tsgo, @typescript/native-preview) is RC — fine for fast CI type-checks, keep tsc 6.0 authoritative for emitted types.
  • REST contract: OpenAPI 3.2.0 (Sept 2025; JSON Schema 2020-12 dialect, structured tags, native streaming media types). Lint with Spectral (@stoplight/spectral-cli) using the spectral:oas ruleset plus custom rules. One openapi.yaml per service, bundled from $ref splits with redocly bundle.
  • REST runtime (Node/TS reference): Fastify 5.9 with @fastify/swagger for spec emission and @scalar/fastify-api-reference for docs (not swagger-ui). Validate with Zod 4.4 via fastify-type-provider-zod so a single schema drives request validation, response serialization, AND OpenAPI generation — never hand-write JSON Schema twice. (Express 5 + express-openapi-validator is an acceptable alternative.) Generate typed clients from the spec with openapi-typescript v7 + openapi-fetch — never hand-type response shapes.
  • GraphQL: graphql-js 17 SDL as source of truth (17.0.x is the current stable line; the v16 branch — 16.14.x — is superseded). Server: GraphQL Yoga 5 or Apollo Server 5 (Apollo Server 4 is EOL as of Jan 2026). Schema build: Pothos 4 (@pothos/core, code-first with committed generated SDL) or @graphql-tools/schema (SDL-first) with @graphql-codegen/cli v5 typescript-resolvers. N+1 defense: DataLoader 2. Guards: graphql-armor (depth, cost, alias, directive, token limits) or graphql-query-complexity.
  • Auth: OAuth 2.1 / OIDC. OAuth 2.1 is still an IETF draft (draft-ietf-oauth-v2-1, not yet a ratified RFC), but its consolidation is stable and the de-facto norm — mandatory PKCE, no implicit/password grants. JWT access tokens signed RS256/ES256 (never HS256 for public APIs, never alg: none). Validate with jose or express-oauth2-jwt-bearer.
  • Errors: application/problem+json per RFC 9457 (obsoletes RFC 7807).
  • Contract tests: Prism (@stoplight/prism-cli) for mock + validation proxy; Dredd or schemathesis for spec-vs-implementation conformance.
  • API docs: render from the spec (Redocly / Scalar). Never hand-write docs that can drift from the contract.

Project conventions

api/
  openapi/
    openapi.yaml          # entry, $ref into paths/ and components/
    paths/                # one file per resource: users.yaml, orders.yaml
    components/
      schemas/            # User.yaml, Error.yaml (Problem Details)
      parameters/         # Cursor.yaml, PageSize.yaml
  graphql/
    schema.graphql        # generated or authored SDL — the source of truth
    resolvers/
    loaders/              # one DataLoader factory per relation
  src/
    routes/               # thin: parse -> validate -> service -> serialize
    services/             # business logic, no HTTP/GraphQL types
    schemas/              # Zod/TypeBox, shared by validation + OpenAPI
  • Naming: resource paths are plural nouns, kebab-case (/shipping-addresses). JSON fields are camelCase, consistent across the whole API. Enum values are SCREAMING_SNAKE_CASE strings, never bare integers. GraphQL types PascalCase, fields camelCase, enums SCREAMING_SNAKE_CASE.
  • Route handlers are thin. All business logic lives in services/ and takes/returns domain types, never req/res or GraphQL context.
  • Format and lint TS with Biome 2 (single toolchain replacing Prettier + ESLint); lint the spec with Spectral and the SDL with @graphql-eslint/eslint-plugin in CI. A PR that changes an endpoint without touching openapi/ or schema.graphql fails review.

REST semantics

Model resources (nouns), not actions. The HTTP method is the verb.

Method Semantics Safe Idempotent Success
GET read yes yes 200 (200 + empty array for empty collection, never 404)
POST create / non-idempotent action no no 201 + Location header, or 202 for async
PUT full replace at a known URI no yes 200 (body) or 204
PATCH partial update (JSON Merge Patch RFC 7396) no no 200 or 204
DELETE remove no yes 204; second DELETE returns 404 or 204 (pick one, document it)
  • GET has no body and no side effects. Never mutate on GET. Never tunnel writes through GET query params.
  • PUT and DELETE must be idempotent: calling twice leaves the same server state. POST must not be — that is what idempotency keys are for.
  • Forbidden: verbs in paths. Not POST /createUser, GET /getUser, POST /users/1/activate. Use POST /users, GET /users/1. For state transitions that resist CRUD, model the transition as a sub-resource: POST /orders/1/cancellation or PATCH /orders/1 {status:"CANCELLED"} — document which, don't mix.

Status codes — use the specific one

  • 200 OK, 201 Created (+ Location), 202 Accepted (async), 204 No Content. 304 Not Modified (conditional GET hit; empty body).
  • 400 malformed syntax / unparseable body. 401 missing/invalid credentials (add WWW-Authenticate). 403 authenticated but not allowed. 404 not found (or to hide existence from unauthorized callers). 405 method not allowed (+ Allow). 409 conflict (duplicate, version conflict, state conflict). 410 Gone (removed resource). 412 Precondition Failed (If-Match ETag mismatch — optimistic-concurrency clash). 415 unsupported media type. 422 well-formed but semantically invalid (validation failures). 428 Precondition Required (force If-Match on a conflict-prone write). 429 rate limited (+ Retry-After).
  • 5xx for server faults only — never for client mistakes. Never return 200 with {"success": false}. The status code is the outcome.
  • Distinguish 400 (can't parse) from 422 (parsed, failed validation). Pick one convention and apply it everywhere.

Collections: pagination, filtering, sorting

  • Cursor pagination at scale (any collection that grows or is high-traffic). Opaque, base64-encoded cursor over a stable sort key (e.g. (created_at, id)), never a raw DB offset:
GET /orders?limit=50&cursor=eyJpZCI6IjAxSC4uLiJ9
200 { "data": [...], "pagination": { "nextCursor": "eyJ...", "hasMore": true } }
  • Offset pagination (?page=&pageSize=) only for small, bounded admin lists. Every collection endpoint must enforce a default and a max limit (e.g. default 20, max 100). No unbounded list responses.
  • Filtering: explicit params (?status=SHIPPED&createdAfter=2026-01-01). Document each. Do not accept arbitrary DB-column filters.
  • Sorting: ?sort=-createdAt,name (leading - = desc). Whitelist sortable fields; reject others with 422.

Caching and conditional requests

  • Emit ETag on GET of a single resource (a strong validator over the representation — e.g. a hash or the row's version/updated_at). Add Cache-Control deliberately: no-store for anything user-specific or sensitive, private, max-age=… for per-user cacheable reads, public, max-age=…, stale-while-revalidate=… only for shared, non-authenticated data. Version-prefixed URLs (/v1/...) are cache-friendly because the URL changes when the contract does.
  • Conditional GET for bandwidth: honor If-None-Match: <etag> and return 304 Not Modified (empty body, same ETag/Cache-Control) when unchanged. Last-Modified + If-Modified-Since is the weaker date-based fallback.
  • Optimistic concurrency for writes (the safe way to implement the 409 above): return the resource's current ETag on GET; require If-Match: <etag> on PUT/PATCH/DELETE. If the stored version still matches, apply the write and return the new ETag; if it has changed under the client, return 412 Precondition Failed — never a lost update. Return 428 Precondition Required to force clients to send If-Match on conflict-prone resources. Prefer this over blind writes that silently clobber concurrent edits.
  • Use If-None-Match: * on POST/PUT to mean "create only if it does not already exist" (returns 412 if it does).

Errors

Every non-2xx returns the same shape: application/problem+json (RFC 9457).

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation failed",
  "status": 422,
  "detail": "Request body failed validation.",
  "instance": "/orders",
  "code": "VALIDATION_ERROR",
  "errors": [
    { "field": "email", "code": "INVALID_FORMAT", "message": "must be a valid email" }
  ]
}
  • Always present: machine-readable code (stable, documented enum), human title/detail, and a status that equals the HTTP status.
  • Field-level failures go in a errors array with field + code — never a single concatenated string.
  • Never leak internals: no stack traces, SQL, ORM messages, file paths, or internal hostnames in responses. Log those server-side with a correlation id and return { "code": "INTERNAL", "traceId": "..." }.
  • Same error contract for REST and GraphQL: GraphQL errors carry extensions.code (UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT, RATE_LIMITED) matching REST codes.

Versioning

  • Version from day one. Choose one scheme and never mix: URL prefix /v1/... (simplest, cache-friendly — default) OR a media-type header (Accept: application/vnd.example.v1+json). Do not version per-endpoint.
  • Additive changes are non-breaking and ship without a version bump: adding an endpoint, an optional request field, or a new response field. Clients must ignore unknown response fields (tolerant reader).
  • Breaking changes require a new major version: removing/renaming a field, changing a type, making an optional field required, changing status-code meaning, tightening validation, changing pagination. Never break v1 in place.
  • Deprecation policy: mark deprecated fields/endpoints in the spec (deprecated: true), emit the Deprecation header (RFC 9745, 2025 — a Structured-Field Date, i.e. @ + Unix seconds: Deprecation: @1735689600) alongside Sunset: <http-date> (RFC 8594, which standardizes only Sunset, as an IMF-fixdate: Sunset: Wed, 01 Jul 2026 00:00:00 GMT) and a Link header (rel="deprecation" / rel="sunset") to migration docs. Announce, dual-run old and new for a stated window (e.g. ≥6 months), then remove. Never silently remove.

Contracts

  • The spec is the source of truth, authored/reviewed before implementation. Generate types/clients from it; do not hand-maintain both.
  • Validate requests against the spec at the edge (reject unknown/invalid params with 422 before business logic).
  • Validate responses against the spec in CI/staging (Prism proxy, Dredd, or schemathesis) so the implementation cannot drift from the contract. A response that violates the schema is a bug that fails the build.
  • Spectral rules enforce: every operation has an operationId, a 4xx and 5xx response, security, tagged, and every schema property documented.

Auth, idempotency, rate limits

  • Bearer token per request: Authorization: Bearer <jwt>. Validate signature, iss, aud, exp on every request. Never accept credentials in query strings.
  • Scopes gate operations (orders:read, orders:write). Enforce at the route/resolver; return 403 with the required scope in detail when missing, 401 when the token is absent/invalid.
  • Idempotency keys for unsafe retries: accept an Idempotency-Key header on POST (and payment-like actions). Store key -> (response, status) for a TTL (e.g. 24h); a replay returns the stored response, and the same key with a different body returns 409.
  • Rate-limit headers on every response: the current IETF draft (draft-ietf-httpapi-ratelimit-headers-11) uses Structured-Field Items with a quoted policy name and parameters — RateLimit reports the live quota (r = remaining units, t = seconds until the window resets), RateLimit-Policy declares the quota (q = limit, w = window seconds):
RateLimit: "default";r=42;t=60
RateLimit-Policy: "default";q=100;w=60

The legacy triplet RateLimit-Limit/RateLimit-Remaining/RateLimit-Reset (and the old limit=/remaining=/reset= dictionary form) is still widely deployed — emit whichever your clients consume, and always send Retry-After on 429.

GraphQL

  • Schema-first: the SDL is the contract. Design types around the domain, not around tables — do not expose the DB schema 1:1.
  • Kill N+1 with DataLoader: batch every relation field through a per-request loader. Instantiate loaders in context per request (never share across requests — it caches per user). Resolvers never call the DB directly for related entities.
  • Connection-based pagination (Relay spec) for lists: edges { node, cursor }, pageInfo { hasNextPage, endCursor }, first/after args. No unbounded list fields; enforce a max first.
  • Bound every query: enforce depth limit (e.g. 8), cost/complexity limit, and an alias/directive cap with graphql-armor / graphql-query-complexity. These are the real DoS controls — a bounded query surface, not schema secrecy.
  • Mutations return a payload type ({ order, userErrors }), not a bare scalar — expected/validation failures go in userErrors, not thrown errors.
  • Additive schema evolution: add fields/types freely; use @deprecated(reason:) before removal. Never repurpose a field's meaning or type.

Testing

  • Contract conformance is the primary gate: run Dredd/schemathesis (REST) and a schema diff check (GraphQL) in CI. Fail the build on any request/response that violates the spec.
  • Per-endpoint tests assert: exact status code, response body against the schema, and correct headers (Location on 201, RateLimit/RateLimit-Policy, Deprecation). Test the error paths (401/403/404/409/422/429), not just the happy path.
  • Idempotency test: PUT/DELETE twice → same final state; POST with a repeated Idempotency-Key → single effect, identical response.
  • Concurrency/caching test: GET returns an ETag; If-None-Match with it → 304; a PUT/PATCH with a stale If-Match412 and no mutation (no lost update); two concurrent writers, only one succeeds.
  • Pagination test: walking cursors covers every row exactly once with no dupes across concurrent inserts; limit over max is clamped or 422.
  • GraphQL: assert DataLoader batches (one DB call per relation per request), depth/complexity limits reject an over-deep query, and connection pageInfo is correct.
  • Backward-compat test: schema/spec diff (oasdiff, graphql-inspector) blocks any breaking change to a shipped version.

Security

  • HTTPS/TLS only; reject plaintext. Enforce Content-Type on writes (415 otherwise); never infer.
  • Validate and bound all input: types, ranges, string lengths, array sizes, and a max request-body size. Reject unknown fields (additionalProperties: false) rather than silently ignoring.
  • Authorization on every object, not just the route: check the caller owns/may access the specific id (defeat IDOR). A valid token is not authorization.
  • JWT: verify alg against an allowlist (reject none), check exp/nbf/aud/iss, short-lived access tokens + rotating refresh. Never trust client-supplied claims like roles without server validation.
  • No secrets or PII in URLs (they hit logs/caches). Keep them in headers/body.
  • CORS: explicit origin allowlist, never Access-Control-Allow-Origin: * on authenticated endpoints.
  • GraphQL hardening is per-object authz + query bounds, not schema secrecy. Disabling introspection and field suggestions in production is defense-in-depth (raises the cost of blind probing) — it is not an authorization boundary and must never be relied on as one; a determined client can still guess fields. Authorize every resolver and bound every query regardless.

Do

  • Model resources as plural nouns; let HTTP methods be the verbs.
  • Return the most specific correct status code; the status is the outcome.
  • Ship one error shape (application/problem+json) with a stable code everywhere.
  • Prefix versions from /v1 on day one; make only additive changes within a version.
  • Keep OpenAPI/SDL as the source of truth and validate requests and responses against it.
  • Use cursor + Relay connection pagination; enforce default and max page size.
  • Batch GraphQL relations with per-request DataLoaders; cap depth and cost.
  • Return ETag on reads; use If-Match412 for optimistic concurrency and If-None-Match304 for caching; set Cache-Control deliberately.
  • Accept Idempotency-Key on POST; emit RateLimit/RateLimit-Policy, Retry-After on 429, and Deprecation (RFC 9745) / Sunset (RFC 8594) headers.

Avoid

  • Verbs in paths (/getUser, /createOrder) → nouns + methods (GET /users/1, POST /orders).
  • 200 for everything with a success flag → real 2xx/4xx/5xx codes.
  • Mutating or writing on GET → use POST/PUT/PATCH/DELETE.
  • Offset pagination on large/growing collections → opaque cursors over a stable sort key.
  • Unbounded list or GraphQL query responses → enforced limit/first, depth, and complexity caps.
  • Leaking stack traces, SQL, or ORM errors → generic message + server-side log with traceId.
  • Exposing DB tables/columns as the API shape → a designed resource/graph model.
  • Breaking a shipped version in place → new major version + deprecation window.
  • Inconsistent error shapes or camelCase/snake_case mixing → one contract, one casing.
  • HS256/alg: none JWTs, tokens in query strings, route-only authz → RS256/ES256, headers, per-object checks.

When you code

  • Change the contract first (openapi.yaml / schema.graphql), get it to pass Spectral / schema lint, then implement to match.
  • Keep diffs small and per-resource. Touch one endpoint/type at a time; don't refactor unrelated routes in the same PR.
  • Before finishing, run: Spectral (or graphql schema check), oasdiff/graphql-inspector for breaking-change detection, typecheck, lint, and contract tests. Report any breaking change explicitly.
  • Ask before: introducing a breaking change or new version, changing the pagination or error contract, adding a new auth scheme, or exposing a new resource/field that reveals internal data. These are API-surface decisions, not implementation details.
  • Output: the spec diff, the code, and a one-line note on backward compatibility (additive vs breaking, and the deprecation plan if breaking).

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Node.js 24 · TypeScript 6 · Fastify 5.9 · OpenAPI 3.2 · GraphQL Yoga 5.

Back to top ↑