Part 4 turns your Python skills into a real API. FastAPI combines the typing and Pydantic foundations from earlier parts into a framework that validates requests, generates docs, and runs async out of the box. This part covers the three things you use in every endpoint: routing, request and response models, and dependency injection.
What you will build
- Typed routes with path and query parameters
- Request and response models that validate automatically
- Dependency injection for shared resources and auth
- Automatic interactive docs at /docs
Info
Install
Add FastAPI and an ASGI server with uv add "fastapi[standard]". The standard extra includes uvicorn and the CLI used below.
1. A typed route
A FastAPI route is a plain async function with type hints. FastAPI reads the hints to parse and validate inputs, and to document the endpoint. Path parameters come from the URL, query parameters from the query string.
from fastapi import FastAPI
app = FastAPI(title="Projects API")
@app.get("/projects/{project_id}")
async def get_project(project_id: int, include_archived: bool = False) -> dict:
return {"id": project_id, "include_archived": include_archived}
Run it with uv run fastapi dev src/llm_app/main.py and open /docs. The id is validated as an integer and the flag defaults to false, with no manual parsing.
2. Request and response models
Use Pydantic models for bodies. FastAPI validates the incoming JSON against the request model and shapes the output to the response model, so you never leak internal fields by accident.
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class ProjectIn(BaseModel):
name: str = Field(min_length=3, max_length=120)
status: str = "draft"
class ProjectOut(BaseModel):
id: int
name: str
status: str
@app.post("/projects", response_model=ProjectOut, status_code=201)
async def create_project(payload: ProjectIn) -> ProjectOut:
return ProjectOut(id=1, name=payload.name, status=payload.status)
3. Dependency injection
A dependency is a function FastAPI calls for you and injects the result. Use it for shared resources like a database session or an HTTP client, and for cross cutting concerns like authentication. The same dependency can guard many routes.
from fastapi import Depends, FastAPI, Header, HTTPException
app = FastAPI()
async def require_api_key(x_api_key: str = Header(...)) -> str:
if x_api_key != "secret-key":
raise HTTPException(status_code=401, detail="invalid api key")
return x_api_key
@app.get("/secure")
async def secure(api_key: str = Depends(require_api_key)) -> dict:
return {"ok": True}
The route never runs unless the dependency passes, and the dependency is testable on its own. This is the same idea you practiced in Part 2 and Part 3, now wired into the framework.
Checkpoint
What does the response_model parameter do?
4. Validating path and query parameters
Type hints handle parsing, but you often want rules too: a page number that must be positive, a search term with a maximum length, a limit capped to protect the database. FastAPI exposes Path and Query for exactly this, and a failed rule produces the same clean validation error as a bad body. You declare the constraint once and never write a manual guard.
from fastapi import FastAPI, Path, Query
app = FastAPI()
@app.get("/projects/{project_id}/items")
async def list_items(
project_id: int = Path(ge=1),
q: str | None = Query(default=None, max_length=80),
limit: int = Query(default=20, ge=1, le=100),
) -> dict:
return {"project_id": project_id, "q": q, "limit": limit}
5. Errors and status codes
Returning a 200 with an error message hidden in the body is a habit worth breaking. Raise HTTPException with the right status code and let FastAPI shape the response. Clients, logs, and monitoring all rely on the status line, so a missing record should be a 404 and a forbidden action a 403, not a 200 that quietly contains the word error.
from fastapi import FastAPI, HTTPException
app = FastAPI()
DB = {1: "Search API"}
@app.get("/projects/{project_id}")
async def get_project(project_id: int) -> dict:
name = DB.get(project_id)
if name is None:
raise HTTPException(status_code=404, detail="project not found")
return {"id": project_id, "name": name}
For validation errors you do nothing at all: when a request body fails its Pydantic model, FastAPI returns a 422 with a detail list naming each bad field. That is the behavior you will assert on in the testing part, and it is free because of the modeling work from Part 2.
6. Routers keep the app from sprawling
A single file with every route becomes unmanageable fast. APIRouter lets you group related routes into their own module with a shared prefix and tags, then include them in the app. This is the structure Part 5 builds on, and it keeps each feature, projects, chat, search, in a file you can read top to bottom.
# routers/projects.py
from fastapi import APIRouter
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("")
async def list_projects() -> list[dict]:
return [{"id": 1, "name": "Search API"}]
# main.py
from fastapi import FastAPI
from routers import projects
app = FastAPI()
app.include_router(projects.router)
7. The docs are not an afterthought
Because every route is typed and every model is declared, FastAPI generates an OpenAPI schema and serves interactive docs at /docs and a clean reference at /redoc. This is genuinely useful, not a checkbox. Frontend developers read it, you click Try it out to exercise an endpoint without writing a client, and the schema can generate typed API clients. Add a summary and description to a route and it shows up in the docs automatically.
@app.post(
"/projects",
summary="Create a project",
description="Creates a project and returns it with a generated id.",
response_model=ProjectOut,
status_code=201,
)
async def create_project(payload: ProjectIn) -> ProjectOut:
return ProjectOut(id=1, name=payload.name, status=payload.status)
Pro tip
Let the framework do the work. If you find yourself manually parsing query strings, hand-writing validation, or maintaining API docs in a separate document, there is almost always a FastAPI feature that does it from your type hints instead.
8. Putting the pieces together
Routing, models, dependencies, and errors are not separate tricks, they combine into one readable endpoint. The handler below validates the body with a model, pulls a shared resource through a dependency, returns a 404 when appropriate, and shapes its output with a response model. Read it and notice how little glue code there is: the framework does the parsing, validation, and serialization, leaving you with the actual logic.
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
router = APIRouter(prefix="/projects", tags=["projects"])
DB: dict[int, dict] = {}
class ProjectIn(BaseModel):
name: str = Field(min_length=3, max_length=120)
class ProjectOut(BaseModel):
id: int
name: str
async def next_id() -> int: # a stand-in shared dependency
return len(DB) + 1
@router.post("", response_model=ProjectOut, status_code=201)
async def create(payload: ProjectIn, new_id: int = Depends(next_id)) -> ProjectOut:
DB[new_id] = {"id": new_id, "name": payload.name}
return ProjectOut(**DB[new_id])
@router.get("/{project_id}", response_model=ProjectOut)
async def read(project_id: int) -> ProjectOut:
if project_id not in DB:
raise HTTPException(status_code=404, detail="project not found")
return ProjectOut(**DB[project_id])
Everything here is testable in isolation, which is the quiet benefit of building it this way. The model can be validated on its own, the dependency is a plain function you can call directly, and in Part 7 you will drive the whole endpoint with the test client and override next_id without changing a line of the handler.
9. Why this scales
The reason this structure holds up is that responsibilities stay separated. Validation lives in models, shared resources and policy live in dependencies, and routes stay thin. When a requirement changes, you usually touch one of those layers and not the others. Add a field and you edit a model. Add auth and you add a dependency. Add an endpoint and you add a route to a router. That separation is what keeps an API maintainable as it grows from a demo into a product.
Info
The docs prove the contract
Because the example above is fully typed, /docs shows the request and response shapes, the 201 and 404 responses, and a working Try it out form, with no extra effort. If a field is missing from the docs, it is missing a type hint.
The bottom line
FastAPI rewards the groundwork from the earlier parts. Type hints drive routing, Pydantic drives validation, and dependency injection keeps shared resources and auth clean. With these three pieces you can build most of an API, and because each layer stays separate, the app keeps its shape as it grows: models hold validation, dependencies hold shared resources and policy, and routes stay thin. You also get accurate interactive docs for free, which means the contract your frontend reads can never silently drift from the code. Next we harden all of this for production with typed settings, real authentication, middleware you can see through, and a project structure that does not collapse under its own weight.
? Frequently asked questions
Is FastAPI fast enough for production? +
Yes. On an ASGI server like uvicorn it is among the fastest Python frameworks, and most latency in an LLM app comes from the model, not the framework.
Do I need async for every route? +
No, but use async for routes that do network or database I/O so the server can handle other requests while waiting.
Up next: Part 5, FastAPI in production.
Comments
0No comments yet. Be the first to share your thoughts.