0 / 91
Week 6 · Day 41 of 91

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:

  1. The server assigns it. Never accept an id from a request body. Yesterday's POST generates eq-004; a PATCH that tries to change id gets a 400.
  2. It never changes. A caller may have stored /equipment/eq-002 in a bookmark, a log line, or another system's database. Renaming ids breaks all of it silently.
  3. It is never a position. Array index 2 becomes a different record the moment something earlier is deleted. Your ids are the strings eq-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=10 gives "10".
  • They come from outside, so they are untrusted input and get validated exactly like a body. An unrecognised status value is a 400, 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.

  1. Add parsePositiveInt and the rewritten GET /equipment. Run npm run typecheck.
  2. Verify ?status=down, ?status=nope (400), and no filter at all.
  3. Verify ?page=2&limit=2, ?page=0 (400), and ?limit=500 (400).
  4. Add PATCH /equipment/:id. Verify a status change, an unknown id (404), an invalid status (400), and an attempt to change id (400).
  5. Add DELETE /equipment/:id. Verify 204 with an empty body, then 404 on a second attempt.
  6. Change yesterday's duplicate-serial response from 400 to 409 and verify it.
  7. 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.
  8. Add a "Breaking changes" line recording that GET /equipment now 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-002 duplicates what DELETE already says, and every new action invents a new URL shape. Let the method carry the verb.
  • Computing total after slicing. You get the page size, not the collection size, and totalPages becomes nonsense. Filter, count, then slice.
  • Ignoring unknown query parameters. A caller who types ?statuss=down gets the unfiltered list and believes the filter worked. Validate what you accept.
  • Sending a body with 204. 204 means no content; res.json() after res.status(204) is a contradiction. Use res.status(204).end().
  • Reusing ids of deleted records. eq-003 deleted 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.

  1. Read MDN on PUT and PATCH. Does it agree that PATCH is partial and PUT is a full replacement? Quote the distinguishing sentence.
  2. Find 409 Conflict. Does MDN's description match the duplicate-serial use above?
  3. This lesson said DELETE and PUT are idempotent but POST is not. Find MDN's own statement on idempotency and check whether PATCH is 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

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Add PATCH, DELETE, status filtering, and page/limit query handling.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

A documented CRUD API with consistent response shapes.

Working with AI today

AI as skeptical reviewer

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

Q1 What are query parameters commonly used for?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.