SE

Build a REST API

Build something4 hIntermediate

Create CRUD endpoints with validation and error handling.

The theory of REST clicks when you write your second API, not your first. The point of this exercise isn't a perfect service — it's the complete loop: schema, validation, error model, pagination, docs, and a curl-based smoke test you can run from memory.

The big idea

Pick a stack you can stomach, ship a CRUD service end-to-end, then critique it. The underlying tech almost doesn't matter — FastAPI, Express + Zod, NestJS, Go chi, Spring Boot — the muscle you're building is wiring all the cross-cutting concerns (validation, errors, auth, docs, pagination) the same way every time.

What "done" looks like

  1. Choose the domain

    Three or four resources, related by foreign keys. Books / authors is the canonical sandbox. Avoid auth-heavy domains until later.

  2. Model the resources

    Write the OpenAPI spec or the schema first (Zod, Pydantic, .proto). The schema is the contract; everything else is generated from it.

  3. Stand up the routes

    GET /books, GET /books/:id, POST /books, PATCH /books/:id, DELETE /books/:id. Match the REST conventions; resist clever URLs.

  4. Pick one error shape

    Use a single response model for errors. Return the right status family. Never 200 with {"error": "..."} in the body.

  5. Add pagination + filtering

    ?page + ?per_page (or ?cursor=). Decide what's filterable and document it.

  6. Validate every input

    Reject malformed payloads at the boundary with a clear 400. Don't trust client types.

  7. Generate docs

    OpenAPI / Swagger UI / Redoc — one command, no hand-written docs.

  8. Smoke test with curl

    A README with 6-8 commands that exercise the happy path and one failure case for each endpoint. If a teammate can copy them and the API works, you're done.

A complete request, end to end

curl client
Routingmethod + path
Middlewareauth, log, trace
Validationschema parse
Handlerbusiness logic
Responsestatus + body

Reference implementation

import express from 'express';
import { z } from 'zod';
 
const app = express();
app.use(express.json());
 
const BookIn = z.object({
  title: z.string().min(1).max(200),
  author_id: z.number().int(),
});
type BookIn = z.infer<typeof BookIn>;
type Book = BookIn & { id: number };
 
// in-memory store; replace with a real DB
const DB = new Map<number, Book>();
let NEXT_ID = 1;
 
app.post('/books', (req, res) => {
  const parsed = BookIn.safeParse(req.body);
  if (!parsed.success) return res.status(422).json({ error: parsed.error });
  const book: Book = { id: NEXT_ID++, ...parsed.data };
  DB.set(book.id, book);
  res.status(201).json(book);
});
 
app.get('/books', (req, res) => {
  const page = Math.max(1, Number(req.query.page ?? 1));
  const perPage = Math.min(100, Math.max(1, Number(req.query.per_page ?? 20)));
  const items = Array.from(DB.values());
  const start = (page - 1) * perPage;
  res.json(items.slice(start, start + perPage));
});
 
app.get('/books/:id', (req, res) => {
  const book = DB.get(Number(req.params.id));
  if (!book) return res.status(404).json({ detail: 'Book not found' });
  res.json(book);
});

Smoke-test script

# happy path
curl -s -X POST http://localhost:8000/books \
  -H 'content-type: application/json' \
  -d '{"title":"A Wizard of Earthsea","author_id":1}' | jq
 
curl -s http://localhost:8000/books | jq
curl -s http://localhost:8000/books/1 | jq
 
# error cases
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/books/9999     # 404
curl -s -X POST http://localhost:8000/books \
  -H 'content-type: application/json' -d '{}'                                  # 422

In practice

Once the books API works, repeat with a different stack. The friction differences (how each framework handles validation, where errors are caught, how middleware composes) teach more than reading a book ever will.

Key takeaways

  • Start from the schema, not the route handlers.
  • One error shape, one pagination convention, one auth pattern — everywhere.
  • Generate OpenAPI/Swagger; never hand-write API docs.
  • A 6-line `curl` script that exercises the happy + sad paths is your acceptance test.
  • Build it twice in two different stacks — that's where the patterns sink in.

Checkpoint questions

Use these to test whether the lesson is clear enough to explain without rereading.

  1. 1What endpoints, validation rules, and error cases prove the API is complete enough?
  2. 2How would you test the happy path and at least two failure paths with curl?
  3. 3Where should pagination, filtering, and consistent error responses be handled?
  4. 4What parts of the service would you replace before moving from in-memory data to production?

References

External resources for going deeper after the lesson above.