Chapter 10 · Week 10

APIs, JSON, and FastAPI

What is a promise made in precise words?

Chapter 10 — APIs, JSON, and FastAPI

“An interface is a promise. Every public API is a contract you will have to keep — long after you have forgotten why you made it.” — Joshua Bloch, Effective Java (paraphrased from the API-design talks)

“Let what you say be simply ‘Yes’ or ‘No’; anything more than this comes from evil.” — Matthew 5:37 (ESV)


Why This Matters

Last week you built a server with your bare hands. No framework. Just Node’s http module, a port, and a callback that you filled with if (req.method === "GET") branches and hand-written JSON.stringify. You parsed the request body yourself. You set the status codes yourself. You wrote the 404 yourself. By the end of Week 9 you knew, precisely, what a web framework would later hide from you — because you had done all of it by hand.

This week you do it again. Same API. Same resource. Same endpoints. Same status codes. Same JSON in, JSON out.

But in a different language, with a different framework, on a different concurrency model: Python and FastAPI.

Why repeat yourself? Because the entire thesis of this book is that the right tool for the job is a decision you make before you write code — and you cannot feel a decision you have only ever made one way. A student who has only ever built servers in Node thinks “server” and “Node” are the same word. A student who has built the same server twice, in two stacks, feels the seam between the problem and the tool. That seam is where architecture lives.

By the end of this chapter you will be able to look at a JSON API requirement and say, out loud, with reasons: “Node, because the team only knows JavaScript and the throughput matters more than the validation.” Or: “FastAPI, because the request shapes are gnarly and we are going to call a Python ML model anyway.” Both sentences are correct. Knowing which sentence is correct for this problem is the job.

There is a second, deeper reason this chapter exists. FastAPI is built on a Python library called Pydantic, and Pydantic models are specifications you can run. In Coding 2 you learned to write contracts — preconditions, postconditions, the Javadoc that says exactly what a method promises. You wrote them in prose and enforced them with assertions and tests. A Pydantic model is that same contract, made executable: it says exactly what shape a request must have, it rejects anything that does not conform, and it documents itself. It is precise speech, in code.

Which is this week’s Christian question. What is a promise made in precise words? Our Lord tells us, in Matthew 5, to let our yes be yes and our no be no — to say exactly what we mean, no more and no less, because anything beyond plain speech “comes from evil.” The church has taken this seriously enough to write confessions — the Augsburg Confession, the catechisms, the creeds — documents whose entire purpose is to say what is believed in words so precise that they cannot be quietly twisted later. A confession is a contract with God’s people: this, and not that, is what we hold. A well-designed API is the same shape of thing. It is a promise made in precise words, and the discipline of keeping it is the discipline of saying exactly what you mean.

Let us learn to say it exactly.


10.1 — REST, Done Right

You used the word “REST” loosely last week. Now we pin it down, because the rest of this chapter assumes it.

REST (Representational State Transfer) is not a technology. It is a set of conventions for using HTTP the way HTTP was designed to be used. You already implemented most of it by hand in Node; here are the rules made explicit.

1. Resources are nouns. The URL names a thing, not an action.

Good (noun)Bad (verb in the URL)
GET /versesGET /getAllVerses
GET /verses/7GET /fetchVerseById?id=7
POST /versesPOST /createVerse
DELETE /verses/7POST /deleteVerse?id=7

The URL identifies a resource — a verse, the collection of verses, one verse by id. What you want to do to that resource is not in the URL. It is in the HTTP method.

2. HTTP methods are the verbs.

MethodMeansOn /versesOn /verses/7
GETread, no side effectslist all versesread verse 7
POSTcreate a new resourcecreate a verse(rarely used)
PUTreplace a resource wholesale(rarely used)replace verse 7
PATCHpartially updateupdate some fields of 7
DELETEremove(rarely used)delete verse 7

GET must be safe — calling it changes nothing. GET, PUT, and DELETE should be idempotent — calling them twice has the same effect as calling them once. (Deleting verse 7 twice leaves verse 7 deleted both times; the second call is a 404, but the state is the same.) POST is neither safe nor idempotent — POST twice and you get two verses. These are not pedantic distinctions. A proxy or a retrying client relies on them.

