REST API Design
Express.js Fundamentals
Chapter 7 of 8 · REST API Design
REST API Design
REST is not a standard you pass or fail — it's a set of conventions that, when followed consistently, make an API predictable enough that a developer can guess how to call an endpoint they've never seen before. The goal is that GET /users returns a list, GET /users/5 returns user 5, POST /users creates one, and DELETE /users/5 removes it — all without reading documentation. That predictability is the entire point.
Resources, Not Actions
The core REST idea: URLs identify things (resources), and HTTP methods express what you want to do with them. The method IS the verb. Put a verb in the URL and you've written RPC dressed up as HTTP.
❌ RPC-style (verbs in URLs)
✅ REST-style (nouns + HTTP methods)
URL Conventions
Plural nouns for collections
/users not /user
/products not /product
Consistent regardless of whether you expect one or many results.
Hierarchical sub-resources
/users/:id/orders — orders belonging to a user
/posts/:id/comments — comments on a post
Keep nesting shallow (max 2 levels). Deep nesting gets unwieldy.
Lowercase, hyphens
/user-profiles not /userProfiles or /user_profiles
URLs are case-sensitive on most servers. Lowercase avoids ambiguity.
No trailing slashes
/users not /users/
Either enforce no-slash (redirect or 301) or accept both — just pick one and be consistent.
HTTP Methods → CRUD
| Method | URL | Action | Idempotent? | Body? |
|---|---|---|---|---|
| GET | /users |
List all users | Yes | No |
| GET | /users/:id |
Get one user | Yes | No |
| POST | /users |
Create a user | No | Yes |
| PUT | /users/:id |
Replace a user entirely | Yes | Yes |
| PATCH | /users/:id |
Update specific fields | Usually | Yes |
| DELETE | /users/:id |
Remove a user | Yes | No |
💡 PUT vs PATCH
PUT replaces the entire resource — if you omit a field, it's gone. A client must send all fields. PATCH is a partial update — only send the fields you want to change. In practice, most APIs implement PATCH (partial update) and label it PUT, or just use PATCH for everything. The distinction matters when clients need to update one field without risking data loss on others.
Status Codes That Matter
| Code | Name | When to use |
|---|---|---|
| 200 | OK | Successful GET, PATCH, PUT. General success with a body. |
| 201 | Created | POST that created a resource. Include a Location header pointing at the new resource. |
| 204 | No Content | Successful DELETE or PUT with no response body. Do NOT include a body. |
| 400 | Bad Request | Malformed request — invalid JSON, wrong Content-Type, missing required query param. |
| 401 | Unauthorized | Not authenticated (misleading name). The client needs to log in / send a token. |
| 403 | Forbidden | Authenticated but not allowed. The server knows who you are; you just can't do this. |
| 404 | Not Found | Resource doesn't exist. Also used for intentional "not exposing this resource exists" cases. |
| 405 | Method Not Allowed | The URL exists but not for this HTTP method. Should include an Allow header. |
| 409 | Conflict | Resource state conflict — duplicate email, version mismatch, concurrent edit. |
| 422 | Unprocessable Entity | Parses fine but fails validation — email format wrong, value out of range. |
| 429 | Too Many Requests | Rate limited. Should include Retry-After header. |
| 500 | Internal Server Error | Unexpected server error. Never expose internal details in the response body. |
| 503 | Service Unavailable | Temporarily down — DB unavailable, maintenance mode. Include Retry-After if known. |
⚠ 401 vs 403 — the Common Confusion
401 means "you haven't told me who you are yet" — send credentials and try again. 403 means "I know who you are and the answer is no." A logged-in user trying to edit someone else's post gets 403, not 401. A request with no auth token at all gets 401.
JSON Response Shape
Pick a shape and be consistent across every endpoint. Two common approaches:
Bare resource (simpler)
Envelope (more flexible)
Error Response Shape
Errors must be consistent too. A client should never have to guess whether the error is in body.error, body.message, or body.errors[0].msg.
The Location Header on 201
When a POST creates a resource, tell the client where to find it. This is the REST-correct way — it avoids a follow-up GET to discover the new resource's URL.
API Versioning
Versioning lets you change the API without breaking existing clients. The three common approaches:
| Strategy | Example | Verdict |
|---|---|---|
| URL versioning | /api/v1/users |
Most common. Visible, cacheable, easy to test in a browser. Recommended default. |
| Accept header | Accept: application/vnd.myapp.v2+json |
Technically correct REST. Harder to test, invisible in browser. Used by GitHub API. |
| Query parameter | /users?version=1 |
Easy to add, but query params are for filtering/sorting, not protocol selection. Avoid. |
Filtering, Sorting, and Pagination
💡 Always Allowlist Sort Fields
Never do ORDER BY ${req.query.sort} directly — that's SQL injection. Use a Set or object map of permitted field names and fall back to a default if the client sends something unexpected. Same applies to order: only accept 'asc' or 'desc', nothing else.
A Complete REST Router
Coding Challenges
Challenge 1 — RESTful Products CRUD
Build a /api/v1/products router with a full REST surface on an in-memory array. GET / lists all products (bare array, no envelope needed yet). POST / creates a product (validate name and price — throw 422 with a details array listing each failing field); respond 201 with a Location header. GET /:id fetches one — 404 if missing. PATCH /:id accepts partial updates (name and/or price); merge into existing resource, 404 if missing. DELETE /:id removes it — 204 No Content on success, 404 if missing. Mount the router at /api/v1/products. Use express-async-errors. Write supertest tests: each status code, the 201 Location header, the 422 details array for multiple missing fields, and a 204 with no body.
Challenge 2 — Filtering, Sorting & Pagination
Extend the products router with query-param support on GET /api/v1/products. Filtering: ?category= (exact match), ?minPrice= and ?maxPrice= (numeric, validate they're positive numbers). Sorting: ?sort=name|price|id (allowlist — default id), ?order=asc|desc (default asc). Pagination: ?page= (min 1, default 1), ?limit= (min 1, max 50, default 10). Response shape: { data: [...], meta: { total, page, perPage, pages } }. Pre-load the in-memory store with at least 25 products across 3 categories and varying prices. Write supertest tests: filtering by category reduces the list; minPrice/maxPrice filter correctly; sort=price asc puts cheapest first; page=2&limit=5 returns the right slice and correct meta.pages; an invalid sort field is silently ignored (falls back to default); minPrice greater than maxPrice returns 400.
Challenge 3 — API Versioning & Consistent Error Shape
Build an app with two API versions. Mount identical route logic for /api/v1/articles and /api/v2/articles, but with a breaking change in v2: the response envelope wraps the resource in { data: ... } whereas v1 returns the bare resource/array. Implement shared router logic in a factory function createArticlesRouter({ wrap }) where wrap is a boolean — when true, wrap single resources in { data: ... } and collections in { data: [...], meta: { total } }; when false, return bare. Both versions support GET /, POST /, GET /:id, DELETE /:id. Add a centralized error handler that always returns { error, status } JSON regardless of version. Write supertest tests: v1 GET returns a bare array; v2 GET returns { data: [...], meta }; v1 POST returns bare resource with 201; v2 POST returns { data: ... } with 201; both versions return the same error shape for 404 and 422; the 204 DELETE has no body.