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.
By the end you'll be able to
- Trace a complete API from resource model through every endpoint, showing how each RDP pattern contributes.
- Produce an endpoint table and compact request/response for a real API spec review or interview.
- Serve a functional mock using static JSON files or Prism from an OpenAPI skeleton.
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}/tasks | List tasks (with pagination + filtering) | rdp-02 |
| POST | /v1/projects/{project}/tasks | Create a task | rdp-02 |
| GET | /v1/projects/{project}/tasks/{task} | Get a task | rdp-02 |
| PUT | /v1/projects/{project}/tasks/{task} | Replace a task (full update) | rdp-02 |
| PATCH | /v1/projects/{project}/tasks/{task} | Partial update with update_mask | rdp-04 |
| DELETE | /v1/projects/{project}/tasks/{task} | Soft-delete a task | rdp-07 |
| POST | /v1/projects/{project}/tasks/{task}:complete | Mark task completed (custom method) | rdp-03 |
| POST | /v1/projects/{project}/tasks/{task}:undelete | Restore a soft-deleted task | rdp-07 |
| POST | /v1/projects/{project}/tasks/{task}:expunge | Hard-delete a task (elevated permission) | rdp-07 |
| POST | /v1/projects/{project}/tasks:batchGet | Fetch multiple tasks by name in one call | rdp-06 |
| POST | /v1/projects/{project}/tasks:exportTasks | Start a long-running export (returns Operation) | rdp-05 |
| GET | /v1/operations/{operation} | Poll a long-running operation | rdp-05 |
Architecture diagram: how the patterns layer
Step-by-step: assemble the API
- Resource model & URL layout — establish the naming hierarchy (rdp-01). Every subsequent endpoint is a path under this hierarchy.
- Standard CRUD methods — wire up GET (get + list), POST (create), PUT (update), PATCH (partial update), DELETE (soft-delete) (rdp-02).
- Custom method :complete — add the task completion business action as a POST to a verb-suffix path (rdp-03).
- PATCH with update_mask — implement partial updates that only touch named fields, preventing overwrite races (rdp-04).
- Long-running :exportTasks — wrap the slow export in a Long-Running Operation that returns immediately with a polling handle (rdp-05).
- batchGet — add a single-round-trip multi-fetch to reduce client-side N+1 calls (rdp-06).
- Soft delete + undelete + validate_only — make DELETE reversible, add :expunge for hard deletion, and add the dry-run flag to CREATE (rdp-07).
- Pagination + filtering on List — add page_size/page_token and a filter query parameter to List.
- Error shape — standardize all errors to a single JSON envelope.
- 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.
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.
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 }
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.
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 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.
Under the hood: mock servers and contract validation
A mock server like Stoplight Prism bridges the gap between spec design and developer integration. It executes requests through a structured engine:
- Spec Compilation: On startup, the mock engine loads the OpenAPI specification, compiles the schema validation rules using a JSON Schema compiler, and populates an internal route trie.
- Request Interception & Validation: When a client sends a request, the engine checks the URI against the route trie. It then validates headers, query params, and body structure against the schema definitions.
- Mock Response Generation: If request validation passes, the engine looks for
responsesdefined in the spec. It selects a response (usually matching the requested status code, defaulting to 2xx), retrieves itsexampleobjects, and automatically generates mock data for any unpopulated fields using JSON Schema definitions. If validation fails, it generates a standard 400 error.
How to debug & inspect it
Debugging mock APIs requires auditing OpenAPI spec syntax, validating payload constraints, and inspecting mock server execution logs.
Lint the OpenAPI specification and start the mock server:
Symptom → Cause → Fix:
| Symptom | Likely Cause | Fix |
|---|---|---|
| Mock server returns 404 for valid looking paths | Path parameter names or version prefixes in request do not match spec paths | Compare request URL to the path definitions in the OpenAPI YAML (e.g. check for missing /v1) |
| Mock server returns 400 validation error on client request | The client request payload is missing required schema fields or violates format rules | Check the mock server console logs to see which JSON schema validation rule was violated |
| Response fields contain generic strings or meaningless fake values | OpenAPI schema components lack specific examples or helper format declarations | Add explicit example values and specify formats (like format: date-time) in the schema properties |
When not to use this
- Do not ship mocks as production contracts without the same idempotency keys, auth, and error model as the real API.
- Do not skip retry tests on Create: double-submit the same
Idempotency-Keyand assert one resource. - Prefer contract tests / OpenAPI mocks when the goal is client generation, not hand-built HTML demos.
Failure under retry (mock Create)
Timeout after first Create succeeds: client retries with the same key → mock must return the same resource id (200/201 replay), not a second row. Crash mid-write with key stored as pending → second call waits or 409 until complete.
When not to use this pattern
- Do not invent custom methods when a standard method + field mask expresses the intent (AIP-style).
- Do not soft-delete everything — compliance and storage cost; prefer hard delete + audit log where law allows.
- Skip LRO ceremony for work that finishes under ~1–2s p99; return the result synchronously.
- Batch endpoints are not free parallelism — partial failure contracts are harder than N simple calls with idempotency.
Why resource-oriented design
Resources + standard verbs give a shared mental model across teams: identity, lifecycle, error model, and long-running status. Custom verbs explode surface area. Standout APIs make the nouns right so the verbs stay boring.
Failure under retry
| Method pattern | Retry risk | Defense |
|---|---|---|
| Create | Duplicate resource | Idempotency-Key or client-assigned name |
| Update / Patch | Lost update | ETag / version / If-Match |
| Delete | Already gone | Idempotent 404 or 200 with same end state |
| LRO | Stuck queued | Sweeper, lease, poll contract |
🧠 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:
- Resource name:
projects/{project}/tasks/{task}/comments/{comment} - 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
- Create request/response: POST body
{"comment":{"body":"LGTM — approved."}}; server setsauthorfrom the auth token; returns 201 with the comment resource including server-set fields. - 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
- A complete API spec has three layers: resource model + URL layout, per-endpoint methods, and cross-cutting behaviors (pagination, field masks, LRO, batch, soft-delete, validate_only, error shape, auth, versioning).
- Start every spec with an endpoint table — it takes minutes, fits on one screen, and is the fastest artifact to review with stakeholders.
- Every pattern from this module addresses a real failure mode: LRO prevents timeout on slow operations; update_mask prevents overwrite races; soft-delete prevents unrecoverable data loss; validate_only prevents surprise errors after a dry-run looks clean.
- An OpenAPI skeleton + Prism gives you a live contract-validated mock before any server code is written — this unblocks client development and catches design mistakes early.
- Every endpoint needs auth — not just the obvious CRUD ones. Custom methods and batch endpoints are easy to miss in access-control reviews.
Sources & further reading
- Google AIP-1 — AIP overview and purpose
- Google AIP-193 — Errors
- OpenAPI Specification 3.1.0
- Stoplight Prism — open-source API mock server
- rdp-01: Resource model & naming
- rdp-02: Standard methods
- rdp-03: Custom methods
- rdp-04: Field masks
- rdp-05: Long-running operations
- rdp-06: Batch operations
- rdp-07: Soft deletion & request validation
- Case studies: design framework
- Prep: design method cheatsheet
- Security 08: API keys, JWTs & hardening
- Reliability 01: API versioning