3. The server is stateless.

Each request carries everything the server needs to handle it. The server keeps no memory of “where you were” between requests. Your verse data lives on the server, yes — but there is no per-client conversation state sitting in the server’s RAM waiting for your next call. This is what lets you run ten copies of the server behind a load balancer: any copy can answer any request, because no copy is holding your half-finished session hostage. (Statelessness is also exactly what made last week’s event-loop server able to juggle many connections — there was no per-connection state to corrupt.)

4. Status codes carry meaning.

You set these by hand last week. Here is the working subset, with the rule for each:

CodeNameUse it when
200OKa GET/PUT succeeded and there is a body to return
201Createda POST created a resource (return the new resource)
204No Contenta DELETE succeeded and there is nothing to return
400Bad Requestthe client sent something malformed you reject
404Not Foundthe resource does not exist
409Conflictthe request collides with current state (duplicate, etc.)
422Unprocessable Entitythe body is well-formed JSON but fails validation
500Internal Server Erroryour code threw and you did not handle it

Coach’s Note — The line between 400 and 422 trips people up. 400 is “I cannot even understand this request” — the JSON does not parse, a required header is missing. 422 is “I understood you perfectly, and what you asked for is invalid” — the JSON parsed fine but id was a string where an int was required. FastAPI draws this line for you automatically, and draws it correctly. That is a gift; in Node you drew it by hand, or forgot to.

5. JSON is the request and response contract.

The body going in and the body coming out are JSON, with a Content-Type: application/json header to say so. The shape of that JSON — which keys, which types, which are required — is the contract. In Node you enforced that shape with hand-written if checks. In FastAPI, a Pydantic model is that shape, and the framework enforces it. That is the whole point of the chapter.


10.2 — FastAPI From Zero

Python is new to your web work but not new to you — you spent all of Phase 1 in it. FastAPI is a third-party package. Install it and its server:

pip install fastapi uvicorn

That command pulls in three things worth naming, because they map onto ideas you already know:

  • FastAPI — the framework: routing, dependency injection, the request/response machinery.
  • Starlette — the ASGI toolkit FastAPI is built on (the low-level HTTP plumbing). You will rarely touch it directly.
  • Pydantic — the validation library. This is the part that does the contract enforcement. We give it its own section.

And separately, uvicorn — the server that actually listens on the port. FastAPI is the application; uvicorn is the thing that runs it. (Compare Week 9: in Node, http.createServer was both. In Python the application and the server are two pieces.)

Here is the smallest possible FastAPI app. Put it in main.py:

from fastapi import FastAPI

app = FastAPI()

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

Run it:

uvicorn main:app --reload

main:app means “in the module main, find the object named app.” --reload restarts the server when you edit the file — convenient in development, never in production. Visit http://127.0.0.1:8000/ and you get {"message": "Hello, world."}. Notice what you did not write: no JSON.stringify, no res.writeHead(200, {"Content-Type": "application/json"}), no manual route matching. You returned a Python dict; FastAPI serialized it to JSON and set the headers. The framework hid exactly the work you did by hand last week.

Coach’s Note — @app.get("/litman-books/") is a decorator — a function that wraps another function. You met these conceptually in Coding 2. Read it as: “register root as the handler for GET /.” The decorator is the route table. In Node you wrote that table as an if/else chain on req.method and req.url; FastAPI lets you express it one route at a time, declaratively.

Path and query parameters, typed

FastAPI reads parameters straight out of your function signature, and it uses the type hints. This is where Python’s type hints stop being documentation and start doing work.

# Path parameter: part of the URL path. /verses/7  ->  verse_id = 7
@app.get("/litman-books/verses/{verse_id}")
def get_verse(verse_id: int):
    return {"id": verse_id}

# Query parameter: after the ? in the URL. /search?term=grace&limit=5
@app.get("/litman-books/search")
def search(term: str, limit: int = 10):
    return {"term": term, "limit": limit}

Because verse_id is annotated int, FastAPI coerces it: a request to /verses/7 gives you the integer 7. A request to /verses/banana? FastAPI returns a clean 422 before your function ever runs, with a body explaining that verse_id should be an integer. You wrote that validation by hand in Node. Here the type hint is the validation.

