Week 3: REST API Design & Validation

Week 2 gave you the mechanics of Express — middleware, routers, params, centralized errors. This week is about what those mechanics should actually express: how to model resources and pick HTTP verbs and status codes that mean something to a client, how to validate every request body with Zod instead of hand-rolled if checks, and how to shape error responses consistently across the whole API. The AppError hierarchy from Week 2 gets extended here to carry validation detail, and the consistent request/response shape you build this week is exactly what the Prisma-backed routes in Weeks 4 and 5 plug real persistence into.

Module 3 of 22 Week 3 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Model a resource with correct HTTP verb and status-code semantics
  • Validate request bodies with Zod and surface useful, structured validation errors
  • Design one consistent error-response shape and apply it across an entire API

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.

standard resource routes for /posts
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.

Nesting resources deeper than two levels usually backfires

/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.

the codes you'll use in nearly every route
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.

src/routes/posts.ts
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
});
404 for "not found" vs. "not shown"

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.

src/schemas/post.ts
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:

src/middleware/validate.ts
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();
  };
}
src/routes/posts.ts
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.

Validate query strings and params too, not just bodies

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:

a consistent shape for every error response
{
  "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:

src/lib/errors.ts
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);
  }
}
src/middleware/error-handler.ts
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.

Never let a raw error message reach the client on a 500

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

Hands-on

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:

  1. Write createNoteSchema (title: 1–120 chars, body: 1–5000 chars, tags: optional array of up to 5 strings) and derive updateNoteSchema from it with .partial().
  2. Add a reusable validateBody middleware and apply it to POST /api/notes and PATCH /api/notes/:id (add PATCH if you only had POST/DELETE before).
  3. Fix status codes: 201 + Location header on create, 204 with no body on delete, 404 for a missing note, 409 if you add a uniqueness rule (e.g. no two notes with the same title) and violate it.
  4. Extend AppError to carry a code string, add a ConflictError, and update the centralized error handler to return the { error: { message, code, details? } } shape for every error path, including Zod failures.
  5. Write down (as a comment or a short markdown table) the full route list for /api/notes with verb, path, and status code on success — treat it as the contract you're implementing against.
  6. 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, and details.
Hint

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?

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?

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?

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?

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.