API Design

Resource Design Patterns · Lesson 08

Capstone: build a complete mock API

Every pattern from this module — resource naming, standard methods, custom methods, field masks, long-running operations, batch, soft delete, validation — converges here. Follow this lesson and you will have a complete, runnable Tasks API specification you can serve as a mock today.

⏱ 25 min Difficulty: advanced Prereq: rdp-01 through rdp-07

By the end you'll be able to

The API we are building: Tasks

The Tasks API manages work items inside projects. A project is the parent resource; a task is the child. Every pattern from this module is exercised on one or both of these resources. The result is not a toy — it is the kind of spec you would present in a design review or check into a company API registry.

Resource hierarchy (from rdp-01):

Collection:  /v1/projects
Resource:    /v1/projects/{project}

Collection:  /v1/projects/{project}/tasks
Resource:    /v1/projects/{project}/tasks/{task}

Task resource fields:

{
  "name":         "projects/{project}/tasks/{task}",  // server-assigned, immutable
  "title":        "string",                           // required, 1–500 chars
  "notes":        "string",                           // optional
  "status":       "OPEN | IN_PROGRESS | DONE",
  "due_time":     "RFC 3339 timestamp | null",
  "assignee":     "users/{user} | null",
  "labels":       ["string"],
  "completed":    false,                              // set by :complete
  "create_time":  "RFC 3339 timestamp",               // server-set, immutable
  "update_time":  "RFC 3339 timestamp",               // server-set on every write
  "delete_time":  "RFC 3339 timestamp | null",        // soft-delete sentinel
  "expire_time":  "RFC 3339 timestamp | null"         // expiry after soft-delete
}

Complete endpoint table

Every endpoint in the API. Pattern links point back to the lesson that introduced each concept.

Method Path Purpose Pattern
GET/v1/projects/{project}/tasksList tasks (with pagination + filtering)rdp-02
POST/v1/projects/{project}/tasksCreate a taskrdp-02
GET/v1/projects/{project}/tasks/{task}Get a taskrdp-02
PUT/v1/projects/{project}/tasks/{task}Replace a task (full update)rdp-02
PATCH/v1/projects/{project}/tasks/{task}Partial update with update_maskrdp-04
DELETE/v1/projects/{project}/tasks/{task}Soft-delete a taskrdp-07
POST/v1/projects/{project}/tasks/{task}:completeMark task completed (custom method)rdp-03
POST/v1/projects/{project}/tasks/{task}:undeleteRestore a soft-deleted taskrdp-07
POST/v1/projects/{project}/tasks/{task}:expungeHard-delete a task (elevated permission)rdp-07
POST/v1/projects/{project}/tasks:batchGetFetch multiple tasks by name in one callrdp-06
POST/v1/projects/{project}/tasks:exportTasksStart a long-running export (returns Operation)rdp-05
GET/v1/operations/{operation}Poll a long-running operationrdp-05

Architecture diagram: how the patterns layer

Client Auth (Bearer token) · Versioning (/v1/…) · Request-Id header URL routing: /v1/projects/{project}/tasks/{task}[:{method}] Standard: GET · POST · PUT · PATCH · DELETE rdp-02 Custom: :complete · :undelete · :expunge rdp-03, rdp-07 Pagination · update_mask (rdp-04) · LRO :exportTasks (rdp-05) batchGet (rdp-06) · soft-delete fields (rdp-07) · validate_only (rdp-07) Consistent error shape {code, status, message, details[]}
Every request passes through auth and versioning, hits the URL router, and lands in either a standard or custom method handler. Cross-cutting behaviors (pagination, field masks, LRO, batch, soft-delete, validate_only) are orthogonal to the method and applied per-endpoint as needed.

Step-by-step: assemble the API

  1. Resource model & URL layout — establish the naming hierarchy (rdp-01). Every subsequent endpoint is a path under this hierarchy.
  2. Standard CRUD methods — wire up GET (get + list), POST (create), PUT (update), PATCH (partial update), DELETE (soft-delete) (rdp-02).
  3. Custom method :complete — add the task completion business action as a POST to a verb-suffix path (rdp-03).
  4. PATCH with update_mask — implement partial updates that only touch named fields, preventing overwrite races (rdp-04).
  5. Long-running :exportTasks — wrap the slow export in a Long-Running Operation that returns immediately with a polling handle (rdp-05).
  6. batchGet — add a single-round-trip multi-fetch to reduce client-side N+1 calls (rdp-06).
  7. Soft delete + undelete + validate_only — make DELETE reversible, add :expunge for hard deletion, and add the dry-run flag to CREATE (rdp-07).
  8. Pagination + filtering on List — add page_size/page_token and a filter query parameter to List.
  9. Error shape — standardize all errors to a single JSON envelope.
  10. Auth & versioning notes — document the Bearer-token expectation and the /v1/ version prefix (see sec-08, rel-01).