The same rule sorts query parameters: a name with a default (limit: int = 10) is optional; a name without one (term: str) is required. FastAPI knows the difference from the signature alone.


10.3 — Pydantic Models as Specifications

Here is the heart of the chapter.

In Coding 2 you learned that a specification is a promise: given these inputs satisfying these preconditions, this method guarantees these postconditions. You wrote those promises in Javadoc and enforced them with assert and with tests. The discipline was precise speech: say exactly what the method requires and exactly what it delivers.

A Pydantic model is that promise, made executable, for the shape of your data.

from pydantic import BaseModel, Field

class VerseCreate(BaseModel):
    reference: str = Field(..., min_length=1, max_length=80)
    text: str = Field(..., min_length=1, max_length=1000)
    translation: str = Field(default="ESV", min_length=1, max_length=20)

Read that as a contract, line by line:

  • reference is a required string (the ... — Python’s Ellipsis — means “no default, you must supply it”), between 1 and 80 characters.
  • text is a required string, 1 to 1000 characters.
  • translation is an optional string; if omitted it defaults to "ESV".

Now wire it into a route. When a Pydantic model is the type of a parameter, FastAPI reads it from the request body and validates it:

@app.post("/litman-books/verses")
def create_verse(body: VerseCreate):
    # If we get here, `body` is GUARANTEED valid. The contract held.
    return {"reference": body.reference, "text": body.text}

What happens to a request that violates the contract? Suppose a client POSTs {"reference": "", "text": "hi"}reference is empty, below min_length=1. FastAPI never calls your function. It returns 422 Unprocessable Entity with a body that says, precisely, what was wrong:

{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["body", "reference"],
      "msg": "String should have at least 1 character",
      "input": ""
    }
  ]
}

Stop and feel the weight of that. You did not write a single line of validation. You wrote a contract — the model — and the framework enforced it, rejected the bad request, and generated a precise, machine-readable explanation of exactly which promise was broken and where. That is the Coding 2 specs discipline, except the specification is now load-bearing code instead of a comment a careless future maintainer can ignore.

Coach’s Note — This is the single biggest difference from your Node server, and the reason FastAPI exists. In Node you validated by hand, which means you validated inconsistently — caught the missing field, forgot the empty string, never thought about the max length until production sent you a 4-megabyte “reference.” A Pydantic model is exhaustive by construction. You say the shape once; every route that uses it gets the same enforcement, the same error format, the same precision. Precise speech, written once, kept everywhere.

Three views of a resource

A subtle but professional move: the shape a client may send is not always the shape your server stores, which is not always the shape you send back. Notice that VerseCreate above has no id. That is deliberate — the client does not get to choose the id; the server assigns it. If id were in the create model, a client could POST {"id": 1, ...} and clobber an existing verse. The contract forbids the bug by simply not offering the field.

So we split the model:

class VerseCreate(BaseModel):     # what the client SENDS (no id)
    reference: str
    text: str
    translation: str = "ESV"

class Verse(BaseModel):           # what the server RETURNS (id assigned)
    id: int
    reference: str
    text: str
    translation: str = "ESV"

Then we tell the route to use Verse as its response model:

@app.post("/litman-books/verses", response_model=Verse, status_code=201)
def create_verse(body: VerseCreate):
    new_id = assign_id()
    store[new_id] = body.model_dump()
    return {"id": new_id, **store[new_id]}

response_model=Verse does two jobs: it documents the response shape in /docs, and it filters the output — any field not declared on Verse is stripped before it goes out the wire, so you can never accidentally leak an internal field you forgot about. The contract guards the door in both directions.

(body.model_dump() is the Pydantic v2 method that turns a validated model back into a plain dict — the successor to v1’s .dict(). If you find tutorials using .dict() or class Config:, they are v1; we use v2 throughout.)


10.4 — The API That Documents Itself: /docs

Here is a thing your Node server did not do, and could not do without real extra work.

Start any FastAPI app and visit http://127.0.0.1:8000/docs.

You get a complete, interactive documentation page — every endpoint, every parameter, every request shape, every response shape, with “Try it out” buttons that send real requests to your running server. You wrote none of it. FastAPI generated it from your route signatures and your Pydantic models.

