Skip to content

    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):

    Terminal
    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):

    Terminal
    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.

    backend/cap54_fastapi_basico.pylinhas 10 a 47
    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:

    backend/cap54_fastapi_basico.pylinhas 52 a 56
    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())
    
    Output
    {'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:

    backend/cap54_fastapi_basico.pylinhas 58 a 60
    resposta = cliente.post("/livros", json={"titulo": "", "paginas": -1})
    print(resposta.status_code)
    print([(erro["loc"], erro["type"]) for erro in resposta.json()["detail"]])
    
    Output
    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:

    backend/cap54_fastapi_basico.pylinhas 65 a 82
    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"]))
    
    Output
    {'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.