Chapter 10 · Reps

APIs, JSON, and FastAPI — Reps

← Back to Chapter 10

Chapter 10 — Reps

Conditioning, not grading. FastAPI + Pydantic reps this week.

Ground rules:

  1. Type every line yourself. No copy-paste, and — this matters now — no asking the agent to write the reps. Phase 2 turns agentic AI on for the project, but the reps are still hand-built. You cannot direct an agent to build a FastAPI service well if your own hands have never typed @app.post and watched the 422 come back.
  2. Run every endpoint after you write it. Start uvicorn, hit the route, read the response. Then send it bad input on purpose and read the error. The error body is the contract speaking.
  3. Read the /docs page after every model change. Watch the generated documentation update itself. That feedback loop is half the lesson.
  4. Pydantic v2 only. .model_dump(), not .dict(). ConfigDict, not class Config:. If a tutorial uses the old style, it is v1 — translate it.

Install once and you are set for the week:

pip install fastapi uvicorn

Run any app in this file with uvicorn FILENAME:app --reload from the folder that holds it. Toolchain and virtual-environment setup live in Appendix A.


Reps 1–3: First Contact

Rep 1 — Hello, FastAPI

Create hello.py:

from fastapi import FastAPI

app = FastAPI()

@app.get("/litman-books/")
def root():
    return {"message": "Hello, world."}

Run uvicorn hello:app --reload. Visit http://127.0.0.1:8000/ and confirm the JSON. Then — and do not skip this — visit http://127.0.0.1:8000/docs. You wrote three lines of logic; FastAPI generated an interactive documentation page. Click around it.

Now break it: change def root to def root(x) (a required parameter with no default and no type). Re-run, hit /, and read the 422. FastAPI now demands an x. Understand why, then undo it.


Rep 2 — Path and Query Parameters

Add two routes to a fresh params.py:

@app.get("/litman-books/square/{n}")
def square(n: int):
    return {"n": n, "square": n * n}

@app.get("/litman-books/greet")
def greet(name: str, loud: bool = False):
    msg = f"Hello, {name}."
    return {"greeting": msg.upper() if loud else msg}

Hit /square/9 (expect 81). Then hit /square/banana and read the 422 — the type hint int did the validation for you, with zero validation code. Hit /greet?name=Maya and /greet?name=Maya&loud=true. Notice loud is optional because it has a default; name is required because it does not. FastAPI learned all of that from the signature.


Rep 3 — Your First Pydantic Model

Create model_rep.py with a model and a route that uses it as a body:

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

class Note(BaseModel):
    title: str = Field(..., min_length=1, max_length=60)
    body: str = Field(..., min_length=1)

@app.post("/litman-books/notes")
def make_note(note: Note):
    return {"title": note.title, "length": len(note.body)}

POST a valid note (use the /docs “Try it out” button — it is the easiest way). Then POST each of these and read every error:

  • {"title": "", "body": "hi"} — empty title, below min_length.
  • {"title": "ok"} — missing body entirely.
  • {"title": "ok", "body": 5}body is a number, not a string.

Three different violations, three precise 422 messages, all from a contract you wrote in five lines and zero hand-written if checks. Write down, in one sentence, what each loc field in the error body told you.


Reps 4–6: The Resource Takes Shape

Rep 4 — Split Create and Response Models

Model a Verse two ways — the shape the client SENDS and the shape the server RETURNS:

class VerseCreate(BaseModel):     # no id — the server assigns it
    reference: str = Field(..., min_length=1, max_length=80)
    text: str = Field(..., min_length=1, max_length=1000)
    translation: str = "ESV"

class Verse(VerseCreate):          # inherits the above, adds id
    id: int = Field(..., ge=1)

Write a POST /verses that accepts a VerseCreate, assigns an id (a module-level counter), and returns a Verse. Set response_model=Verse and status_code=201 on the decorator. POST a verse and confirm you get back a 201 with an id you did not send. Then try POSTing {"id": 99, "reference": "John 1:1", "text": "..."} and confirm the id you sent is ignored — the create model has no id field, so it is silently dropped. The contract forbade the bug.


Rep 5 — In-Memory CRUD: GET and POST

Build a real (tiny) store. A module-level dict of id → fields, and a counter:

_verses: dict[int, dict] = {}
_next_id = 1

Implement:

  • GET /verses → list of all verses (each as {"id": id, **fields}), with response_model=list[Verse].
  • GET /verses/{verse_id} → one verse, response_model=Verse.
  • POST /verses → create (from Rep 4).

POST two verses, then GET /verses and confirm both appear with their assigned ids. GET /verses/1 and confirm the single verse. Do not handle the missing-id case yet — that is Rep 8. (For now, GET /verses/999 will throw a 500. Good. You will fix it deliberately, so you feel the difference.)