How? FastAPI emits an OpenAPI specification — a standard, machine-readable JSON description of your entire API — served at /openapi.json. The /docs page is a tool called Swagger UI rendering that spec into something a human can click. (There is a second renderer at /redoc if you prefer it.)

This is not a toy. The OpenAPI spec is the same artifact teams use to generate client libraries, to drive contract tests, to onboard the next engineer. And here is the lesson that ties it to the whole chapter: the documentation is generated from the contracts. Because your Pydantic models are the spec, the docs cannot drift out of sync with the code the way a hand-written README always eventually does. The promise and the description of the promise are the same object.

Coach’s Note — Recall Coding 2, where I hammered on this: a comment that contradicts the code is worse than no comment, because it lies with authority. The auto-generated /docs page can never lie, because it is not a separate description of the code — it is the code, rendered. When you change a model, the docs change in the same commit, automatically. That is the dream that hand-written documentation chases and never catches.

Open /docs for the chapter’s code/main.py. Click on POST /verses, hit “Try it out,” send a verse, watch the 201 come back. Then send an empty reference and watch the 422. You are reading the contract and testing it in the same window.


10.5 — Exceptions: 404 and 400 the FastAPI Way

Validation (the 422) is automatic. But plenty of errors are your logic: the client asked for verse 7 and there is no verse 7. That is a 404, and you raise it yourself with HTTPException.

from fastapi import FastAPI, HTTPException

@app.get("/litman-books/verses/{verse_id}", response_model=Verse)
def get_verse(verse_id: int):
    fields = store.get(verse_id)
    if fields is None:
        raise HTTPException(status_code=404, detail=f"No verse with id {verse_id}.")
    return {"id": verse_id, **fields}

raise HTTPException(...) does exactly what the name says: it stops the handler and produces an HTTP error response with the status code and a JSON body {"detail": "..."}. Compare Week 9, where a 404 meant manually calling res.writeHead(404) and res.end(JSON.stringify(...)) and remembering to return so the rest of the handler did not also run. Here, raise unwinds the stack for you — there is no “forgot to return after the error” bug to make, because raising an exception cannot fall through.

You use the same tool for a 400 when you judge the request bad for a reason Pydantic cannot know — a business rule, not a shape rule:

@app.post("/litman-books/verses", response_model=Verse, status_code=201)
def create_verse(body: VerseCreate):
    if body.reference in existing_references():
        raise HTTPException(status_code=409, detail="That reference already exists.")
    ...

Here is the clean division of labor, and it is worth memorizing:

Kind of errorWho catches itStatus
Body is not valid JSONFastAPI (automatic)400
Body is JSON but wrong shape/typePydantic (automatic)422
Shape is fine but violates a business ruleYou, via HTTPException400 / 409
Resource does not existYou, via HTTPException404
Your code threw unexpectedlyFastAPI (last resort)500

The framework handles the mechanical validation; you handle the judgment calls. That split is itself an architectural principle: automate the rules that can be stated as shapes; reserve human-written logic for the rules that require judgment. It is the same principle that governs how you will direct agentic AI on this week’s project.


10.6 — FastAPI Is Async, and Why That Connects to Weeks 8–9

FastAPI runs on ASGI — the Asynchronous Server Gateway Interface. uvicorn is an ASGI server. This means FastAPI, like your Node server, is built around an event loop, not a thread-per-request model.

Recall the concurrency lessons. In Week 8 you learned that Python threads do not parallelize CPU-bound work (the GIL), but they do help I/O-bound work, because a thread waiting on the network is not holding the GIL. In Week 9 you saw Node’s single-threaded event loop juggle many connections by never blocking — it kicks off the slow I/O and goes to serve someone else while it waits.

FastAPI gives you the event-loop model in Python. You can write a route as async def:

import asyncio

@app.get("/litman-books/slow")
async def slow():
    await asyncio.sleep(2)        # yields the loop for 2s; serves others meanwhile
    return {"done": True}

While that await asyncio.sleep(2) waits, the event loop is free to handle other requests — exactly the behavior you demonstrated in Node’s Hard tier last week. The await is the point where this handler says “I am going to wait now; go do something useful.” That is the same cooperative-concurrency idea, now in Python.

