Chapter 54, Backend
FastAPI
FastAPI transforms Python functions with type annotations into a validated and documented API. It only makes sense after you master types, exceptions, and tests, and that is why it comes now.
Code for this chapter: backend/cap54_fastapi_basico.py
Install and run
FastAPI runs on top of Starlette and uses Pydantic for validation. The uvicorn is the server. The httpx2 is required for the testing client (Starlette 1.x prefers it, and with the httpx old one it still works, but warns that it is deprecated):
uv add fastapi uvicorn
uv add --dev httpx2 pytest
With a file main.py that defines app, the server starts like this, and --reload reloads on save (only in development):
uv run uvicorn main:app --reload
Then, open http://127.0.0.1:8000/docs: FastAPI generates an interactive page with all the routes from your code.
Routes, models, and validation
Each route is a function. The path parameter comes from the URL, the query parameter comes from ?x=1, and the body is a Pydantic model. Validation happens before your function runs: if the data is invalid, it is not called at all.
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI(title="Biblioteca")
class LivroCriar(BaseModel):
titulo: str = Field(min_length=1, max_length=100)
paginas: int = Field(gt=0)
class Livro(LivroCriar):
id: int
banco: dict[int, Livro] = {}
@app.post("/livros", response_model=Livro, status_code=201)
def criar(dados: LivroCriar) -> Livro:
livro = Livro(id=len(banco) + 1, **dados.model_dump())
banco[livro.id] = livro
return livro
@app.get("/livros/{livro_id}", response_model=Livro)
def obter(livro_id: int) -> Livro:
if livro_id not in banco:
raise HTTPException(status_code=404, detail="livro não encontrado")
return banco[livro_id]
@app.get("/livros")
def listar(minimo_paginas: Annotated[int, Query(ge=0)] = 0) -> list[Livro]:
return [livro for livro in banco.values() if livro.paginas >= minimo_paginas]
The response_model filters the output: if your internal object has a field senha, it does not leak, because only what is in the model is serialized.
Test without starting the server
The TestClient calls the application directly, in memory, without network. This is how I test every route:
cliente = TestClient(app)
print(cliente.post("/livros", json={"titulo": "Python na Prática", "paginas": 500}).json())
print(cliente.get("/livros/1").status_code)
nao_existe = cliente.get("/livros/9")
print(nao_existe.status_code, nao_existe.json())
{'titulo': 'Python na Prática', 'paginas': 500, 'id': 1}
200
404 {'detail': 'livro não encontrado'}
When the body is invalid, FastAPI responds 422 with the exact list of wrong fields, the location of each one, and the type of error:
resposta = cliente.post("/livros", json={"titulo": "", "paginas": -1})
print(resposta.status_code)
print([(erro["loc"], erro["type"]) for erro in resposta.json()["detail"]])
422
[(['body', 'titulo'], 'string_too_short'), (['body', 'paginas'], 'greater_than')]
Dependency injection
With Depends, a route declares what it needs (a database session, the logged-in user, a configuration), and FastAPI provides it. The decisive advantage is in testing: you can swap the real dependency for a fake without changing the route:
def obter_banco() -> dict[int, Livro]:
return banco
BancoDep = Annotated[dict[int, Livro], Depends(obter_banco)]
@app.get("/total")
def total(base: BancoDep) -> dict[str, int]:
return {"total": len(base)}
print(cliente.get("/total").json())
app.dependency_overrides[obter_banco] = lambda: {i: Livro(id=i, titulo="x", paginas=1) for i in range(5)}
print(cliente.get("/total").json())
app.dependency_overrides.clear()
print(sorted(app.openapi()["paths"]))
{'total': 1}
{'total': 5}
['/livros', '/livros/{livro_id}', '/total']
The last line shows that the OpenAPI contract exists and lists the routes. It is this specification that feeds the page /docs and that tools generate clients and contract tests.
def or async def
Declare the route with async def only if everything it calls is asynchronous (await). If it calls a synchronous library (a common database driver, the requests), use def simple: FastAPI executes it in a separate thread and the server remains responsive. A route async def that calls blocking code freezes all requests, the error that chapter 47 already showed.
Where each responsibility resides
In the routes, there is only the HTTP translation (reading the input, choosing the status, returning the output). The business logic resides in a service, and the database access in a repository. It is the separation that the architecture chapter presented, and the final project (chapter 62) applies it.
Exercise 1
Remove a book
Add DELETE /livros/{livro_id} that responds 204 when removing and 404 when the book does not exist.