Rep 6 — Typed Query Parameters: Filter and Paginate

Extend GET /verses with three query parameters, all validated by type:

from fastapi import Query

@app.get("/litman-books/verses", response_model=list[Verse])
def list_verses(
    translation: str | None = Query(default=None),
    limit: int = Query(default=50, ge=1, le=100),
    offset: int = Query(default=0, ge=0),
):
    ...

Implement the filtering (by translation if given) and the slicing (rows[offset:offset+limit]). Test:

  • /verses?translation=ESV filters.
  • /verses?limit=1 returns one.
  • /verses?limit=0422 (below ge=1). Read it.
  • /verses?limit=banana422 (not an int). Read it.

Then open /docs and confirm every constraint (ge, le, the default) is documented automatically in the parameter descriptions. You wrote constraints; FastAPI wrote the documentation.


Reps 7–9: Errors, Done Right

Rep 7 — 201, 204, and the Right Status Codes

Add PUT /verses/{verse_id} (full replace, returns 200 + the verse) and DELETE /verses/{verse_id} (returns 204 with no body — set status_code=204 and return nothing). Confirm with the /docs “Try it out” tool that:

  • POST returns 201.
  • GET and PUT return 200.
  • DELETE returns 204 and an empty body.

Match these to the table in §10.1. Wrong status codes are a broken contract even when the data is right.


Rep 8 — Raise a Clean 404

Go back to your GET /verses/{verse_id} (and PUT, and DELETE) and handle the missing resource:

from fastapi import HTTPException

fields = _verses.get(verse_id)
if fields is None:
    raise HTTPException(status_code=404, detail=f"No verse with id {verse_id}.")

GET /verses/999 should now return a clean 404 with a useful detail, not the 500 from Rep 5. Do the same for PUT and DELETE. Confirm deleting the same verse twice gives 204 then 404 — the state is idempotent (gone is gone) even though the second response differs.


Rep 9 — Validation Error vs Business Error

Add one business rule: a verse reference must be unique. Before creating, check:

if any(v["reference"] == body.reference for v in _verses.values()):
    raise HTTPException(status_code=409, detail="That reference already exists.")

Now provoke both kinds of error and note the difference:

  • POST {"reference": "", "text": "x"}422 (shape violation, caught by Pydantic, automatic).
  • POST a duplicate reference → 409 (business-rule violation, raised by you).

Write one sentence distinguishing the two: who caught it, and why the codes differ. This is the §10.5 division of labor in your own hands.


Reps 10–11: Async and the Whole Thing Cold

Rep 10 — Feel the Event Loop

Add two routes, one sync and one async, both “slow”:

import asyncio, time

@app.get("/litman-books/slow-sync")
def slow_sync():
    time.sleep(3)            # blocks
    return {"kind": "sync"}

@app.get("/litman-books/slow-async")
async def slow_async():
    await asyncio.sleep(3)   # yields the loop
    return {"kind": "async"}

Open two browser tabs. Hit /slow-async in both at nearly the same time; they finish together (~3s total), because await let the loop serve the second while the first waited. This is the Week 8/9 event loop, in Python.

(Note: because FastAPI runs plain def routes in a threadpool, /slow-sync may also appear to serve two requests concurrently — that is the threadpool, not the loop. The real footgun is calling a blocking function inside an async def, which would freeze everything. Try adding time.sleep(3) inside an async def and watch concurrency die. Then remove it.)

Write one sentence: when is async def worth it, and when is plain def the right tool?


Rep 11 — The Whole Resource, From a Blank File

Close everything. New file, blank. From memory, build a complete CRUD API for a resource of your choice (prayer requests, study notes, your call):

  1. Two Pydantic models (create and response), with Field constraints.
  2. An in-memory store (dict + counter).
  3. GET (list, with at least a limit query param), GET /{id}, POST (201), PUT, DELETE (204).
  4. A clean 404 everywhere a resource might be missing.
  5. A working /docs page.

No peeking at the earlier reps until you are stuck. This is the project in miniature — if you can do this cold, the Normal tier of Project 10 is conditioning, not a fight.


Done? One Last Thing.

Without looking anything up, answer these in writing, two or three sentences each:

  1. A client POSTs JSON that parses fine but has reference as a number. What status code comes back, who produced it, and why is it not a 400?
  2. Why does putting id in your create model introduce a bug, and how does splitting create/response models fix it by construction?
  3. Name one constraint under which you would choose Node over FastAPI for a JSON API, and one under which you would choose FastAPI over Node. One sentence each, with the reason.

If you can answer all three cold — the validation contract, the model split, and the right-tool decision — you have the chapter. Question 3 is the one the project’s Hard tier makes you write three times, for real scenarios.


Up next: Project 10 — Project 10: The Same API, in FastAPI.