Two honest cautions, because the right-tool thesis demands honesty:

  1. You can write plain def routes too (as code/main.py does), and FastAPI runs them in a threadpool so they do not block the loop. For an in-memory CRUD API with no real I/O, plain def is perfectly correct and simpler. Do not reach for async because it sounds advanced; reach for it when you have real await-able I/O (a database call, an HTTP call to another service). Right tool for the job, at the smallest scale.
  2. async does not buy you CPU parallelism. The GIL is still there. An async def route doing heavy math blocks the loop just as a Node handler doing heavy math blocks Node’s. For CPU-bound work in Python you still reach for multiprocessing (Week 8). The event loop is for I/O concurrency, not CPU parallelism. Same lesson, new framework.

Coach’s Note — This is the payoff of Phase 1. You are not learning “FastAPI is async” as a magic incantation. You are recognizing the event loop you already understand, wearing Python clothes. When someone on a team says “should this endpoint be async?”, you now have the real answer: only if it actually waits on I/O — otherwise you are adding a keyword that buys you nothing and a footgun if you accidentally block the loop inside it.


10.7 — Node vs FastAPI: The Right-Tool Decision

You have now built the same API in both stacks. So which is right?

Wrong question. The right question is: right for which constraints? Here is the honest comparison. Read every row as a lever — change the constraint, change the answer.

DimensionRaw Node (http)FastAPI (Python)
Typing & validationmanual, by hand, inconsistentdeclarative via Pydantic, exhaustive, automatic
Self-documentationnone (you write the README)free /docs (OpenAPI) from the code
Async modelevent loop, native, single-threadedevent loop (ASGI), async/await, GIL still applies
Raw throughputvery high; minimal overhead per requesthigh, but Pydantic validation costs CPU per request
Ecosystem fitthe JS/TS world, npm, front-end shared languagethe Python world: data science, ML, scientific libs
ML / data integrationcall out to a separate Python servicenative — import the model in the same process
Team familiarityevery web dev knows JSevery data/ML person knows Python
Boilerplate to a CRUD APIsubstantial (you felt it last week)minimal (you felt it this week)

Notice that neither column is “the winner.” Each cell is a constraint. The architect’s move is to find out which constraints are real for this problem and let them decide:

  • The team is three front-end engineers who know only JavaScript, and the API is dead simple. → Node. Sharing one language across the stack is worth more than free validation you barely need. Forcing them into Python is a cost with no matching benefit.
  • The request bodies are large, nested, and full of rules, and the cost of a bad request reaching the database is high. → FastAPI. Pydantic earns its keep the moment validation gets non-trivial. The CPU cost of validating is cheaper than the cost of a bad write.
  • This API exists to serve predictions from a Python ML model. → FastAPI, not even close. You can import the model and call it in-process. In Node you would stand up a second service in Python and pay the network hop between them — more moving parts, more failure modes, for no reason.

Coach’s Note — “Both are correct under different constraints” is not a cop-out. It is the entire skill. The amateur has one hammer and calls everything a nail — “I always use Node” or “I always use Python.” The architect carries both and chooses with reasons they can write down. When you can write the paragraph that says why this stack for this problem, you are doing the job. The Hard tier of this week’s project makes you write exactly that paragraph, three times.

And here is where agentic AI fits, because Phase 2 assumes it: an agent can build either version. Hand it “re-implement this Node API in FastAPI” and it will produce competent code, fast. What an agent cannot do is sit in the room with the constraints and decide which version this problem deserves. That judgment — which column of the table wins, for these people, on this deadline, with this data — is yours. The agent builds what you specify. You decide what is worth building.


10.8 — Common Bugs

Bug: You wrote async def on a route, then called a blocking library inside it (a synchronous database driver, time.sleep, heavy CPU work). It blocks the entire event loop, and all requests stall. Example: async def report(): time.sleep(5) — every other client waits 5 seconds too. Fix: Inside async def, only call await-able things. If a call is blocking and you cannot await it, either make the route a plain def (FastAPI runs it in a threadpool) or push the work off the loop. When in doubt for an in-memory API: use plain def.


