Without notes, state yesterday’s main idea and one unresolved question.
API design, IDs, filtering, and pagination
HTTP, Node.js, and APIs
Objective
Create predictable interfaces rather than ad hoc endpoints.
HTTP is a request/response protocol like a defined communication bus: method, address, headers, body, and status all have roles.
- resource naming
- query parameters
- stable identifiers
- pagination metadata
Why this matters
You now have endpoints. You do not yet have an API. The difference is predictability: given one endpoint of a well-designed API, a stranger can guess the rest and be right. Given one endpoint of an ad hoc one, they have to read your source code.
Today you add updating, deleting, filtering, and paging — and you do it by following rules rather than inventing each endpoint separately. By the end you will have a full CRUD API (Create, Read, Update, Delete) whose responses all have the same shape.
Resource naming
The core rule: a path names a thing; the method says what to do to it.
Paths are nouns, plural, lowercase, hyphenated if needed. Verbs belong in the method, never the
path. /equipment is a collection; /equipment/eq-002 is one member of it.
| Do this | Not this | Why |
|---|---|---|
GET /equipment |
GET /getAllEquipment |
The method already says "get" |
POST /equipment |
POST /createEquipment |
POST to a collection means "add to it" |
DELETE /equipment/eq-002 |
POST /deleteEquipment?id=eq-002 |
DELETE exists; use it |
GET /maintenance-records |
GET /maintenanceRecords |
URLs are conventionally lowercase |
That gives you the standard seven-endpoint shape for any resource:
| Method | Path | Meaning | Success |
|---|---|---|---|
GET |
/equipment |
List, filtered and paged | 200 |
POST |
/equipment |
Create one | 201 |
GET |
/equipment/:id |
Read one | 200 |
PATCH |
/equipment/:id |
Change some fields | 200 |
PUT |
/equipment/:id |
Replace entirely | 200 |
DELETE |
/equipment/:id |
Remove one | 204 |
PATCH versus PUT: PATCH sends only the fields you want changed and leaves the rest alone.
PUT sends the complete replacement — any field you omit is cleared. Most APIs want PATCH, and
that is what you will build. PUT is idempotent (Day 36) by definition, because it states the whole
final state. PATCH is not guaranteed to be — a patch meaning "add 1 to the hours counter" gives a
different result each time — though the PATCH you write today, which sets a field to a fixed
value, happens to be.
DELETE returns 204 No Content — success, and there is deliberately nothing to send back.
A 204 response must have an empty body, so you end it with res.status(204).end(), not
res.json().
One more status earns its place today: 409 Conflict, for a request that is well-formed but
clashes with the current state — a duplicate serial number, for example. 400 says "you wrote this
wrong"; 409 says "you wrote it fine, but it collides with something that already exists". Move
yesterday's duplicate-serial case from 400 to 409 and your errors become more precise.
A register map, not a pile of test points
A chip datasheet does not document a hundred unrelated pins; it publishes a register map where
address ranges are systematic and the same read/write commands apply throughout. You can operate
a register you have never used because the addressing scheme is consistent. Resource naming is
that map. /equipment/:id addresses one entry, the method is the read/write command, and once a
caller has driven one resource they can drive every other resource in your API without a manual.
An API of one-off endpoints is a chip with no register map: technically usable, practically not.
Stable identifiers
An id is a permanent name for one record. Three rules, all learned the hard way:
- The server assigns it. Never accept an id from a request body. Yesterday's
POSTgenerateseq-004; aPATCHthat tries to changeidgets a400. - It never changes. A caller may have stored
/equipment/eq-002in a bookmark, a log line, or another system's database. Renaming ids breaks all of it silently. - It is never a position. Array index
2becomes a different record the moment something earlier is deleted. Your ids are the stringseq-001,eq-002, and they stay attached to their record for its whole life.
Asset tags, not shelf positions
A workshop labels each machine with a permanent asset tag. "Third from the left" also identifies a machine today, and identifies a different machine tomorrow once one is moved out. Array indices are shelf positions; ids are asset tags.
Query parameters
Query parameters — everything after ? — are for filtering, sorting, and pagination: options
that change how a collection is returned, without changing which resource you asked for. That is
the dividing line. /equipment/eq-002 names one record and belongs in the path. ?status=down
narrows a list and belongs in the query.
Express parses them into req.query. Two things to remember:
- Values are strings or absent, never numbers.
?limit=10gives"10". - They come from outside, so they are untrusted input and get validated exactly like a body.
An unrecognised
statusvalue is a400, not a silently empty list — silence hides typos.
Pagination metadata
A collection that grows without limit cannot be returned in one response. Pagination returns a
slice: ?page=2&limit=20 means "the second block of twenty".
The slice alone is not enough. A caller receiving twenty records cannot tell whether there are twenty-one or twenty thousand, so the response carries metadata alongside the data:
{
"data": [ /* records */ ],
"meta": { "page": 1, "limit": 20, "total": 3, "totalPages": 1 }
}
Adopting this envelope changes GET /equipment from returning a bare array to returning an object.
That is a breaking change: any existing caller doing records.map(...) on the response now
gets an error. Real APIs version their endpoints or announce such changes. Yours has one caller —
you — so make the change today, deliberately, and note it in your README as a breaking change.
Walkthrough
Add to src/server.ts. First, a helper that turns an untrusted query value into a positive
integer or null:
function parsePositiveInt(raw: string | undefined, fallback: number): number | null {
if (raw === undefined) return fallback;
if (!/^\d+$/.test(raw)) return null;
const value = Number(raw);
return value >= 1 ? value : null;
}
Absent means "use the default"; present but nonsense means null, which the route reports as
400. Now replace GET /equipment:
app.get("/equipment", (req, res) => {
const status = req.query.status;
if (status !== undefined && (typeof status !== "string" || !STATUSES.includes(status as Status))) {
res.status(400).json({ error: `status must be one of: ${STATUSES.join(", ")}` });
return;
}
const page = parsePositiveInt(req.query.page as string | undefined, 1);
const limit = parsePositiveInt(req.query.limit as string | undefined, 20);
if (page === null || limit === null || limit > 100) {
res.status(400).json({ error: "page and limit must be whole numbers >= 1, and limit <= 100" });
return;
}
const matches = status ? equipment.filter((item) => item.status === status) : equipment;
const start = (page - 1) * limit;
res.json({
data: matches.slice(start, start + limit),
meta: { page, limit, total: matches.length, totalPages: Math.ceil(matches.length / limit) }
});
});
total counts records after filtering, before slicing — that is what makes totalPages
meaningful. The limit <= 100 cap stops one caller asking for a million records at once.
Then PATCH and DELETE:
app.patch("/equipment/:id", (req, res) => {
const existing = equipment.find((item) => item.id === req.params.id);
if (!existing) {
res.status(404).json({ error: `No equipment with id "${req.params.id}"` });
return;
}
const body = req.body as Record<string, unknown> | undefined;
if (typeof body !== "object" || body === null || Array.isArray(body)) {
res.status(400).json({ error: "body must be a JSON object" });
return;
}
if ("id" in body) {
res.status(400).json({ error: "id cannot be changed" });
return;
}
if (body.status === undefined || !STATUSES.includes(body.status as Status)) {
res.status(400).json({ error: `status must be one of: ${STATUSES.join(", ")}` });
return;
}
existing.status = body.status as Status;
res.json(existing);
});
app.delete("/equipment/:id", (req, res) => {
const index = equipment.findIndex((item) => item.id === req.params.id);
if (index === -1) {
res.status(404).json({ error: `No equipment with id "${req.params.id}"` });
return;
}
equipment.splice(index, 1);
res.status(204).end();
});
Both check existence first and answer 404 before looking at anything else. With npm run dev
running:
curl -s "localhost:3000/equipment?status=down"
curl -s "localhost:3000/equipment?page=2&limit=2"
curl -s "localhost:3000/equipment?page=0"
{"data":[{"id":"eq-002","name":"Cooling Fan A","serial":"CFA-2021-008","status":"down"}],"meta":{"page":1,"limit":20,"total":1,"totalPages":1}}
{"data":[{"id":"eq-003","name":"Bench PSU","serial":"PSU-2020-441","status":"maintenance"}],"meta":{"page":2,"limit":2,"total":3,"totalPages":2}}
{"error":"page and limit must be whole numbers >= 1, and limit <= 100"}
curl -s -X PATCH localhost:3000/equipment/eq-002 -H "Content-Type: application/json" -d '{"status":"maintenance"}'
curl -i -s -X DELETE localhost:3000/equipment/eq-003 | head -1
curl -s -X DELETE localhost:3000/equipment/eq-003
{"id":"eq-002","name":"Cooling Fan A","serial":"CFA-2021-008","status":"maintenance"}
HTTP/1.1 204 No Content
{"error":"No equipment with id \"eq-003\""}
The second DELETE returning 404 is correct and worth pausing on: the record is already gone, so
the request now names something that does not exist.
Watch the state evaporate
Delete a record, confirm it is gone, then stop the server with Ctrl+C and
run npm run dev again. The record is back. Your data lives in a JavaScript array, so every
restart resets it. Week 7 replaces that array with a database; today's design work is what makes
that swap possible without changing a single URL.
Checkpoint
Say which of id, status filter, page, and limit belongs in the path and which in the query, and
why — and give the success status for POST, PATCH, and DELETE.
Your turn
Deliverable: a documented CRUD API with consistent response shapes.
- Add
parsePositiveIntand the rewrittenGET /equipment. Runnpm run typecheck. - Verify
?status=down,?status=nope(400), and no filter at all. - Verify
?page=2&limit=2,?page=0(400), and?limit=500(400). - Add
PATCH /equipment/:id. Verify a status change, an unknown id (404), an invalid status (400), and an attempt to changeid(400). - Add
DELETE /equipment/:id. Verify204with an empty body, then404on a second attempt. - Change yesterday's duplicate-serial response from
400to409and verify it. - Rewrite the endpoint table in
README.md: every method, path, query parameter, request shape, response shape, and every status code each route can return. This table is the deliverable. - Add a "Breaking changes" line recording that
GET /equipmentnow returns{ data, meta }.
Reviewer mode — after the table is written
"Review this API contract for inconsistent status codes, unstable IDs, and breaking response shapes." Give it your README table, not your source. A useful review names a specific route, a specific inconsistency, and the evidence — reject general praise and reject a proposed rewrite. Verify each finding with curl before changing anything.
Common pitfalls
- Verbs in paths.
/equipment/delete/eq-002duplicates whatDELETEalready says, and every new action invents a new URL shape. Let the method carry the verb. - Computing
totalafter slicing. You get the page size, not the collection size, andtotalPagesbecomes nonsense. Filter, count, then slice. - Ignoring unknown query parameters. A caller who types
?statuss=downgets the unfiltered list and believes the filter worked. Validate what you accept. - Sending a body with
204.204means no content;res.json()afterres.status(204)is a contradiction. Useres.status(204).end(). - Reusing ids of deleted records.
eq-003deleted then handed to a new machine makes every old log line a lie. Keep the counter moving forward.
Verify it yourself
Open today's reference, MDN's Overview of HTTP, and find its pages on request methods and response status codes.
- Read MDN on
PUTandPATCH. Does it agree thatPATCHis partial andPUTis a full replacement? Quote the distinguishing sentence. - Find
409 Conflict. Does MDN's description match the duplicate-serial use above? - This lesson said
DELETEandPUTare idempotent butPOSTis not. Find MDN's own statement on idempotency and check whetherPATCHis included.
Add the answers under "Notes" in README.md. Tomorrow you separate this logic from the routes and
verify the whole API end to end.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Add PATCH, DELETE, status filtering, and page/limit query handling.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
A documented CRUD API with consistent response shapes.
Working with AI today
Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.
Review this API contract for inconsistent status codes, unstable IDs, and breaking response shapes.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.