Exercises — REST API design — resources, HTTP methods, status codes, versioning, pagination
Prerequisite ideas you may want open in another tab: HTTP protocol, Idempotency and retries, Authentication vs Authorization, Caching and ETags, API versioning and backward compatibility.
Level 1 — Recognition
Goal: can you name the right method / status code / property on sight?
Exercise 1.1 — Match the verb
For each action, name the single best HTTP method: (a) "read the list of all orders", (b) "wipe order 9 off the server", (c) "create a brand-new order where the server picks the id", (d) "change only the status field of order 9".
Recall Solution
- (a)
GET— reading, never changes state (it is safe). - (b)
DELETE— removes a known item at a known URI. - (c)
POST— creating on a collection when the server assigns the id. - (d)
PATCH— a partial update of one field;PUTwould risk wiping the fields you didn't send.
Exercise 1.2 — Classify the status code
Put each code in its class (success / redirect / client-fault / server-fault) using only the first digit: 204, 301, 403, 500, 429, 201.
Recall Solution
Recall the class rule:
204→ 2xx success (success, nothing to return).301→ 3xx redirect.403→ 4xx client-fault (forbidden).500→ 5xx server-fault.429→ 4xx client-fault (too many requests — the client sent too many).201→ 2xx success (created).
Exercise 1.3 — Safe or Idempotent?
For each method, tick the boxes safe and/or idempotent: GET, POST, PUT, DELETE. (Recap: safe = changes nothing on the server; idempotent = doing it many times ends in the same state as doing it once.)
Recall Solution
Keep the two definitions in mind: safe = no state change at all; idempotent = repeat-safe (same final state).
GET— safe ✅, idempotent ✅ (reading twice = reading once, changes nothing).POST— safe ✗, idempotent ✗ (two creates = two rows).PUT— safe ✗ (it writes), idempotent ✅ (replace to the same value twice = same final state).DELETE— safe ✗, idempotent ✅ (deleting an already-deleted thing leaves it deleted).
More depth in Idempotency and retries.
Level 2 — Application
Goal: apply the rules to concrete requests and compute simple answers.
Exercise 2.1 — Pick the status code
Your server receives each request. Give the single best status code:
(a) POST /v1/users succeeds and creates user 42.
(b) DELETE /v1/users/42 succeeds, nothing to return.
(c) A request arrives with no auth token at all.
(d) A valid, logged-in user tries to delete someone else's account they don't own.
(e) PATCH /v1/users/42 with {"email":"taken@x.com"} where that email already belongs to user 7.
Recall Solution
- (a)
201 Created+ aLocation: /v1/users/42header so the client learns the new URI. - (b)
204 No Content— success with an empty body. - (c)
401 Unauthorized— we don't know who you are (authentication missing). - (d)
403 Forbidden— we know who you are, you're just not allowed (authorization). See Authentication vs Authorization. - (e)
409 Conflict— the request clashes with current server state (duplicate email).
Exercise 2.2 — Offset arithmetic
Page size is . (a) What offset fetches page 4? (b) Which row indices does page 4 cover (as a half-open interval)? (c) If there are items total, how many pages exist?
Recall Solution
Use .
- (a) .
- (b) Page covers indices . Using the half-open convention defined at the top of the page, that square-then-round bracket means include 75, exclude 100 — i.e. rows 75 through 99, which is exactly rows.
- (c) Round up because leftovers still need a page: Check: 4 full pages cover rows; 18 rows remain, needing a 5th page. ✅
Exercise 2.3 — Full replace vs partial merge
User 42 currently is {"name":"Ada","email":"ada@x.com","bio":"engineer"}. A client sends PUT /v1/users/42 with body {"email":"ada@new.com"}. What does the resource look like afterward, and what should the client probably have sent instead?
Recall Solution
PUT = full replace. Fields you omit are treated as "not present", so name and bio get wiped/defaulted:
The client wanted to change one field, so it should have sent PATCH (partial merge), leaving name and bio intact.
Level 3 — Analysis
Goal: reason about why a design breaks, and choose between competing options.
Exercise 3.1 — The shifting-feed bug
A social feed uses GET /posts?limit=10&offset=10 for "page 2". Between a user loading page 1 and requesting page 2, 3 new posts are inserted at the top (newest-first ordering). Explain exactly which posts the user sees twice or skips, and give the fix.
Study the two figures below before answering — the first shows the calm "before" state, the second shows the damage after inserts.
Figure 1 — the state at page-1 load. Each chalk box is one post, stacked newest-at-top with its index labelled on the right. The pink bracket marks the ten posts (indices 0–9) that page 1 already showed the user. The yellow arrow marks where offset=10 says the next window should begin. Right now everything lines up: page 2 would start at index 10, cleanly after page 1.

