1. Resource Modeling & HTTP Verb Semantics
REST models your API as a set of resources — nouns, not verbs. A
"create a post" action isn't POST /createPost; it's
POST /posts, because the URL identifies what you're operating on
(the collection of posts) and the HTTP method identifies what kind of operation
you're performing on it. Once you commit to that split, the rest of the API's shape
mostly falls out of it automatically.
GET /posts -> list posts (optionally paginated/filtered)
POST /posts -> create a new post
GET /posts/:id -> fetch one post
PATCH /posts/:id -> partially update one post
PUT /posts/:id -> replace one post entirely
DELETE /posts/:id -> delete one post
GET /posts/:id/comments -> list comments on that post (nested resource)
POST /posts/:id/comments -> create a comment on that post
The distinction between PATCH and PUT trips people up: PUT
means "here is the complete resource, replace whatever exists at this URL with exactly
this" (fields you omit are meant to be cleared), while PATCH means "apply
this partial set of changes" (fields you omit are left untouched). Most real-world APIs
lean almost entirely on PATCH for updates because clients rarely want to
resend a whole resource just to change one field — but if you do implement
PUT, implement it with true replace semantics, not as an alias for
PATCH, or you'll violate what clients reasonably assume it does.
Two more properties matter when you're deciding how a verb behaves under retries.
Idempotent means calling it once has the same effect as calling it
many times: GET, PUT, and DELETE are all
idempotent by convention (deleting an already-deleted resource should still report
success or a 404, not a different side effect the second time). POST is
explicitly not idempotent — calling it twice is expected to create two
resources — which is exactly why retrying a failed POST automatically is
dangerous without an idempotency key, a topic that matters again in Week 12 when you
build retryable background jobs.
/posts/:postId/comments/:commentId/likes is technically valid but painful in practice — every route needs three params, and it's unclear whether a like belongs to the comment or the post. Prefer flatter URLs like /comments/:commentId/likes once you're more than one level deep, and let the resource's own id (not its position in the URL) carry the rest of the relationship, the same way you'll model foreign keys in Prisma starting Week 4.
2. Status Codes That Mean Something
A status code is part of your API's contract — clients branch on it programmatically,
so picking the closest-matching code (not just 200 for success and
500 for everything else) is what makes an API pleasant to build against.
200 OK -> successful GET, PATCH, or PUT
201 Created -> successful POST that created a resource
(include a Location header or the created resource in the body)
204 No Content -> successful DELETE, or any request with no body to return
400 Bad Request -> the request itself is malformed (bad JSON, wrong field types)
401 Unauthorized -> no valid credentials were provided at all
403 Forbidden -> valid credentials were provided, but they're not allowed to do this
404 Not Found -> the resource (or the route) doesn't exist
409 Conflict -> the request conflicts with current state (e.g. duplicate unique field)
422 Unprocessable Entity -> well-formed request, but it fails business/domain validation
500 Internal Server Error -> an unexpected bug -- never anything the client caused
401 versus 403 is the pairing that gets confused most often:
401 means "I don't know who you are" (missing or invalid token — the fix
is to log in), while 403 means "I know exactly who you are, and you're not
allowed to do this" (the fix is a different account or permission, not re-authenticating).
You'll implement that exact split in Weeks 6 and 7, where authentication middleware
returns 401 and a separate authorization/role check returns 403.
postsRouter.post("/", (req, res) => {
const created = { id: "abc123", title: req.body.title };
res.status(201).location(`/posts/${created.id}`).json(created);
});
postsRouter.delete("/:id", (req, res) => {
// ... delete logic ...
res.status(204).send(); // no body on a 204
});
If a resource exists but the requester isn't allowed to know it exists at all (not even that it exists), some APIs deliberately return 404 instead of 403 to avoid leaking that information — this is a genuine security trade-off, not a mistake, and worth a conscious decision per-resource rather than a blanket rule.
3. Schema Validation with Zod
Zod lets you define a request's expected shape once, as a schema, and get both runtime
validation and a static TypeScript type derived from that same schema — so the type you
use in your handler and the validation that ran against the actual request body can
never drift apart the way a hand-written interface checked by ad-hoc
if statements can.
import { z } from "zod";
export const createPostSchema = z.object({
title: z.string().min(1, "title is required").max(200),
body: z.string().min(1, "body is required"),
tags: z.array(z.string()).max(5).optional(),
publishedAt: z.coerce.date().optional(),
});
// The TypeScript type is derived directly from the schema --
// they can never fall out of sync with each other.
export type CreatePostInput = z.infer<typeof createPostSchema>;
export const updatePostSchema = createPostSchema.partial();
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
createPostSchema.partial() makes every field optional in one call, which
is exactly the shape a PATCH endpoint needs — you get the update schema for
free instead of maintaining a second, nearly-identical schema by hand. Calling
schema.parse(data) either returns a fully-typed, validated value or throws
a ZodError containing every field that failed and why, which you turn into
an HTTP response with a small middleware:
import type { RequestHandler } from "express";
import type { ZodType } from "zod";
export function validateBody(schema: ZodType): RequestHandler {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
});
}
req.body = result.data; // replace with the parsed, validated (and coerced) data
next();
};
}
import { Router } from "express";
import { createPostSchema, type CreatePostInput } from "../schemas/post.js";
import { validateBody } from "../middleware/validate.js";
const postsRouter = Router();
postsRouter.post("/", validateBody(createPostSchema), (req, res) => {
const input = req.body as CreatePostInput; // now safely typed
res.status(201).json({ id: "abc123", ...input });
});
export default postsRouter;
safeParse (rather than parse) is the right choice inside
middleware because it returns a result object instead of throwing — you stay in control
of the response shape on failure instead of relying on your generic error handler to
catch a ZodError it doesn't know the specifics of.
The same validateBody pattern generalizes to a validateQuery version that parses req.query — genuinely useful given Week 2's observation that query values always arrive as strings. A schema like z.object({ page: z.coerce.number().int().min(1).default(1) }) handles both the string-to-number coercion and the default value in one declarative place, replacing the manual Number(req.query.page ?? 1) checks from last week.
4. A Consistent Error-Response Shape
Every error your API returns — a validation failure, a not-found resource, an unexpected bug — should have the same top-level shape, so a client can write one piece of error-handling code instead of special-casing each endpoint. Pick a shape early and apply it everywhere:
{
"error": {
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"details": [
{ "path": "title", "message": "title is required" }
]
}
}
Extending Week 2's AppError hierarchy to carry a machine-readable
code alongside the human-readable message is what makes this
consistent everywhere without duplicating logic in every route:
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: unknown,
) {
super(message);
this.name = "AppError";
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(404, "NOT_FOUND", `${resource} not found`);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(409, "CONFLICT", message);
}
}
import type { ErrorRequestHandler } from "express";
import { ZodError } from "zod";
import { AppError } from "../lib/errors.js";
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: { message: err.message, code: err.code, details: err.details },
});
}
if (err instanceof ZodError) {
return res.status(400).json({
error: {
message: "Validation failed",
code: "VALIDATION_ERROR",
details: err.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
},
});
}
console.error(err);
res.status(500).json({
error: { message: "Internal server error", code: "INTERNAL_ERROR" },
});
};
Notice this single handler now covers both errors you threw deliberately
(AppError subclasses) and a library's own error type
(ZodError, in case a raw .parse() call ever throws instead of
going through your validateBody middleware) — every path still lands on the
same { error: { message, code, details? } } shape by the time it reaches
the client.
The 500 branch deliberately returns a generic "Internal server error" message regardless of what the real error says — stack traces and internal error text can leak implementation details (library versions, file paths, even query fragments) that are useful to an attacker. Log the real error server-side with console.error (or the structured logger you'll build in Week 13) and keep the client-facing message generic.
5. Hands-on Exercise
Redesign last week's notes API with proper REST semantics and Zod validation
Take the in-memory notes API from Week 2's exercise and rebuild its contract to be genuinely REST-correct, fully validated, and consistent on error.
Requirements:
- Write
createNoteSchema(title: 1–120 chars,body: 1–5000 chars,tags: optional array of up to 5 strings) and deriveupdateNoteSchemafrom it with.partial(). - Add a reusable
validateBodymiddleware and apply it toPOST /api/notesandPATCH /api/notes/:id(addPATCHif you only hadPOST/DELETEbefore). - Fix status codes:
201+Locationheader on create,204with no body on delete,404for a missing note,409if you add a uniqueness rule (e.g. no two notes with the same title) and violate it. - Extend
AppErrorto carry acodestring, add aConflictError, and update the centralized error handler to return the{ error: { message, code, details? } }shape for every error path, including Zod failures. - Write down (as a comment or a short markdown table) the full route list for
/api/noteswith verb, path, and status code on success — treat it as the contract you're implementing against. - Confirm with a REST client that a validation failure, a 404, and a 409 all return the exact same top-level JSON shape, differing only in
statusCode,code, anddetails.
Write the route contract table (step 5) before touching any code — deciding "what status code does a missing note on PATCH return" up front is much easier than retrofitting consistency after five routes already disagree with each other.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is it dangerous for a client to automatically retry a failed POST request the same way it safely retries a failed GET?
Why is it dangerous for a client to automatically retry a failed POST request the same way it safely retries a failed GET?
GET is idempotent — repeating it has no additional side effect, so a retry after a timeout is always safe. POST is not idempotent by convention: if the first request actually succeeded server-side but the response was lost (a timeout, not a real failure), retrying it blindly creates a second resource instead of confirming the first one, which is why idempotency keys exist for safely retryable creates.
Q2
A request is missing its auth token entirely. Why is that a 401 and not a 403?
A request is missing its auth token entirely. Why is that a 401 and not a 403?
401 Unauthorized means the server can't establish who's making the request at all — no valid credentials were presented, and the fix is to authenticate. 403 Forbidden is reserved for a request where the identity is known but that identity lacks permission for the action; conflating the two tells the client the wrong thing to fix (re-login vs. request different access).
Q3
Why use schema.safeParse() instead of schema.parse() inside the validateBody middleware?
Why use schema.safeParse() instead of schema.parse() inside the validateBody middleware?
schema.parse() throws a ZodError on failure, forcing you to rely on whatever generic handling your centralized error middleware happens to give thrown errors. safeParse() returns a { success, data } or { success, error } object instead, so the middleware can build exactly the validation-error response shape it wants (with per-field details) without needing a try/catch or a special ZodError branch at every call site.
Q4
Why does the error handler return a generic "Internal server error" message on a 500 instead of the real error's message?
Why does the error handler return a generic "Internal server error" message on a 500 instead of the real error's message?
An unexpected error's message or stack trace can reveal internal details — file paths, library versions, database error text — that are useful to an attacker probing the API, and that a legitimate client has no use for since there's nothing they can fix on their end. Logging the full error server-side (via console.error, or the structured logger from Week 13) while returning a generic message to the client keeps debugging information available to you without exposing it externally.