Compact request/response for every endpoint

Each block shows the minimum viable request and a representative success response. Error shapes are consolidated at the end.

1 — List tasks (pagination + filtering)

GET /v1/projects/proj_42/tasks?page_size=2&filter=status%3DOPEN&show_deleted=false
Authorization: Bearer <token>

200 OK
{
  "tasks": [
    { "name": "projects/proj_42/tasks/task_01", "title": "Update onboarding docs", "status": "OPEN", "create_time": "2026-05-01T10:00:00Z", "update_time": "2026-05-01T10:00:00Z" },
    { "name": "projects/proj_42/tasks/task_02", "title": "Write API design doc", "status": "OPEN", "create_time": "2026-05-02T09:00:00Z", "update_time": "2026-05-02T09:00:00Z" }
  ],
  "next_page_token": "eyJvZmZzZXQiOjJ9"
}

2 — Create task

POST /v1/projects/proj_42/tasks
Authorization: Bearer <token>
Content-Type: application/json

{
  "task": {
    "title":    "Prepare quarterly report",
    "due_time": "2026-07-01T17:00:00Z",
    "assignee": "users/alice",
    "labels":   ["finance", "q2"]
  }
}

201 Created
{
  "name":        "projects/proj_42/tasks/task_77",
  "title":       "Prepare quarterly report",
  "status":      "OPEN",
  "due_time":    "2026-07-01T17:00:00Z",
  "assignee":    "users/alice",
  "labels":      ["finance", "q2"],
  "completed":   false,
  "create_time": "2026-06-20T14:00:00Z",
  "update_time": "2026-06-20T14:00:00Z"
}

3 — Get task

GET /v1/projects/proj_42/tasks/task_77
Authorization: Bearer <token>

200 OK
{ /* same body as Create response above */ }

4 — PATCH with update_mask (partial update)

PATCH /v1/projects/proj_42/tasks/task_77
Authorization: Bearer <token>
Content-Type: application/json

{
  "update_mask": "title,due_time",
  "task": {
    "title":    "Prepare Q2 quarterly report",
    "due_time": "2026-07-05T17:00:00Z"
  }
}

200 OK
{
  "name":        "projects/proj_42/tasks/task_77",
  "title":       "Prepare Q2 quarterly report",
  "due_time":    "2026-07-05T17:00:00Z",
  "assignee":    "users/alice",
  // ... other fields unchanged
  "update_time": "2026-06-20T14:05:00Z"
}

5 — Custom method :complete

POST /v1/projects/proj_42/tasks/task_77:complete
Authorization: Bearer <token>
Content-Type: application/json

{}

200 OK
{
  "name":        "projects/proj_42/tasks/task_77",
  "status":      "DONE",
  "completed":   true,
  "update_time": "2026-06-20T14:10:00Z"
}

6 — Long-running :exportTasks

POST /v1/projects/proj_42/tasks:exportTasks
Authorization: Bearer <token>
Content-Type: application/json

{
  "format": "CSV",
  "filter": "status=DONE"
}

202 Accepted
{
  "name":     "operations/op_export_7f3a",
  "done":     false,
  "metadata": { "progress_percent": 0 }
}

# Poll until done
GET /v1/operations/op_export_7f3a

200 OK (completed)
{
  "name":     "operations/op_export_7f3a",
  "done":     true,
  "response": {
    "download_url": "https://storage.example.com/exports/proj_42_tasks.csv",
    "row_count":    142
  }
}

7 — batchGet

POST /v1/projects/proj_42/tasks:batchGet
Authorization: Bearer <token>
Content-Type: application/json

{
  "names": [
    "projects/proj_42/tasks/task_01",
    "projects/proj_42/tasks/task_77"
  ]
}

200 OK
{
  "tasks": [
    { "name": "projects/proj_42/tasks/task_01", "title": "Update onboarding docs", "status": "OPEN" },
    { "name": "projects/proj_42/tasks/task_77", "title": "Prepare Q2 quarterly report", "status": "DONE" }
  ]
}

8 — Soft delete, :undelete, :expunge