Figure 2 — the same feed after 3 inserts. Three pink "NEW post" boxes have been pushed in at the top, so every old post slides down by 3 index slots. The yellow dashed boxes highlight the window that offset=10 now selects (new indices 10, 11, 12). Crucially, those three slots are occupied by old posts 7, 8, 9 — items the user already saw on page 1. The diagram makes the mechanism visible: the offset counts positions, and positions moved, so the window re-grabs already-seen rows. That is the duplicate bug, drawn.

Recall Solution
What happens. With newest-first ordering, inserting 3 posts at the top pushes every existing post down by 3 index positions. Page 1 already showed the old top-10 (old indices 0–9). Now:
- The 3 new posts occupy new indices 0–2.
- Old posts 0–6 now sit at new indices 3–9 — but the client already saw them on page 1.
- Page 2 requests
offset=10, limit=10→ new indices 10–19. Old index 7,8,9 have moved to new indices 10,11,12 — so the user sees old posts #7, #8, #9 again (duplicates, the yellow dashed boxes in Figure 2), and the truly-next items get pushed off.
In general, inserting items at the top makes the last items of the previous page reappear at the start of the next page (or, for deletions, makes items get skipped).
The fix: cursor (keyset) pagination. Instead of "skip 10", remember the sort key of the last item you showed and ask for items strictly past it: Now new posts at the top don't affect the window at all — the cursor points at a fixed row, not a shifting count.
Exercise 3.2 — Offset vs cursor trade-off
For each requirement, choose offset/limit or cursor and justify in one line: (a) an admin dashboard with clickable page numbers "1 2 3 … 137", (b) an infinite-scroll activity feed over a 50-million-row table, (c) jumping directly to the last page.
Recall Solution
- (a) Offset/limit — you need to jump to arbitrary page 137, which offset supports directly and cursor cannot.
- (b) Cursor — the data is live and huge; cursor is stable under inserts and fast at any depth (a
WHERE id < cursoruses the index — see Database indexing — instead of scanning millions of skipped rows). - (c) Offset/limit — "last page" means
offset = (pages-1)*L, a random jump only offset gives you. (Cursor can't leap to the end.)
Level 4 — Synthesis
Goal: combine methods, codes, versioning and pagination into a coherent contract.
Exercise 4.1 — Design a resource contract
Design the endpoints for a comments resource nested under posts. Specify method + path + success status for: list comments on post 5, read one comment, create a comment, edit only a comment's text, delete a comment. Then say which are idempotent.
Recall Solution
| Action | Method + Path | Success code |
|---|---|---|
| List comments on post 5 | GET /v1/posts/5/comments |
200 OK |
| Read comment 88 | GET /v1/posts/5/comments/88 |
200 OK |
| Create a comment | POST /v1/posts/5/comments |
201 Created + Location |
| Edit only the text | PATCH /v1/posts/5/comments/88 |
200 OK |
| Delete a comment | DELETE /v1/posts/5/comments/88 |
204 No Content |
WHY each status, row by row:
- List →
200 OK: aGETthat returns data uses the generic success code with the collection in the body. (An empty list is still a200with[], not a404— the collection exists, it's just empty.) - Read one →
200 OK: same reasoning; the single comment is returned in the body. Only if comment 88 doesn't exist would this become404. - Create →
201 Created+Location:POSTmade a new resource, so we use the more specific Created code (not plain200) and add aLocation: /v1/posts/5/comments/88header so the client learns the URI of the thing it just made. - Edit text →
200 OK: thePATCHsucceeded and we return the updated comment, so generic success with a body. (Some APIs use204if they return nothing, but returning the fresh state is friendlier.) - Delete →
204 No Content: the delete worked and there's nothing meaningful to send back, so No Content — success with an empty body, which is the conventional DELETE reply.
Idempotent ones: the GETs, the PATCH here (writing the same text twice ends identically), and DELETE. The POST is not idempotent — resending creates a second comment. Note the URLs name nouns (comments), never actions.
Exercise 4.2 — Is it a breaking change?
You maintain /v1/users. For each proposed change, say breaking or additive (safe), and whether it forces a /v2: (a) add a new optional field nickname to the response, (b) rename email to emailAddress, (c) make the previously-optional country field required on create, (d) add a brand-new endpoint GET /v1/users/42/orders.
Recall Solution
- (a) Additive/safe — old clients ignore the new field. No
/v2needed. - (b) Breaking — renaming a field breaks every client reading
email. Needs/v2(or dual-write both keys for a deprecation window). - (c) Breaking — old clients that omit
countrynow get rejected. Needs/v2. - (d) Additive/safe — a new endpoint touches no existing client.
Design goal: keep changes additive so you bump the major version rarely. More in API versioning and backward compatibility.
Level 5 — Mastery
Goal: full end-to-end reasoning, including a retry/failure edge case and exact arithmetic.
Exercise 5.1 — The timed-out payment
A client sends POST /v1/payments to charge $50. The request succeeds on the server (money moves, payment 900 created), but the response is lost to a network drop. The client's library auto-retries the identical POST. (a) What goes wrong? (b) Redesign so the retry is safe without changing the money logic. (c) After your fix, what status should the retried request return?
Recall Solution
- (a)
POSTis not idempotent. The retry creates a second payment 901 → the customer is charged $100 for one $50 purchase. - (b) Attach an idempotency key: the client generates a unique key once and sends it on the first attempt and every retry (e.g. header
Idempotency-Key: abc-123). The server records "keyabc-123→ payment 900". On the retry it sees the key already exists and returns the stored result instead of charging again. (This is precisely how real payment APIs turn a non-idempotent POST into a safely-retryable one — see Idempotency and retries.) - (c) Recommended: replay the original
201 Createdwith payment 900's stored body. The reasoning: the retry must look to the client exactly like the request that (unknown to it) already succeeded — and that original request created a resource, whose correct code is201. So the server replays the stored201and the sameLocation: /v1/payments/900. A plain200 OKis an acceptable fallback if you don't cache the full original response, but you must never return a fresh201for a new charge, and never409(nothing conflicts — it's the same logical request). The client must observe exactly one successful $50 charge.
Exercise 5.2 — Full pagination + versioning trace
An endpoint GET /v2/events?limit=L&offset=O serves an events table with rows, page size . (a) How many pages exist? (b) What offset fetches the last page? (c) How many rows does the last page return? (d) The table is a fast-growing live log — argue in one line whether /v2 should have shipped offset or cursor pagination.
Recall Solution
- (a) .
- (b) The last page is page 34, so .
- (c) The last page covers indices . Using our half-open convention this includes 990, excludes 1000, so it returns rows. Note we clamp the top at , not at — the naive "every page has 30 rows" assumption would over-count here.
- (d) Because the table is a fast-growing live log,
/v2should ship cursor (keyset) pagination: it stays stable under the constant inserts (no duplicate/skipped events, unlike offset — recall Exercise 3.1) and is fast at any depth (an indexedWHERE id < :cursorinstead of scanning 990+ skipped rows). Offset would have been the wrong call here despite looking simpler.
Recall Quick self-check (cloze)
GET is both safe and idempotent.
A create-on-collection uses the method POST and returns 201 Created with a Location header.
Authenticated-but-forbidden is status 403; unauthenticated is 401.
offset(p) = ==== and total pages = ==.
Pagination that is stable under inserts and fast at any depth is cursor (keyset)== pagination.