Module 2 · FastAPI · Deep Dive
Your Python is fluent — now turn it into a web API. FastAPI is how DocChat will talk to the world: typed routes, automatic validation, and docs you get for free.
BasicIntermediateBuild
FastAPI is a Python web framework for building APIs. Three things make it the modern default — and each one is an interview talking point:
Request validator — the function signature is the contract.
Install FastAPI with its standard extras (this pulls in the dev server, Uvicorn, and the validation engine):
# the quotes matter — they stop your shell eating the brackets pip install "fastapi[standard]"
Write the smallest possible app in main.py:
main.py
from fastapi import FastAPI app = FastAPI() @app.get("/") def root(): return {"status": "ok"}
Run it with the dev server, which reloads on every save:
fastapi dev main.py
Now open the two URLs you got for free:
/docs — Swagger UI: an interactive page where you can fill in parameters and hit Execute to call your own API in the browser./redoc — ReDoc: the same API described as clean, readable reference documentation.curl while developing FastAPI. The /docs page is a live test client generated from your type hints. Build a route, refresh, click Execute. This is the single biggest productivity win over your old PHP workflow.
A path operation is a route: an HTTP method plus a URL, declared with a decorator on a function. The four you'll use most:
@app.get("/documents") # read a collection @app.post("/documents") # create @app.put("/documents/{id}") # replace/update @app.delete("/documents/{id}")
A path parameter is a piece of the URL you capture. Put it in braces in the path, then declare it as a function argument with a type hint. FastAPI validates and converts it for you:
@app.get("/documents/{doc_id}") def get_document(doc_id: int): return {"doc_id": doc_id}
Because doc_id is typed int, a request to /documents/7 arrives as the integer 7. A request to /documents/abc never reaches your code — FastAPI returns a clean 422 validation error automatically.
Route::get('/documents/{id}', ...) then cast and check $id yourself. Here the : int hint does the cast and the rejection — for free.
Function arguments that aren't in the path automatically become query parameters (the ?key=value part of a URL). Give them a default to make them optional:
from typing import Optional @app.get("/documents") def list_documents(limit: int = 10, search: Optional[str] = None): return {"limit": limit, "search": search}
Now /documents uses limit=10 and no search; /documents?limit=5&search=tax overrides both. Optional[str] = None means "a string if given, otherwise nothing" — the modern way to say "this parameter is optional".
? → query param (filters, sorts, paginates the resource). /documents/7 vs /documents?limit=5.
For anything more than a couple of fields — like the JSON a client POSTs to create a document — you declare a Pydantic model: a class that inherits from BaseModel with typed fields. FastAPI reads, validates, and parses the request body against it.
from pydantic import BaseModel, Field class Document(BaseModel): title: str = Field(..., min_length=1) pages: int = Field(..., gt=0) @app.post("/documents") def create_document(doc: Document): return doc
Because doc is typed as a Document, FastAPI knows it comes from the request body. Send {"title": "Intro", "pages": 12} and you get a validated Document object. Send {"pages": -1} and the client gets a precise 422 error explaining what's wrong — you wrote zero validation code.
Field(...) adds constraints. The literal ... means "required". Common ones:
| Constraint | Meaning | Applies to |
|---|---|---|
min_length=1 | Non-empty / minimum size | strings, lists |
max_length=200 | Maximum size | strings, lists |
gt=0 | Strictly greater than | numbers |
ge=1 | Greater than or equal | numbers |
$_POST against a rules array — but automatic, typed, and self-documenting. The model is the rulebook, the parser, and the OpenAPI schema all at once.
Python bridge: remember @dataclass from Module 1? Pydantic's BaseModel is that idea — a typed data holder — plus validation, coercion, and JSON serialisation baked in.
You can also declare what a route returns with response_model. FastAPI filters the output to match — handy for hiding internal fields like a password hash:
@app.post("/documents", response_model=Document) def create_document(doc: Document): return doc # output validated & shaped to Document
A field can itself be another model — exactly how real JSON nests:
class Author(BaseModel): name: str class Document(BaseModel): title: str author: Author # nested — {"title": ..., "author": {"name": ...}}
Return a dict (or a Pydantic model, or a list) and FastAPI serialises it to JSON with the right headers. No json_encode, no Content-Type juggling.
@app.get("/health") def health(): return {"ok": True} # → {"ok": true} with 200
Set a non-default status code on the decorator — a successful create should answer 201 Created:
@app.post("/documents", status_code=201) def create_document(doc: Document): return doc
When something genuinely goes wrong — a document that doesn't exist — raise an HTTPException. FastAPI turns it into the right JSON error response:
from fastapi import HTTPException @app.get("/documents/{doc_id}") def get_document(doc_id: int): if doc_id not in store: raise HTTPException(404, "Document not found") return store[doc_id]PHP bridge: instead of
http_response_code(404); echo json_encode([...]), you raise and let the framework format the response. Cleaner, and it short-circuits the function.
You can write a path function as either def or async def, and FastAPI handles both. Use async def when the body awaits something I/O-bound — a database query, an HTTP call to an LLM, a file read. Use plain def for quick, synchronous, CPU-light work; FastAPI runs it safely in a threadpool so it never blocks the event loop. The rule: if a library gives you an await-able call, make the route async def and await it; otherwise plain def is fine.
@app.get("/documents/{doc_id}") async def get_document(doc_id: int): doc = await db.fetch(doc_id) # await an I/O call return doc
Document model and raises a proper 404. This is the literal foundation you'll bolt a database and RAG onto in later modules.
Run it with fastapi dev main.py, open /docs, and exercise every route from the browser.
main.py
from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field app = FastAPI() class Document(BaseModel): title: str = Field(..., min_length=1) pages: int = Field(..., gt=0) documents: dict[int, Document] = {} # in-memory store next_id = 1 @app.get("/documents") def list_documents(): return documents @app.get("/documents/{doc_id}") def get_document(doc_id: int): if doc_id not in documents: raise HTTPException(404, "Document not found") return documents[doc_id] @app.post("/documents", status_code=201) def create_document(doc: Document): global next_id documents[next_id] = doc new_id, next_id = next_id, next_id + 1 return {"id": new_id, "document": doc} @app.delete("/documents/{doc_id}", status_code=204) def delete_document(doc_id: int): if doc_id not in documents: raise HTTPException(404, "Document not found") del documents[doc_id]
That's a complete, validated, self-documented REST API in ~25 lines. The next lesson adds the missing update (PUT) route, then we replace the in-memory dict with a real database.
Answer from memory — retrieval is what moves this from "I read it" to "I know it".
Where do FastAPI's interactive Swagger docs live?
What validates an incoming JSON request body?
How do you return a clean 404 from a route?
In /documents/{doc_id}, what is doc_id?
When is async def the right choice for a route?