Claude Skill

api-design

Use when settling the contract of an API you expose, before implementation: resources/URLs, REST vs GraphQL, versioning, one RFC 9457 error envelope, pagination, idempotency — emitted as OpenAPI 3.1. NOT implementing the endpoints (that is `fastapi`/`nestjs`/`go`/`nodejs`), NOT a

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_api-design-953fef5.zip · 15 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/api-design
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git git clone https://github.com/ericrisco/rsc-harness.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

API design

Your one job

You design the contract an API exposes. You do not write the handler. The deliverable is a set of decisions a backend skill can implement directly: resource shapes, URLs, methods, the status-code map, one error envelope, pagination params, versioning rules — ideally captured as an OpenAPI 3.1 document.

When the user names a framework (FastAPI, NestJS, Go, Node), they own the build; you are pulled in for contract questions. Settle the contract first, then hand off (see Handoff). Keep every decision framework-neutral: nothing here should mention an ORM, a router, or a DI container.

REST vs GraphQL vs hybrid

Pick on traffic shape, not fashion. Decide once, write it down.

Situation Choose Why
CRUD-ish resources, public API, HTTP caching matters REST URLs map to resources; CDN/proxy caching works on GET + ETag out of the box
Many client shapes, deep nested graphs, mobile over-fetch is real GraphQL one round-trip, client picks fields; no N endpoints per screen
Stable resource API + one rich read surface for a client app Hybrid REST for the system of record, a GraphQL read layer on top

Operational gotcha that decides monitoring: GraphQL returns HTTP 200 even when a field errored — failures live in an errors[] array next to partial data. Your dashboards cannot alert on 5xx; you must alert on the errors[] payload. REST signals failure with the HTTP status itself. If your ops team lives on status-code SLOs, that is a point for REST. Schema/nullability design, mutation and error-union conventions, and this error model in full: references/graphql-design.md.

Resource & URL modeling

Resources are nouns; HTTP methods are the verbs. Never put a verb in a path.

Rules, each with its reason:

  • Plural collections, consistent everywhere — /projects, /projects/{id}. Pick plural and never mix singular in; inconsistent naming is the most-cited design smell.
  • Nest sub-resources one level — /projects/{id}/tasks. Deeper than one level (/projects/{p}/tasks/{t}/comments/{c}) gets unreadable; link to the flat resource instead (/comments/{c}).
  • Methods carry intent — GET read, POST create, PUT full replace, PATCH partial update, DELETE remove. A path never says what it does.
  • Filter/sort/select via query string, not new paths — ?status=open&sort=-created_at&fields=id,title. One GET /projects handles all of it; don't mint /projects/open and /projects/byDate.
Bad Good Why
POST /createProject POST /projects the method is the verb
GET /getUserOrders/{id} GET /users/{id}/orders noun hierarchy, no verb
GET /project and GET /tasks GET /projects and GET /tasks one plural convention
GET /projects/active GET /projects?status=active filter is a query param
POST /projects/{id}/delete DELETE /projects/{id} method, not path segment

Full query grammar (filter operators, sparse fieldsets, sort syntax), content negotiation, rate-limit headers and a HATEOAS note: references/rest-conventions.md.

Status codes that matter

You need a small map, used consistently. Don't overload 200.

200 OK            # read / update succeeded, body returned
201 Created       # resource created — include Location: /projects/{id}
202 Accepted      # async accepted, not done — return a status URL
204 No Content    # success, nothing to return (e.g. DELETE)
400 Bad Request   # malformed syntax / unparseable
401 Unauthorized  # not authenticated — who are you?
403 Forbidden     # authenticated but not allowed — I know you, no
404 Not Found     # resource absent (or hidden from this caller)
409 Conflict      # state collision — duplicate, version mismatch
422 Unprocessable # syntactically fine, semantically invalid (validation)
429 Too Many Req  # rate limited — include Retry-After
5xx               # your fault, never the client's; never leak the stack

Two distinctions agents get wrong:

  • 401 vs 403 — 401 means unauthenticated (no/invalid credentials); 403 means authenticated but unauthorized. Returning 401 on a permission failure leaks that re-auth might help when it won't.
  • 409 vs 422 — 409 is a state conflict (the request fights the current server state: dup key, stale version). 422 is a content problem (the body parses but fails business rules). Full status-code table with when-each in references/rest-conventions.md.

Error envelope: RFC 9457

One error shape across every endpoint. Adopt RFC 9457 Problem Details (the current standard; it obsoletes RFC 7807). Media type application/problem+json. Standard members: type, title, status, detail, instance, plus your own extension members.

{
  "type": "https://api.acme.com/problems/validation-error",
  "title": "Your request parameters didn't validate.",
  "status": 422,
  "detail": "due_date must be in the future.",
  "instance": "/projects/8a3/tasks",
  "errors": [
    { "field": "due_date", "message": "must be in the future" }
  ],
  "correlation_id": "req_01H..."
}

Rules:

  • type is a stable, machine-readable URI — clients branch on it, not on detail. Never change a type string once published.
  • title is human, generic per type; detail is human, specific to this occurrence. detail is for people, not parsers.
  • Always carry a correlation/request id (extension member) so a support ticket maps to a log line.
  • Never leak internals — no stack traces, SQL, internal hostnames, or raw DB ids in detail. That is both an information leak and a coupling leak.

Pagination

Default to cursor (keyset) pagination. Use offset only for small, bounded sets.