Bug: You put id in your *Create model, so a client can POST its own id and overwrite an existing resource. Example: class VerseCreate(BaseModel): id: int; ... then POST {"id": 1, ...} clobbers verse 1. Fix: The create model must not contain server-assigned fields. Split create/response models. The client sends VerseCreate (no id); the server returns Verse (id assigned). The contract forbids the bug.


Bug: You returned a dict with extra internal fields and leaked them, because you forgot response_model. Example: the route returns {"id": 1, "text": "...", "_internal_flag": true} and the client sees _internal_flag. Fix: Declare response_model=Verse. FastAPI strips any field not on the response model before it goes out. The model is the door, in both directions.


Bug: You expected a 400 for a wrong-typed field but got a 422, and your test asserting 400 failed. Example: posting {"reference": 5} (int where str required) returns 422, not 400. Fix: Learn the line. 400 = unparseable / your hand-raised business error. 422 = parseable JSON that fails the Pydantic contract. FastAPI uses 422 for all schema-validation failures. Assert 422 in those tests.


Bug: You used Pydantic v1 syntax (.dict(), class Config:, @validator) copied from an old tutorial, and got deprecation errors or silent wrong behavior on a v2 install. Fix: Use v2: .model_dump() not .dict(), model_config = ConfigDict(...) not class Config:, @field_validator not @validator. Check the import line — if the tutorial is from before 2023, suspect v1.


Bug: You ran uvicorn main:app from the wrong directory and got ModuleNotFoundError: No module named 'main'. Fix: uvicorn resolves main:app relative to the current working directory. Run it from the folder that contains main.py. main is the filename without .py; app is the variable name inside it.


10.9 — Reps

Open the exercises for the full set. This is Phase 2, so agentic AI is ON for the project — but the reps this week are still hand-built. You cannot direct an agent to build a FastAPI service well if you have never typed @app.get yourself and watched the 422 come back.

A preview:

  • Rep 1 — Install FastAPI + uvicorn, run the three-line hello app, hit /docs.
  • Rep 3 — Write your first Pydantic model and watch it reject bad input with a 422.
  • Rep 6 — Add typed query parameters for filtering and pagination.
  • Rep 8 — Raise a clean 404 with HTTPException.
  • Rep 11 — Stand up the full CRUD resource cold, from a blank file.

Type every line. Run every endpoint. Read every 422 body — it is the contract telling you exactly which promise broke.


10.10 — This Week’s Project

You’re ready for Project 10 — The Same API, in FastAPI, in Project 10.

You will re-implement your Week 9 Node JSON API in FastAPI, with Pydantic models as the request/response contracts and a working /docs page — and it must be behaviorally identical to the Node version. The Medium tier adds validated query parameters (filter and paginate) with proper 404/400 handling. The Hard tier is the architect’s deliverable: the Node-vs-FastAPI memo for three different constraint scenarios, naming where an agent could have built each version and where you had to decide.

This is Phase 2, so an agent-log.txt is required — every task you delegated to an agent, what it did, and where you intervened. The project is shaped so the agent cannot finish it alone: it can scaffold the FastAPI code, but the stack decision in the Hard tier is judgment only you can supply.


10.11 — Coach’s Final Word for Week 10

Last week you built a server with no framework and learned what a framework hides. This week you let a framework hide it — and because you had done it by hand first, you saw exactly what FastAPI was doing for you at every line. That is the only honest way to learn a framework: build it raw once, then let the tool take over, so the tool is a convenience and not a mystery.

The deeper lesson is the contract. A Pydantic model is precise speech in code — your yes is yes and your no is no, enforced by the machine, documented automatically, kept everywhere. The church wrote confessions for the same reason you write a Pydantic model: so that the promise cannot be quietly bent later, so that “this and not that” is stated in words too precise to twist. When you write a model that says exactly what a request must be, you are practicing a very old discipline in a very new medium.

If you find this easy because Python is familiar: good, but the lesson is not the syntax — it is the decision. Push to the Hard tier and write the memo.

If you find the async parts hard: that’s the gap. It is the same event loop from Weeks 8 and 9. Close it.

See you on Monday.


Up next: Complete every rep in the exercises. Then build Project 10 — the same API, in FastAPI, with the constraint memo. After that, Chapter 11 — persistence with SQLite, where your in-memory data finally learns to survive a restart.