# Soft-delete
DELETE /v1/projects/proj_42/tasks/task_77
200 OK → { "name":..., "delete_time":"2026-06-20T15:00:00Z", "expire_time":"2026-07-20T15:00:00Z" }

# Restore
POST /v1/projects/proj_42/tasks/task_77:undelete
200 OK → { "name":..., "delete_time":null, "expire_time":null }

# Hard-delete (elevated permission)
POST /v1/projects/proj_42/tasks/task_77:expunge
200 OK → {}

9 — validate_only dry-run

POST /v1/projects/proj_42/tasks
Authorization: Bearer <token>
Content-Type: application/json

{
  "validate_only": true,
  "task": { "title": "Safe to create?", "assignee": "users/alice" }
}

200 OK → {} (nothing created; validation passed)

10 — Consistent error shape

# Every error uses this envelope
{
  "error": {
    "code":    400,
    "status": "INVALID_ARGUMENT",
    "message": "Field 'title' is required.",
    "details": [
      {
        "@type": "type.googleapis.com/google.rpc.BadRequest",
        "field_violations": [
          { "field": "title", "description": "must not be empty" }
        ]
      }
    ]
  }
}

HTTP status maps to code; status is the canonical Google RPC status name (see AIP-193). The details array carries structured machine-readable context.

Stand up the mock

You now have a complete spec. The fastest path to a running mock is to serve pre-written JSON files, one per endpoint. A slightly richer option is to generate a live mock from an OpenAPI document using Prism. Both approaches are described below.

Option A — Static JSON files + any HTTP server

Create one JSON file per unique response. Map request paths to files using the server's routing rules. This works with python -m http.server, npx serve, or any static host.

mkdir -p mock/v1/projects/proj_42/tasks cat > mock/v1/projects/proj_42/tasks/index.json <<'EOF' { "tasks": [ ... ], "next_page_token": "" } EOF # GET /v1/projects/proj_42/tasks → serve index.json npx serve mock --listen 8080 Serving mock on http://localhost:8080 curl http://localhost:8080/v1/projects/proj_42/tasks/task_01.json { "name": "projects/proj_42/tasks/task_01", ... }

Limitation: static files cannot distinguish POST from GET, so all method-differentiated responses (create vs list) need a more capable router. For quick demos this is fine; for contract testing, use Option B.

Option B — Prism from an OpenAPI skeleton

Prism reads an OpenAPI 3.x document and serves a mock that validates requests and returns example responses. Install it and point it at the skeleton file.

npm install -g @stoplight/prism-cli prism mock tasks-api.openapi.yaml --port 4010 [CLI] ... Prism is listening on http://127.0.0.1:4010 curl -X POST http://127.0.0.1:4010/v1/projects/proj_42/tasks \ -H "Content-Type: application/json" \ -d '{"task":{"title":"Test task"}}' HTTP/1.1 201 Created { "name": "projects/proj_42/tasks/...", "title": "Test task", ... }

Minimal OpenAPI skeleton

This skeleton covers the List and Create endpoints. Extend it with the same pattern for every other endpoint in the table above.

openapi: "3.1.0"
info:
  title: Tasks API
  version: "1.0.0"
servers:
  - url: https://api.example.com