Approach Use when Why
Cursor / keyset large or changing datasets, feeds, anything hot opaque token over an indexed ordered column → constant-time, stable across inserts
Offset / limit small bounded admin lists, fixed reference tables simple, but the DB scans-and-discards skipped rows (degrades with depth) and skips or duplicates rows when data shifts between page loads

Decision line: if the list can grow unbounded or rows can be inserted between page fetches, use cursor.

REST cursor envelope — same keys on every list endpoint:

{
  "data": [ { "id": "...", "title": "..." } ],
  "next_cursor": "eyJpZCI6MTI4N30",
  "has_more": true
}

The next page is GET /projects?cursor=eyJpZCI6MTI4N30&limit=50. The cursor is opaque — clients must not parse or construct it.

GraphQL has its own de-facto standard: Relay Connections — edges { node, cursor }, pageInfo { hasNextPage, endCursor }, args first / after. Use it; don't invent a bespoke GraphQL pagination shape. See references/graphql-design.md.

Versioning & evolution

Prefer additive, non-breaking evolution over a new version. A new version forks your client base and your maintenance. Most changes don't need one.

  • Non-breaking (no version bump): adding an optional field, adding a new endpoint, adding a new optional query param, adding a new enum value clients are told to tolerate.
  • Breaking (needs a version): removing/renaming a field, changing a type, making an optional field required, changing status-code semantics, changing the error type for a case.

When you must version, use a URL path version (/v1/...) for public APIs — it is visible, cacheable, trivially testable in a browser, and the most common convention clients expect. Header/media-type versioning (Accept: application/vnd.acme.v2+json) keeps URLs clean but is harder to test and cache; query-param versioning (?version=2) pollutes every URL. Default to path.

Deprecate gracefully with the Deprecation and Sunset response headers so clients get programmatic warning before removal:

Deprecation: true
Sunset: Sat, 31 Oct 2026 23:59:59 GMT
Link: <https://api.acme.com/v2/projects>; rel="successor-version"

Full breaking-vs-non-breaking matrix, the three versioning mechanisms and the deprecation/sunset workflow: references/versioning-and-evolution.md.

Idempotency & concurrency

  • Make POST/PATCH retry-safe with an Idempotency-Key header. The client sends a unique key; the server replays the original response on a retry instead of double-creating. This is an IETF httpapi draft (not yet an RFC) but is the proven pattern across Stripe, PayPal, and others — adopt it for any create/charge/payment-like operation where a network retry could duplicate work.
POST /payments
Idempotency-Key: 9b1f7c2e-... 
  • Use ETag + If-Match for optimistic concurrency on PUT/PATCH. The server returns an ETag (a version fingerprint) on read; the client sends it back in If-Match on write. If it no longer matches, the server returns 412 Precondition Failed — no lost update. Use If-None-Match for conditional GET caching.

Anti-patterns

Anti-pattern Why it bites Do instead
Verbs in paths (/getUsers, /createProject) duplicates HTTP semantics, breaks caching/tooling noun + HTTP method
Mixed plural/singular collections clients can't predict URLs one plural convention everywhere
200 on error (REST) breaks status-code monitoring and client error handling real 4xx/5xx + RFC 9457 body
Different error shape per endpoint every client writes per-endpoint parsing one application/problem+json shape
Leaking stack traces / SQL / DB ids in errors info leak + couples clients to internals generic title, safe detail, correlation id
Offset pagination on a hot/large feed slow at depth; skips/dups rows on insert cursor/keyset pagination
Unbounded list endpoint (no limit) one client can pull the whole table enforce a default + max limit
New version for every change forks clients, multiplies maintenance additive non-breaking evolution
Breaking a field in place on a live version silently breaks existing clients new field/version + deprecation headers
401 for a permission failure misleads client into re-authing 403 when authenticated-but-forbidden
200 for a created resource hides the create, no Location 201 + Location header
Ignoring GraphQL partial errors[] failures invisible to monitoring alert on errors[], not just HTTP 5xx

Handoff

The contract is the artifact. Emit it as an OpenAPI 3.1 document — the checkable deliverable a framework skill generates code from. How to shape it, and what scripts/verify.sh checks: references/openapi-contract.md.

Hand off to the builder:

Adjacent concerns you do not own:

Files (rsc-harness)
  • evals
    • cases.yaml 3.1 KB
      skill: api-design
      
      should_trigger:
        - prompt: "Design the REST API for a multi-tenant invoicing service"
          why: core greenfield contract-design request; resource modeling + URLs.
        - prompt: "How do I add a field and a new status value to this endpoint without breaking existing clients?"
          why: evolution/versioning; non-obvious — no words 'API' or 'design', it's the additive-non-breaking rule.
        - prompt: "Should this be REST or GraphQL, and how should pagination work?"
          why: paradigm choice plus pagination convention — both are design-time decisions.
        - prompt: "What's the standard error format? I keep returning a different shape per endpoint"
          why: symptom-led; routes to the single RFC 9457 problem-details envelope.
        - prompt: "Our list endpoint times out at page 5000 with offset pagination"
          why: non-obvious symptom — the fix is a cursor/keyset pagination design, not a query tweak.
        - prompt: "diseña los endpoints y el versionado de nuestra API pública"
          why: Spanish trigger for endpoint design + versioning strategy.
        - prompt: "We need an Idempotency-Key on POST so retries don't double-charge — how should the contract handle it?"
          why: non-obvious header-level contract decision (idempotency), design-time not implementation.
      
      should_not_trigger:
        - prompt: "Implement these endpoints in FastAPI with Pydantic models and async routes"
          route_to: fastapi
          why: build-time framework implementation, not contract design.
        - prompt: "Threat-model this endpoint and check the CORS and CSP config for OWASP issues"
          route_to: secure-coding
          why: security/hardening lens, not the API shape.
        - prompt: "Verify the incoming Stripe webhook signature and dedupe replayed events"
          route_to: webhooks
          why: receiving inbound events with HMAC verification, not designing an exposed API.
        - prompt: "Write a client that calls the OpenWeather API and handles its rate limits and retries"
          route_to: api-connector-builder
          why: building an outbound client to a third-party API, not designing one.
        - prompt: "Wire a custom provider with useFactory in my NestJS module"
          route_to: nestjs
          why: framework dependency-injection wiring, not contract design.
      
      capability:
        - scenario: "Design the public REST API contract for a 'projects' resource with nested tasks, supporting pagination, consistent errors, and a versioning plan."
          must_include:
            - plural noun collections and nested path `/projects/{id}/tasks` with no verbs in paths
            - correct methods + status codes, including 201 + Location on create and the 409-vs-422 distinction
            - one RFC 9457 `application/problem+json` error envelope used across all endpoints, with a stable machine `type` and a correlation id, no leaked internals
            - cursor (keyset) pagination with a consistent response envelope (data + next_cursor/has_more)
            - URL-path versioning for the public API plus the additive-non-breaking evolution rule (and Deprecation/Sunset on removal)
            - a handoff note pointing to a framework skill (fastapi/nestjs/go/nodejs) to implement it
            - bonus: an OpenAPI 3.1 sketch of the contract
      
    • README.md 518 B
      # Evals: api-design
      
      These cases are run by the repository's eval harness against the skill router. `should_trigger` asserts that for each prompt the router selects `api-design`; `should_not_trigger` asserts the named sibling wins instead (each `route_to` is a real catalog id); `capability` is a rubric-graded generation check — the model is asked the scenario and its output is scored against the `must_include` list. Run them with the repo's eval runner pointed at `cases.yaml`; no network or live API is needed.
      
  • references
    • graphql-design.md 2.9 KB
      # GraphQL design
      
      When you chose GraphQL (many client shapes, deep graphs, over-fetch is real), design the schema as the contract. Framework-neutral.
      
      ## Schema & nullability
      
      - **Model the domain graph, not your tables.** Types are nouns with relationships; let clients traverse instead of you minting endpoints.
      - **Nullability is a contract.** A non-null field (`String!`) means the resolver must always produce it — and if it errors, GraphQL nulls the *nearest nullable parent*, which can blank a whole subtree. Make a field non-null only when it truly cannot be absent; default to nullable for anything fetched from a flaky downstream.
      - **Use enums for closed sets** and add values additively (clients should tolerate unknown enum values).
      - **Evolve additively; deprecate with `@deprecated(reason: ...)`** instead of removing — GraphQL has no URL version to fall back on.
      
      ## Pagination: Relay Connections
      
      Use the de-facto standard rather than a bespoke shape, so client tooling (Apollo/Relay) works out of the box.
      
      ```graphql
      type ProjectConnection {
        edges: [ProjectEdge!]!
        pageInfo: PageInfo!
      }
      
      type ProjectEdge {
        node: Project!
        cursor: String!
      }
      
      type PageInfo {
        hasNextPage: Boolean!
        endCursor: String
      }
      
      type Query {
        projects(first: Int!, after: String): ProjectConnection!
      }
      ```
      
      - `first` + `after` paginate forward; `last` + `before` backward.
      - `cursor` is opaque — clients pass `endCursor` back as `after`; they never construct it.
      
      ## Mutations & error model
      
      - **One input object per mutation, one payload type out** — keeps mutations evolvable: `createProject(input: CreateProjectInput!): CreateProjectPayload!`.
      - **Model expected, business-level failures as data, not as protocol errors.** A union or a payload with `userErrors { field, message }` lets clients handle validation without parsing the transport `errors[]`.
      
      ```graphql
      type CreateProjectPayload {
        project: Project
        userErrors: [UserError!]!
      }
      ```
      
      ## The HTTP-200 error model (the big operational difference)
      
      GraphQL returns **HTTP 200 even when a field errored.** Failures appear in a top-level `errors[]` array alongside partial `data`:
      
      ```json
      {
        "data": { "project": null },
        "errors": [
          { "message": "Project not found", "path": ["project"], "extensions": { "code": "NOT_FOUND" } }
        ]
      }
      ```
      
      Consequences you must design for:
      - **Monitoring cannot rely on HTTP 5xx.** Alerting must inspect the `errors[]` payload and `extensions.code`, not the status line. Wire this before launch or outages go invisible.
      - **Clients must check `errors[]` on every response**, even a 200. Treating 200 as success silently swallows failures.
      - **Standardize `extensions.code`** (e.g. `NOT_FOUND`, `FORBIDDEN`, `VALIDATION`) so clients and dashboards branch on a stable machine value — the GraphQL analogue of an RFC 9457 `type`.
      
      This single difference, not the query syntax, is what makes GraphQL operationally distinct from REST. Decide it explicitly.
      
    • openapi-contract.md 3.2 KB
      # OpenAPI 3.1 contract
      
      The contract is the artifact. Capture a REST design as an **OpenAPI 3.1** document — a real, lintable file that a framework skill generates server stubs and clients from, and that `scripts/verify.sh` checks.
      
      Why 3.1: it aligns with JSON Schema 2020-12, so your request/response schemas are reusable JSON Schema, and it adds `webhooks` and richer `$ref` handling over 3.0.
      
      ## Minimal skeleton
      
      ```yaml
      openapi: 3.1.0
      info:
        title: Projects API
        version: 1.0.0
      servers:
        - url: https://api.acme.com/v1
      paths:
        /projects:
          get:
            summary: List projects
            parameters:
              - { name: cursor, in: query, schema: { type: string } }
              - { name: limit, in: query, schema: { type: integer, maximum: 100, default: 50 } }
            responses:
              "200":
                description: A page of projects
                content:
                  application/json:
                    schema: { $ref: "#/components/schemas/ProjectPage" }
              default:
                $ref: "#/components/responses/Problem"
          post:
            summary: Create a project
            parameters:
              - { name: Idempotency-Key, in: header, schema: { type: string } }
            requestBody:
              required: true
              content:
                application/json:
                  schema: { $ref: "#/components/schemas/ProjectCreate" }
            responses:
              "201":
                description: Created
                headers:
                  Location: { schema: { type: string } }
                content:
                  application/json:
                    schema: { $ref: "#/components/schemas/Project" }
              "422": { $ref: "#/components/responses/Problem" }
              default: { $ref: "#/components/responses/Problem" }
      components:
        responses:
          Problem:
            description: RFC 9457 problem details
            content:
              application/problem+json:
                schema: { $ref: "#/components/schemas/Problem" }
        schemas:
          Problem:
            type: object
            properties:
              type: { type: string, format: uri }
              title: { type: string }
              status: { type: integer }
              detail: { type: string }
              instance: { type: string }
            required: [type, title, status]
          ProjectPage:
            type: object
            properties:
              data: { type: array, items: { $ref: "#/components/schemas/Project" } }
              next_cursor: { type: [string, "null"] }
              has_more: { type: boolean }
            required: [data, has_more]
      ```
      
      ## Conventions that keep it clean
      
      - **One reusable `Problem` schema and `Problem` response**, referenced by every error case — enforces the single RFC 9457 envelope.
      - **A `default` response on every operation** so unexpected statuses still resolve to the problem shape.
      - **Every list operation carries `cursor` + `limit`** params — verify.sh warns when a list path lacks them.
      - **No verbs in path keys** — verify.sh flags `/get*`, `/create*`, `/delete*` segments.
      - **`info.version` is the contract version**, distinct from the URL `/v1` major; bump it on any non-breaking addition too.
      
      ## Handoff
      
      Give the framework skill this file. It becomes the source of truth: FastAPI/NestJS/Go/Node tooling can generate models, route stubs, and validation from it, and clients can generate SDKs. Keep the OpenAPI doc updated *before* the code changes — design-first, not code-first.
      
    • rest-conventions.md 3.4 KB
      # REST conventions
      
      Depth behind the SKILL. Framework-neutral. Adopt as house style and keep it consistent across every endpoint.
      
      ## Status-code map — when each
      
      | Code | Meaning | Use when |
      |---|---|---|
      | 200 OK | success + body | GET, or a PUT/PATCH that returns the updated resource |
      | 201 Created | created | POST created a resource — include `Location: /collection/{id}` |
      | 202 Accepted | accepted, not done | async work queued — return a status/poll URL |
      | 204 No Content | success, no body | DELETE, or PUT/PATCH that returns nothing |
      | 304 Not Modified | cache hit | conditional GET with `If-None-Match` and ETag matched |
      | 400 Bad Request | malformed | body/params unparseable or syntactically wrong |
      | 401 Unauthorized | unauthenticated | missing/invalid credentials — re-auth may help |
      | 403 Forbidden | unauthorized | authenticated but lacks permission/scope |
      | 404 Not Found | absent | resource doesn't exist (or is hidden from this caller) |
      | 405 Method Not Allowed | wrong verb | path exists, method doesn't — set `Allow` header |
      | 409 Conflict | state collision | duplicate, version mismatch, concurrent edit |
      | 412 Precondition Failed | stale write | `If-Match` ETag no longer matches |
      | 415 Unsupported Media Type | bad content-type | server can't process the sent representation |
      | 422 Unprocessable Content | semantic invalid | parses fine, fails business validation |
      | 429 Too Many Requests | rate limited | include `Retry-After` |
      | 500 Internal Server Error | server fault | unexpected — never leak the stack |
      | 503 Service Unavailable | temporarily down | maintenance/overload — `Retry-After` if known |
      
      Rule of thumb: client mistakes are 4xx, server faults are 5xx. Never blame the client for your bug, never hide your bug behind a 200.
      
      ## Query grammar (filter / sort / sparse fieldsets)
      
      Keep one grammar across all list endpoints so clients learn it once.
      
      ```http
      GET /projects?status=active&owner=u_42&sort=-created_at,title&fields=id,title,status&limit=50
      ```
      
      - **Filter** by field name as a query key: `?status=active`. For operators, namespace them: `?created_at[gte]=2026-01-01`. Pick one operator syntax and never mix.
      - **Sort** with a comma list; `-` prefix = descending: `?sort=-created_at,title`.
      - **Sparse fieldsets** let clients trim payloads: `?fields=id,title`. Whitelist allowed fields server-side.
      - **Reserve** `limit`, `cursor` (or `offset`), `sort`, `fields`, `q` (free-text search) so they never collide with resource fields.
      
      ## Content negotiation
      
      - Default to `application/json`. Honor `Accept`; return `406 Not Acceptable` if you can't satisfy it.
      - Validate `Content-Type` on writes; `415` if unsupported.
      - Version via media type only if you chose header versioning (see versioning reference); otherwise keep it simple.
      
      ## Rate-limit headers
      
      Expose limits so well-behaved clients self-throttle:
      
      ```http
      RateLimit-Limit: 1000
      RateLimit-Remaining: 12
      RateLimit-Reset: 1735689600
      Retry-After: 30
      ```
      
      On 429 always send `Retry-After`. Prefer the IETF `RateLimit-*` fields; the older `X-RateLimit-*` names are still common in the wild.
      
      ## HATEOAS — pragmatic stance
      
      Full hypermedia (links driving all client navigation) is rarely worth it for typical JSON APIs; clients hardcode URLs anyway. Useful subset: include a `Location` on 201, a `Link` header for pagination/successor-version, and self/next links in list envelopes when it costs little. Don't build a hypermedia framework no client will follow.
      
    • versioning-and-evolution.md 2.7 KB
      # Versioning & evolution
      
      Default posture: **evolve additively, version rarely.** A version is a fork of your client base — earn it.
      
      ## Breaking vs non-breaking matrix
      
      | Change | Breaking? | Notes |
      |---|---|---|
      | Add a new optional response field | No | clients ignore unknown fields if you told them to |
      | Add a new endpoint | No | nothing existing changes |
      | Add a new optional query/body param | No | old requests still valid |
      | Add a new enum value | No* | *only if clients were told to tolerate unknown values; otherwise breaking |
      | Loosen a validation rule | No | previously-rejected input now accepted |
      | Remove or rename a field | **Yes** | clients reading it break |
      | Change a field's type or format | **Yes** | `string` → `object`, date format change, etc. |
      | Make an optional field required | **Yes** | old requests start failing |
      | Tighten validation (narrower accepted range) | **Yes** | previously-valid input now rejected |
      | Change a status code for a case | **Yes** | client branching breaks |
      | Change an error `type` URI for a case | **Yes** | clients branch on `type` |
      | Change pagination/cursor semantics | **Yes** | in-flight pagination breaks |
      
      Design hint: tell clients up front to **ignore unknown fields and tolerate unknown enum values** ("must-ignore" / robustness rule). That single contract clause turns many would-be breaking changes into additive ones.
      
      ## Three versioning mechanisms
      
      | Mechanism | Example | Pros | Cons |
      |---|---|---|---|
      | **URL path** | `/v1/projects` | visible, cacheable, browser-testable, expected by most clients | version leaks into every URL; "v" is coarse-grained |
      | **Header / media type** | `Accept: application/vnd.acme.v2+json` | clean URLs, content-negotiated | harder to test/cache, easy to forget, opaque to humans |
      | **Query param** | `/projects?version=2` | trivial to add | pollutes URLs, easy to omit, muddies caching |
      
      **Default to URL path for public APIs.** Reserve header/media-type versioning for internal APIs where tooling controls the header. Don't mix mechanisms.
      
      ## Deprecation & sunset workflow
      
      1. Ship the successor (new field/endpoint/version) additively.
      2. Mark the old surface deprecated in responses:
      
      ```http
      Deprecation: true
      Sunset: Sat, 31 Oct 2026 23:59:59 GMT
      Link: <https://api.acme.com/v2/projects>; rel="successor-version"
      ```
      
      3. Document the migration and the sunset date; notify known integrators.
      4. Hold the deprecated surface working until the `Sunset` date — give clients real time (months, not days, for public APIs).
      5. After sunset, return `410 Gone` for the removed surface (not `404`) so clients learn it was intentional.
      
      Keep the deprecation window generous and the messaging programmatic — clients should learn from headers, not from breakage.
      
  • scripts
    • verify.sh 4.4 KB
      #!/usr/bin/env bash
      # verify.sh — read-only check of an API-design artifact (OpenAPI 3.1 spec).
      #
      # The api-design skill emits its contract as an OpenAPI document. This script
      # finds candidate spec files under a target dir, validates they parse and carry
      # the required OpenAPI keys, and emits non-fatal warnings for the design
      # anti-patterns it can detect statically. It never writes anything.
      #
      # Usage:   verify.sh [TARGET_DIR]   (default: current directory)
      # Exit:    0  no spec found (soft-pass) OR all found specs valid
      #          1  a spec file is present but invalid/unparseable
      #
      # Soft-pass on a clean/empty target: a design may live only in a doc.
      
      set -uo pipefail
      
      TARGET="${1:-.}"
      fail=0
      
      note()  { printf '  - %s\n' "$1"; }
      warn()  { printf '  ! %s\n' "$1"; }
      
      if [ ! -d "$TARGET" ]; then
        echo "verify(api-design): target '$TARGET' is not a directory — nothing to check."
        exit 0
      fi
      
      # Collect candidate OpenAPI files (read-only). Tolerate no matches.
      # Portable to bash 3.2 (macOS default): no mapfile; newline-delimited list.
      specs=()
      while IFS= read -r f; do
        [ -n "$f" ] && specs+=("$f")
      done < <(
        find "$TARGET" \
          \( -name 'openapi.yaml' -o -name 'openapi.yml' -o -name 'openapi.json' \
             -o -name '*.openapi.yaml' -o -name '*.openapi.yml' -o -name '*.openapi.json' \) \
          -type f 2>/dev/null
      )
      
      if [ "${#specs[@]}" -eq 0 ]; then
        echo "verify(api-design): no OpenAPI spec found under '$TARGET' — soft-pass (design may be in-doc only)."
        exit 0
      fi
      
      # Choose a linter if available; else fall back to a structural check.
      LINTER=""
      if command -v redocly >/dev/null 2>&1; then
        LINTER="redocly"
      elif command -v spectral >/dev/null 2>&1; then
        LINTER="spectral"
      fi
      
      # Structural fallback: parse + required keys, using whatever is on PATH.
      parse_check() {
        local f="$1"
        case "$f" in
          *.json)
            if command -v jq >/dev/null 2>&1; then
              jq -e 'has("openapi") and has("info") and has("paths")' "$f" >/dev/null 2>&1
              return $?
            fi
            ;;
          *.yaml|*.yml)
            if command -v python3 >/dev/null 2>&1; then
              python3 - "$f" <<'PY' >/dev/null 2>&1
      import sys
      try:
          import yaml
      except Exception:
          sys.exit(2)  # no yaml lib -> can't check, treat as inconclusive
      with open(sys.argv[1]) as fh:
          d = yaml.safe_load(fh)
      sys.exit(0 if isinstance(d, dict) and all(k in d for k in ("openapi", "info", "paths")) else 1)
      PY
              local rc=$?
              [ "$rc" -eq 2 ] && return 3   # inconclusive
              return $rc
            fi
            ;;
        esac
        return 3  # inconclusive: no suitable parser on PATH
      }
      
      # Static anti-pattern warnings (grep-based, never fatal).
      antipattern_scan() {
        local f="$1"
        if grep -Eiq '"?/[A-Za-z0-9_]*(get|create|update|delete|fetch|list)[A-Za-z0-9_]*"?[[:space:]]*:' "$f"; then
          warn "verb-like path segment(s) detected — paths should be nouns; HTTP methods are the verbs."
        fi
        if grep -Eq '(/[A-Za-z0-9_/{}]*)+[[:space:]]*:' "$f" && ! grep -Eiq 'cursor|limit|offset|first|after|page' "$f"; then
          warn "no pagination params (cursor/limit/offset) found — list endpoints should paginate."
        fi
        if ! grep -Eiq 'problem\+json|"4[0-9][0-9]"|default[[:space:]]*:' "$f"; then
          warn "no 4xx / default error responses found — every operation should define an error response (RFC 9457)."
        fi
      }
      
      echo "verify(api-design): checking ${#specs[@]} spec file(s) under '$TARGET'"
      [ -n "$LINTER" ] && echo "  using linter: $LINTER"
      
      for f in "${specs[@]}"; do
        echo "* $f"
        ok=1
        if [ -n "$LINTER" ]; then
          if [ "$LINTER" = "redocly" ]; then
            redocly lint "$f" >/dev/null 2>&1 || ok=0
          else
            spectral lint "$f" >/dev/null 2>&1 || ok=0
          fi
          if [ "$ok" -eq 0 ]; then
            # Linter unhappy: confirm with structural parse before failing hard.
            parse_check "$f"; pc=$?
            if [ "$pc" -eq 0 ]; then
              warn "$LINTER reported issues but the spec parses and has required keys — review lint output."
            else
              note "spec is invalid per $LINTER."
              fail=1
              continue
            fi
          fi
        else
          parse_check "$f"; pc=$?
          case "$pc" in
            0) : ;;  # valid
            3) warn "no parser on PATH (jq/python3+pyyaml) — skipped structural validation." ;;
            *) note "spec does not parse or is missing required keys (openapi/info/paths)."; fail=1; continue ;;
          esac
        fi
        antipattern_scan "$f"
      done
      
      if [ "$fail" -ne 0 ]; then
        echo "verify(api-design): FAIL — one or more specs are invalid."
        exit 1
      fi
      
      echo "verify(api-design): OK (warnings, if any, are advisory)."
      exit 0
      
  • SKILL.md 12.1 KB
    ---
    name: api-design
    description: "Use when settling the contract of an API you expose, before implementation: resources/URLs, REST vs GraphQL, versioning, one RFC 9457 error envelope, pagination, idempotency — emitted as OpenAPI 3.1. NOT implementing the endpoints (that is `fastapi`/`nestjs`/`go`/`nodejs`), NOT auth hardening (that is `secure-coding`), NOT consuming a third-party API (that is `api-connector-builder`)."
    tags: [api-design, rest, graphql, openapi, versioning, pagination, http, rfc9457, contract-design]
    recommends: [fastapi, nestjs, go, nodejs, secure-coding, webhooks, api-connector-builder, code-review]
    origin: risco
    ---
    
    # API design
    
    ## Your one job
    
    You design the **contract** an API exposes. You do not write the handler. The deliverable is a set of decisions a backend skill can implement directly: resource shapes, URLs, methods, the status-code map, one error envelope, pagination params, versioning rules — ideally captured as an **OpenAPI 3.1** document.
    
    When the user names a framework (FastAPI, NestJS, Go, Node), they own the build; you are pulled in for contract questions. Settle the contract first, then hand off (see [Handoff](#handoff)). Keep every decision framework-neutral: nothing here should mention an ORM, a router, or a DI container.
    
    ## REST vs GraphQL vs hybrid
    
    Pick on traffic shape, not fashion. Decide once, write it down.
    
    | Situation | Choose | Why |
    |---|---|---|
    | CRUD-ish resources, public API, HTTP caching matters | **REST** | URLs map to resources; CDN/proxy caching works on GET + ETag out of the box |
    | Many client shapes, deep nested graphs, mobile over-fetch is real | **GraphQL** | one round-trip, client picks fields; no N endpoints per screen |
    | Stable resource API + one rich read surface for a client app | **Hybrid** | REST for the system of record, a GraphQL read layer on top |
    
    Operational gotcha that decides monitoring: **GraphQL returns HTTP 200 even when a field errored** — failures live in an `errors[]` array next to partial `data`. Your dashboards cannot alert on 5xx; you must alert on the `errors[]` payload. REST signals failure with the HTTP status itself. If your ops team lives on status-code SLOs, that is a point for REST. Schema/nullability design, mutation and error-union conventions, and this error model in full: [`references/graphql-design.md`](references/graphql-design.md).
    
    ## Resource & URL modeling
    
    Resources are **nouns**; HTTP methods are the verbs. Never put a verb in a path.
    
    Rules, each with its reason:
    - **Plural collections, consistent everywhere** — `/projects`, `/projects/{id}`. Pick plural and never mix singular in; inconsistent naming is the most-cited design smell.
    - **Nest sub-resources one level** — `/projects/{id}/tasks`. Deeper than one level (`/projects/{p}/tasks/{t}/comments/{c}`) gets unreadable; link to the flat resource instead (`/comments/{c}`).
    - **Methods carry intent** — `GET` read, `POST` create, `PUT` full replace, `PATCH` partial update, `DELETE` remove. A path never says what it does.
    - **Filter/sort/select via query string, not new paths** — `?status=open&sort=-created_at&fields=id,title`. One `GET /projects` handles all of it; don't mint `/projects/open` and `/projects/byDate`.
    
    | Bad | Good | Why |
    |---|---|---|
    | `POST /createProject` | `POST /projects` | the method is the verb |
    | `GET /getUserOrders/{id}` | `GET /users/{id}/orders` | noun hierarchy, no verb |
    | `GET /project` and `GET /tasks` | `GET /projects` and `GET /tasks` | one plural convention |
    | `GET /projects/active` | `GET /projects?status=active` | filter is a query param |
    | `POST /projects/{id}/delete` | `DELETE /projects/{id}` | method, not path segment |
    
    Full query grammar (filter operators, sparse fieldsets, sort syntax), content negotiation, rate-limit headers and a HATEOAS note: [`references/rest-conventions.md`](references/rest-conventions.md).
    
    ## Status codes that matter
    
    You need a small map, used consistently. Don't overload 200.
    
    ```http
    200 OK            # read / update succeeded, body returned
    201 Created       # resource created — include Location: /projects/{id}
    202 Accepted      # async accepted, not done — return a status URL
    204 No Content    # success, nothing to return (e.g. DELETE)
    400 Bad Request   # malformed syntax / unparseable
    401 Unauthorized  # not authenticated — who are you?
    403 Forbidden     # authenticated but not allowed — I know you, no
    404 Not Found     # resource absent (or hidden from this caller)
    409 Conflict      # state collision — duplicate, version mismatch
    422 Unprocessable # syntactically fine, semantically invalid (validation)
    429 Too Many Req  # rate limited — include Retry-After
    5xx               # your fault, never the client's; never leak the stack
    ```
    
    Two distinctions agents get wrong:
    - **401 vs 403** — 401 means *unauthenticated* (no/invalid credentials); 403 means *authenticated but unauthorized*. Returning 401 on a permission failure leaks that re-auth might help when it won't.
    - **409 vs 422** — 409 is a *state* conflict (the request fights the current server state: dup key, stale version). 422 is a *content* problem (the body parses but fails business rules). Full status-code table with when-each in [`references/rest-conventions.md`](references/rest-conventions.md).
    
    ## Error envelope: RFC 9457
    
    One error shape across **every** endpoint. Adopt **RFC 9457 Problem Details** (the current standard; it obsoletes RFC 7807). Media type `application/problem+json`. Standard members: `type`, `title`, `status`, `detail`, `instance`, plus your own extension members.
    
    ```json
    {
      "type": "https://api.acme.com/problems/validation-error",
      "title": "Your request parameters didn't validate.",
      "status": 422,
      "detail": "due_date must be in the future.",
      "instance": "/projects/8a3/tasks",
      "errors": [
        { "field": "due_date", "message": "must be in the future" }
      ],
      "correlation_id": "req_01H..."
    }
    ```
    
    Rules:
    - **`type` is a stable, machine-readable URI** — clients branch on it, not on `detail`. Never change a `type` string once published.
    - **`title` is human, generic per type; `detail` is human, specific to this occurrence.** `detail` is for people, not parsers.
    - **Always carry a correlation/request id** (extension member) so a support ticket maps to a log line.
    - **Never leak internals** — no stack traces, SQL, internal hostnames, or raw DB ids in `detail`. That is both an information leak and a coupling leak.
    
    ## Pagination
    
    Default to **cursor (keyset)** pagination. Use offset only for small, bounded sets.
    
    | Approach | Use when | Why |
    |---|---|---|
    | **Cursor / keyset** | large or changing datasets, feeds, anything hot | opaque token over an indexed ordered column → constant-time, stable across inserts |
    | **Offset / limit** | small bounded admin lists, fixed reference tables | simple, but the DB scans-and-discards skipped rows (degrades with depth) and **skips or duplicates rows** when data shifts between page loads |
    
    Decision line: if the list can grow unbounded or rows can be inserted between page fetches, use cursor.
    
    REST cursor envelope — same keys on every list endpoint:
    
    ```json
    {
      "data": [ { "id": "...", "title": "..." } ],
      "next_cursor": "eyJpZCI6MTI4N30",
      "has_more": true
    }
    ```
    
    The next page is `GET /projects?cursor=eyJpZCI6MTI4N30&limit=50`. The cursor is opaque — clients must not parse or construct it.
    
    GraphQL has its own de-facto standard: **Relay Connections** — `edges { node, cursor }`, `pageInfo { hasNextPage, endCursor }`, args `first` / `after`. Use it; don't invent a bespoke GraphQL pagination shape. See [`references/graphql-design.md`](references/graphql-design.md).
    
    ## Versioning & evolution
    
    **Prefer additive, non-breaking evolution over a new version.** A new version forks your client base and your maintenance. Most changes don't need one.
    
    - **Non-breaking (no version bump):** adding an optional field, adding a new endpoint, adding a new optional query param, adding a new enum value clients are told to tolerate.
    - **Breaking (needs a version):** removing/renaming a field, changing a type, making an optional field required, changing status-code semantics, changing the error `type` for a case.
    
    When you must version, **use a URL path version (`/v1/...`) for public APIs** — it is visible, cacheable, trivially testable in a browser, and the most common convention clients expect. Header/media-type versioning (`Accept: application/vnd.acme.v2+json`) keeps URLs clean but is harder to test and cache; query-param versioning (`?version=2`) pollutes every URL. Default to path.
    
    Deprecate gracefully with the `Deprecation` and `Sunset` response headers so clients get programmatic warning before removal:
    
    ```http
    Deprecation: true
    Sunset: Sat, 31 Oct 2026 23:59:59 GMT
    Link: <https://api.acme.com/v2/projects>; rel="successor-version"
    ```
    
    Full breaking-vs-non-breaking matrix, the three versioning mechanisms and the deprecation/sunset workflow: [`references/versioning-and-evolution.md`](references/versioning-and-evolution.md).
    
    ## Idempotency & concurrency
    
    - **Make POST/PATCH retry-safe with an `Idempotency-Key` header.** The client sends a unique key; the server replays the original response on a retry instead of double-creating. This is an IETF httpapi draft (not yet an RFC) but is the proven pattern across Stripe, PayPal, and others — adopt it for any create/charge/payment-like operation where a network retry could duplicate work.
    
    ```http
    POST /payments
    Idempotency-Key: 9b1f7c2e-... 
    ```
    
    - **Use `ETag` + `If-Match` for optimistic concurrency** on PUT/PATCH. The server returns an `ETag` (a version fingerprint) on read; the client sends it back in `If-Match` on write. If it no longer matches, the server returns **412 Precondition Failed** — no lost update. Use `If-None-Match` for conditional GET caching.
    
    ## Anti-patterns
    
    | Anti-pattern | Why it bites | Do instead |
    |---|---|---|
    | Verbs in paths (`/getUsers`, `/createProject`) | duplicates HTTP semantics, breaks caching/tooling | noun + HTTP method |
    | Mixed plural/singular collections | clients can't predict URLs | one plural convention everywhere |
    | 200 on error (REST) | breaks status-code monitoring and client error handling | real 4xx/5xx + RFC 9457 body |
    | Different error shape per endpoint | every client writes per-endpoint parsing | one `application/problem+json` shape |
    | Leaking stack traces / SQL / DB ids in errors | info leak + couples clients to internals | generic `title`, safe `detail`, correlation id |
    | Offset pagination on a hot/large feed | slow at depth; skips/dups rows on insert | cursor/keyset pagination |
    | Unbounded list endpoint (no limit) | one client can pull the whole table | enforce a default + max `limit` |
    | New version for every change | forks clients, multiplies maintenance | additive non-breaking evolution |
    | Breaking a field in place on a live version | silently breaks existing clients | new field/version + deprecation headers |
    | 401 for a permission failure | misleads client into re-authing | 403 when authenticated-but-forbidden |
    | 200 for a created resource | hides the create, no `Location` | 201 + `Location` header |
    | Ignoring GraphQL partial `errors[]` | failures invisible to monitoring | alert on `errors[]`, not just HTTP 5xx |
    
    ## Handoff
    
    The contract is the artifact. Emit it as an **OpenAPI 3.1** document — the checkable deliverable a framework skill generates code from. How to shape it, and what `scripts/verify.sh` checks: [`references/openapi-contract.md`](references/openapi-contract.md).
    
    Hand off to the builder:
    - Python/async → [`../fastapi/SKILL.md`](../fastapi/SKILL.md)
    - NestJS / Node DI → [`../nestjs/SKILL.md`](../nestjs/SKILL.md)
    - Go `net/http` → [`../go/SKILL.md`](../go/SKILL.md)
    - Node generally → [`../nodejs/SKILL.md`](../nodejs/SKILL.md)
    
    Adjacent concerns you do **not** own:
    - Auth hardening, OWASP, CORS/CSP threat-modeling → [`../secure-coding/SKILL.md`](../secure-coding/SKILL.md) (you only place 401/403/scopes in the contract)
    - Receiving inbound webhooks (HMAC verify, replay, dedupe) → [`../webhooks/SKILL.md`](../webhooks/SKILL.md)
    - Building an outbound client to a third-party API → [`../api-connector-builder/SKILL.md`](../api-connector-builder/SKILL.md)
    - Reviewing handler code (not the design) → [`../code-review/SKILL.md`](../code-review/SKILL.md)
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related