paths:
  /v1/projects/{project}/tasks:
    get:
      summary: List tasks
      parameters:
        - { name: project, in: path, required: true, schema: { type: string } }
        - { name: page_size, in: query, schema: { type: integer, default: 20 } }
        - { name: page_token, in: query, schema: { type: string } }
        - { name: filter, in: query, schema: { type: string } }
        - { name: show_deleted, in: query, schema: { type: boolean, default: false } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListTasksResponse" }
    post:
      summary: Create task
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateTaskRequest" }
      responses:
        "201":
          description: Task created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
components:
  schemas:
    Task:
      type: object
      properties:
        name:        { type: string, readOnly: true }
        title:       { type: string }
        notes:       { type: string }
        status:      { type: string, enum: [OPEN, IN_PROGRESS, DONE] }
        due_time:    { type: string, format: date-time, nullable: true }
        assignee:    { type: string, nullable: true }
        labels:      { type: array, items: { type: string } }
        completed:   { type: boolean }
        create_time: { type: string, format: date-time, readOnly: true }
        update_time: { type: string, format: date-time, readOnly: true }
        delete_time: { type: string, format: date-time, nullable: true, readOnly: true }
        expire_time: { type: string, format: date-time, nullable: true, readOnly: true }
    CreateTaskRequest:
      type: object
      properties:
        validate_only: { type: boolean, default: false }
        task: { $ref: "#/components/schemas/Task" }
    ListTasksResponse:
      type: object
      properties:
        tasks:           { type: array, items: { $ref: "#/components/schemas/Task" } }
        next_page_token: { type: string }
🎯 Interview angle

A system design question asking you to "design a task management API" is exactly this lesson. Walk the interviewer through the endpoint table first — it shows scope. Then pick the two or three most interesting design decisions to discuss in depth: why soft-delete instead of hard-delete, how the LRO pattern avoids request timeouts on the export, how update_mask prevents partial-update races. Showing you can reason about the tradeoffs, not just list the endpoints, is what separates strong candidates.

⚠️ Common trap

Forgetting auth on custom methods and batch endpoints. Standard CRUD endpoints are obvious targets for access control, so they tend to get checked. Custom methods like :exportTasks and :batchGet are easy to overlook. The :exportTasks operation is particularly risky — it could export an entire project's task history to anyone who discovers the endpoint. Every endpoint in the table must check auth, not just the ones with data in the URL path.

✅ Do this, not that

Do write the endpoint table before writing any code or OpenAPI YAML. A table forces you to name every operation, choose HTTP methods deliberately, and spot duplicates or gaps. It is the fastest format to review with stakeholders and takes 10 minutes to draft. Don't start with the OpenAPI YAML — it is dense, easy to get lost in schema details, and hides the overall shape of the API.

🧠 Quick check

1. The :exportTasks endpoint returns a 202 with an operation name rather than waiting for the export to finish. Why?

The Long-Running Operation pattern (rdp-05) decouples the start of a slow job from its completion. The client gets an operation handle immediately, polls for progress, and retrieves the result when done — no timeout risk, and progress is observable.

2. A client needs the same three tasks repeatedly in a UI. Currently it issues three separate GET requests. Which endpoint should it switch to?

batchGet (rdp-06) is the correct tool: it fetches a specific set of resources by name in a single round trip. The filter approach on List is not designed for name-based lookups and would require parsing or server-side optimization. batchGet is also semantically clear — "give me exactly these resources."

3. You want to change only the assignee field of a task without affecting any other fields. Which request is correct?

PATCH with update_mask (rdp-04) is the safest option. It tells the server exactly which fields to update. PUT replaces the entire resource — if another client updated the title between your GET and PUT, you would overwrite their change. The :assign custom method is a reasonable alternative but adds endpoint count without adding capability.

4. After completing this module you build the Tasks API spec. A colleague asks: "How do I verify the API works before I write any production code?" What do you recommend?

A mock server from the OpenAPI spec (e.g. Prism) lets both API consumers and producers validate the contract before any implementation is written. This unblocks frontend/client development and surfaces contract mismatches early — before they are expensive to fix in production code.

✍️ Capstone exercise: extend the spec with a new resource

You have been asked to add Comments to the Tasks API. A comment belongs to a task, has fields body (string, required), author (string, server-set from auth), create_time, and update_time. Comments can be soft-deleted. There is no update operation — comments are immutable once created.

Produce: (1) the resource name format, (2) the complete endpoint table for comments (method · path · purpose), (3) the Create request/response, (4) one design decision you had to make and why.

Model answer:

  1. Resource name: projects/{project}/tasks/{task}/comments/{comment}
  2. Endpoint table:
    • POST /v1/projects/{project}/tasks/{task}/comments — Create comment
    • GET /v1/projects/{project}/tasks/{task}/comments — List comments
    • GET /v1/projects/{project}/tasks/{task}/comments/{comment} — Get comment
    • DELETE /v1/projects/{project}/tasks/{task}/comments/{comment} — Soft-delete comment
    • POST /v1/projects/{project}/tasks/{task}/comments/{comment}:undelete — Restore comment
  3. Create request/response: POST body {"comment":{"body":"LGTM — approved."}}; server sets author from the auth token; returns 201 with the comment resource including server-set fields.
  4. Design decision example: "I omitted PATCH/PUT entirely because comments are immutable by spec. If we allowed edits, a reader couldn't trust that the comment they are looking at reflects what was originally written. Immutability is a feature, not a limitation. If the business later needs edit history, that would be the time to add revisions (AIP-162), not a plain PATCH."

Rubric: Full marks for a correct resource name, a complete endpoint table with no missing or spurious methods, a valid Create request/response, and a clearly reasoned design decision. Bonus: noting that author must be server-set (never client-supplied) because clients cannot be trusted to self-report their own identity.

Key takeaways

Sources & further reading