Salta al contenuto principale

01 - FastAPI da Zero: Setup, Type Hints e Auto-Documentazione OpenAPI

Come FastAPI trasforma i type hints in validazione, risposte 422 e documentazione OpenAPI, con listati eseguiti su parametri, corpo, errori e configurazione.

Cosa ne pensi di questo articolo?

In Breve

Come FastAPI trasforma i type hints in validazione, risposte 422 e documentazione OpenAPI, con listati eseguiti su parametri, corpo, errori e configurazione.

FastAPI legge le annotazioni di tipo che scrivi comunque e le usa tre volte: per decidere che cosa accettare da una richiesta, per decidere che cosa restituire e per scrivere la documentazione OpenAPI. Il tipo di un parametro, qui, non è un commento: è il contratto dell'endpoint. È il motivo per cui un servizio di cinque rotte si scrive in una sera, e anche il motivo per cui un tipo sbagliato smette di essere un bug silenzioso e diventa una risposta 422.

In questo articolo costruisci un piccolo servizio di anagrafica clienti, un passo alla volta: server minimo, parametri, corpo della richiesta, errori, documentazione, configurazione e struttura delle cartelle. Ogni listato è stato eseguito davvero il 2026-11-12 con FastAPI 0.136.3, Pydantic 2.13.4, pydantic-settings 2.14.1 e email-validator 2.3.0 su CPython 3.12.13. Le righe che cominciano per # Output: sono state copiate dall'uscita dell'interprete. Al momento della consultazione PyPI indicava come ultima versione di FastAPI la 0.162.5, con Python 3.10 o superiore: quella non l'ho provata, e sulle differenze fra le due versioni non mi pronuncio.

Una precisazione sul metodo: i listati usano TestClient, che chiama l'applicazione nello stesso processo, senza aprire porte e senza avviare un server. Quindi i comandi uvicorn e fastapi dev che trovi sotto sono quelli giusti, ma non li ho eseguiti: dei server vedrai solo la risposta che l'applicazione avrebbe dato.

Il server minimo e ciò che genera da solo

Un ambiente virtuale per progetto, sempre: installare FastAPI a livello di sistema significa che due progetti si contendono la stessa versione. L'extra standard porta con sé Uvicorn, il client httpx che serve a TestClient, la riga di comando fastapi e email-validator, che ti servirà fra poco per EmailStr: l'ho letto nei metadati del pacchetto installato (Provides-Extra: standard), non lo do per scontato.

# Comandi di installazione: schema da adattare, non eseguiti qui
python -m venv .venv
source .venv/bin/activate          # su Windows: .venv\Scripts\activate
pip install "fastapi[standard]" pydantic-settings

Il server più piccolo possibile ha un'istanza e una rotta. I tre argomenti del costruttore (title, description, version) non cambiano il comportamento: finiscono nella sezione info dello schema OpenAPI, ed è lì che li leggerà chi consuma la tua API. Il decoratore @app.get("/") registra la funzione sotto il metodo GET e il percorso /; il dizionario che restituisci viene serializzato in JSON.

# main.py
from fastapi import FastAPI

app = FastAPI(
    title="Anagrafica clienti",
    description="API di esempio costruita con FastAPI e i type hints di Python",
    version="1.0.0",
)


@app.get("/")
def read_root():
    return {"message": "Servizio attivo", "status": "running"}

# Output:
# fastapi 0.136.3
# 200 {'message': 'Servizio attivo', 'status': 'running'}
# 200 200
# Anagrafica clienti 1.0.0 ['/']
# 3.1.0

La terza riga dell'output è quella che conta: /docs e /redoc rispondono 200 senza che tu abbia scritto una riga di documentazione, e lo schema (3.1.0 è la versione della specifica OpenAPI che questa FastAPI emette) contiene già la tua rotta e i tre metadati. Per vederlo nel browser avvia il server con fastapi dev main.py, oppure con uvicorn main:app --reload (main è il file, app la variabile, --reload riavvia a ogni modifica: è un comodo da sviluppo, non da produzione). Poi apri http://127.0.0.1:8000/docs. Entrambe le interfacce, Swagger UI e ReDoc, sono descritte nella pagina First Steps della documentazione.

Parametri di percorso e di query: il tipo è il contratto

FastAPI decide da dove prendere ogni parametro guardando la firma della funzione. Se il nome compare nel percorso (/users/{user_id}) è un parametro di percorso; se è un tipo semplice che non compare nel percorso è un parametro di query; se è un modello Pydantic è il corpo. Senza valore predefinito il parametro è obbligatorio, con un valore predefinito è opzionale. Il tipo fa il resto: int significa «converti, e se non riesci rifiuta».

Per i limiti (maggiore di, minore di) si usano Path() e Query(). Qui li scrivo dentro Annotated, che lascia libero il valore predefinito: in limit il = 10 dopo l'annotazione è il default, e ge=1, le=100 è il vincolo. Il caso concreto è la paginazione di un elenco: un client che chiede limit=500 non deve poter far leggere al database cinquecento righe per volta.

# params.py
from typing import Annotated

from fastapi import FastAPI, Path, Query

app = FastAPI()


@app.get("/users/{user_id}")
def get_user(user_id: int):
    return {"user_id": user_id}


@app.get("/items/{item_id}")
def get_item(
    item_id: Annotated[int, Path(title="Codice articolo", ge=1, le=1000)],
):
    return {"item_id": item_id}


@app.get("/search")
def search_users(
    q: str,                                          # obbligatorio: nessun default
    page: int = 1,                                   # opzionale
    limit: Annotated[int, Query(ge=1, le=100)] = 10,  # opzionale, con limiti
    active_only: bool = True,                        # "true"/"false" nella query string
    role: str | None = None,                         # opzionale, None se assente
):
    return {"q": q, "page": page, "limit": limit, "active_only": active_only, "role": role}

# Output:
# 200 /users/42
#     {'user_id': 42}
# 422 /users/abc
#     {'detail': [{'type': 'int_parsing', 'loc': ['path', 'user_id'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'abc'}]}
# 200 /items/5
#     {'item_id': 5}
# 422 /items/0
#     {'detail': [{'type': 'greater_than_equal', 'loc': ['path', 'item_id'], 'msg': 'Input should be greater than or equal to 1', 'input': '0', 'ctx': {'ge': 1}}]}
# 200 /search?q=mario&page=2&limit=20&active_only=false&role=admin
#     {'q': 'mario', 'page': 2, 'limit': 20, 'active_only': False, 'role': 'admin'}
# 422 /search?q=mario&limit=500
#     {'detail': [{'type': 'less_than_equal', 'loc': ['query', 'limit'], 'msg': 'Input should be less than or equal to 100', 'input': '500', 'ctx': {'le': 100}}]}
# 422 /search
#     {'detail': [{'type': 'missing', 'loc': ['query', 'q'], 'msg': 'Field required', 'input': None}]}

Guarda i tre casi d'errore. /users/abc fallisce con tipo int_parsing, e la posizione ['path', 'user_id'] dice senza ambiguità in quale parte della richiesta sta il problema. /items/0 viola ge=1 e il messaggio porta nel campo ctx il limite violato. /search senza q dà missing. In tutti e tre i casi il codice della tua funzione non è nemmeno partito: la validazione è avvenuta prima, ed è per questo che dentro la funzione puoi fidarti dei tipi. Nota anche che active_only=false è arrivato come False, un booleano vero e non la stringa "false".

Il formato di quegli errori (type, loc, msg) è quello di Pydantic, ed è lo stesso che ritroverai nel corpo. Come si leggono nel dettaglio, e come si scrivono validatori più ricchi dei semplici limiti, è l'argomento di Pydantic v2: validazione avanzata.

Il corpo della richiesta: modelli Pydantic e response_model

Per i dati di una POST, PUT o PATCH si dichiara un modello Pydantic e lo si mette come parametro: FastAPI legge il JSON, lo valida contro il modello e ti consegna un oggetto già tipizzato. Conviene usare due modelli distinti, uno per ciò che il client invia (UserCreate) e uno per ciò che il server restituisce (UserResponse): sono due contratti diversi, e tenerli separati impedisce che un campo interno finisca in una risposta perché «tanto è nel modello».

La funzione create_user restituisce di proposito un dizionario con un campo in più, password_hash. Il parametro response_model=UserResponse filtra la risposta sul modello dichiarato, quindi il campo non esce. Il field_validator su name è la regola di dominio che i semplici vincoli non sanno esprimere («nome e cognome»): dev'essere un classmethod, e restituisce il valore, eventualmente ripulito. Il ruolo predefinito è viewer, cioè quello con meno privilegi: un default sbagliato qui è una falla, non un dettaglio.

# users.py
from datetime import datetime, timezone
from enum import Enum

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr, Field, field_validator

app = FastAPI()


class UserRole(str, Enum):
    admin = "admin"
    editor = "editor"
    viewer = "viewer"


class UserCreate(BaseModel):
    name: str = Field(min_length=2, max_length=100, examples=["Mario Rossi"])
    email: EmailStr
    role: UserRole = UserRole.viewer      # il ruolo con meno privilegi
    age: int | None = Field(default=None, ge=0, le=150)

    @field_validator("name")
    @classmethod
    def name_must_contain_space(cls, v: str) -> str:
        if " " not in v.strip():
            raise ValueError("servono nome e cognome")
        return v.strip()


class UserResponse(BaseModel):
    id: int
    name: str
    email: EmailStr
    role: UserRole
    created_at: datetime


@app.post("/users", response_model=UserResponse, status_code=201, tags=["users"])
def create_user(user: UserCreate):
    return {
        "id": 42,                                   # in produzione lo assegna il database
        "name": user.name,
        "email": user.email,
        "role": user.role,
        "password_hash": "$2b$12$non-deve-uscire",  # campo interno
        "created_at": datetime(2026, 11, 12, 9, 30, tzinfo=timezone.utc),
    }

# Output:
# 201 {'id': 42, 'name': 'Mario Rossi', 'email': 'mario@example.com', 'role': 'viewer', 'created_at': '2026-11-12T09:30:00Z'}
# 422
#    ['body', 'name'] value_error - Value error, servono nome e cognome
#    ['body', 'email'] value_error - value is not a valid email address: An email address must have an @-sign.
#    ['body', 'role'] enum - Input should be 'admin', 'editor' or 'viewer'
#    ['body', 'age'] less_than_equal - Input should be less than or equal to 150

Il primo caso mostra il percorso felice: stato 201 (status_code=201 nel decoratore), nessun password_hash nel JSON, e la data restituita come stringa ISO 8601 perché created_at è un datetime e Pydantic lo serializza. Il secondo mostra una proprietà che fa risparmiare tempo: la richiesta aveva quattro difetti, e il 422 li riporta tutti e quattro insieme. Chi usa la tua API corregge tutto in un giro, non uno per tentativo. Un dettaglio su EmailStr: senza il pacchetto email-validator la definizione del modello fallisce subito con «ImportError: email-validator is not installed, run `pip install 'pydantic[email]'`» (l'ho provato togliendolo dal percorso), quindi l'errore si vede all'avvio e non in produzione. L'extra standard lo include.

Gli errori: HTTPException, 422 e il gestore generico

Gli errori attesi, come un cliente che non esiste, si segnalano con HTTPException: l'eccezione interrompe la funzione e FastAPI la trasforma in una risposta con lo stato e il detail che hai indicato. Le costanti di status sono equivalenti ai numeri, ma HTTP_404_NOT_FOUND si legge senza consultare una tabella. Gli errori imprevisti, quelli che non hai previsto, sono un'altra storia, e qui il comportamento ha due sfumature che quasi nessun tutorial dice.

# errors.py
import logging

from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse

app = FastAPI()
logger = logging.getLogger("api")

CLIENTI = {1: {"id": 1, "nome": "Mario Rossi"}}


@app.get("/clienti/{cliente_id}")
def get_cliente(cliente_id: int):
    cliente = CLIENTI.get(cliente_id)
    if cliente is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Cliente {cliente_id} non trovato",
        )
    return cliente


@app.get("/guasto")
def guasto():
    return 1 / 0


@app.exception_handler(Exception)
async def errore_generico(request: Request, exc: Exception):
    logger.error("Eccezione non gestita su %s", request.url.path, exc_info=exc)
    return JSONResponse(status_code=500, content={"detail": "Errore interno"})

# Output:
# 200 {'id': 1, 'nome': 'Mario Rossi'}
# 404 {'detail': 'Cliente 9 non trovato'}
# 500 {'detail': 'Errore interno'}
# TestClient di default rilancia: ZeroDivisionError division by zero
# CORS su 404: https://app.example.it
# CORS su 500: None

La prima sfumatura è nel TestClient: di default rilancia le eccezioni del server, anche se hai registrato un gestore, e questo l'ho misurato (la quarta riga dell'output è un ZeroDivisionError uscito dal client). Se nei test vuoi vedere la risposta 500, costruisci il client con raise_server_exceptions=False, come ho fatto nelle prime tre chiamate. La seconda sfumatura è la riga CORS su 500: None. Il gestore di Exception gira dentro ServerErrorMiddleware, il livello più esterno dello stack di Starlette (lo descrive la pagina Exceptions), cioè fuori dal middleware CORS. Risultato misurato, con un CORSMiddleware(allow_origins=[...]) aggiunto prima dell'avvio e l'intestazione Origin nella richiesta: sulla 404 l'intestazione Access-Control-Allow-Origin c'è, sulla 500 no. Un front-end su un altro dominio, davanti a un errore interno, non vedrà il tuo JSON: il browser lo blocca e nella console compare un errore CORS che nasconde la vera causa. Se ti capita, guarda il log del server prima di sospettare della configurazione CORS.

Il gestore, in ogni caso, serve a due cose: registrare l'eccezione completa nel log (con exc_info) e rispondere con un messaggio generico. Il dettaglio dell'errore non va mai al client: stack trace e percorsi dei file sono materiale per chi vuole attaccare il servizio. La struttura del messaggio d'errore, se vuoi uno standard, è argomento di REST nel 2026.

La documentazione OpenAPI e che cosa spegnere in produzione

Le interfacce che FastAPI espone di default sono tre, e dipendono da tre parametri indipendenti del costruttore:

  • /docs: Swagger UI, per provare le chiamate dal browser; si spegne con docs_url=None.
  • /redoc: ReDoc, una lettura più da manuale; si spegne con redoc_url=None.
  • /openapi.json: lo schema leggibile dalle macchine, da cui si generano i client; si spegne con openapi_url=None.

La distinzione che conta è nell'ultima voce. Spegnere solo le due interfacce grafiche lascia lo schema completo raggiungibile, e dallo schema si ricostruisce tutto ciò che la grafica mostrava. La documentazione dice che con openapi_url=None anche le due interfacce si disattivano, perché dipendono dallo schema: ed è il parametro da impostare se vuoi davvero chiudere.

# prod.py
import os

from fastapi import FastAPI

DEBUG = os.getenv("DEBUG", "false").lower() == "true"

app = FastAPI(
    docs_url="/docs" if DEBUG else None,
    redoc_url="/redoc" if DEBUG else None,
    openapi_url="/openapi.json" if DEBUG else None,
)


@app.get("/health")
def health():
    return {"status": "ok"}

# Output:
# DEBUG=true [200, 200, 200, 200]
# DEBUG=false [404, 404, 404, 200]
# solo docs_url/redoc_url a None: [404, 404, 200]

Le due righe di DEBUG sono il confronto: con il flag acceso rispondono tutte e tre le rotte, spento restano tre 404 e il solo /health. La riga finale lo conferma sull'errore più comune: con docs_url=None, redoc_url=None e basta, /openapi.json risponde 200. Detto questo, un trade-off onesto: nascondere la documentazione riduce quanto un curioso trova a colpo d'occhio, ma non protegge nulla. Le rotte rispondono lo stesso a chi ne conosce il percorso. La protezione è l'autenticazione; il documento spento è solo un'igiene in più, e in una API pubblica pensata per terzi la documentazione accesa è spesso proprio quello che vuoi.

Per arricchire la documentazione non serve riscrivere lo schema a mano: il costruttore accetta summary, contact e license_info (li descrive la pagina Metadata and Docs URLs), e il decoratore accetta tags e summary per raggruppare e titolare le rotte.

# docs_meta.py
from fastapi import FastAPI

app = FastAPI(
    title="Anagrafica clienti",
    version="2.0.0",
    summary="Gestione dell'anagrafica",
    description="Descrizione lunga, con **markdown**.",
    contact={"name": "Federico Calo", "url": "https://federicocalo.dev"},
    license_info={"name": "MIT", "url": "https://opensource.org/licenses/MIT"},
)


@app.get("/clienti", tags=["clienti"], summary="Elenco dei clienti")
def elenco():
    return []

# Output:
# title -> Anagrafica clienti
# summary -> Gestione dell'anagrafica
# description -> Descrizione lunga, con **markdown**.
# contact -> {'name': 'Federico Calo', 'url': 'https://federicocalo.dev/'}
# license -> {'name': 'MIT', 'url': 'https://opensource.org/licenses/MIT'}
# version -> 2.0.0

Ogni coppia dell'output è una chiave della sezione info dello schema: riassunto, contatto e licenza sono finiti lì, e un client generato dallo schema li porta con sé. Nota che l'URL del contatto è tornato con una barra finale: lo schema normalizza gli URL, e se confronti il testo in un test lo scopri.

La configurazione con pydantic-settings

Le impostazioni che cambiano da un ambiente all'altro (URL del database, chiavi, origini consentite) non si scrivono nel codice: si leggono dalle variabili d'ambiente. La classe BaseSettings del pacchetto pydantic-settings fa questo e in più valida i tipi, quindi un'impostazione mancante o malformata ferma l'avvio invece di manifestarsi alla prima richiesta. I campi senza valore predefinito (database_url, secret_key) sono obbligatori; gli altri hanno un default sensato. Il nome della variabile corrisponde a quello del campo senza distinguere le maiuscole, e il file .env è una fonte aggiuntiva.

La funzione get_settings è decorata con lru_cache così l'oggetto si costruisce una volta sola, e non a ogni richiesta, e si inietta nelle rotte con Depends. La documentazione dedica a questo schema la sezione «Creating the Settings only once with lru_cache» della pagina Settings and Environment Variables; nei test puoi sostituire la dipendenza con app.dependency_overrides.

# app/config.py
from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    app_name: str = "Anagrafica clienti"
    app_version: str = "1.0.0"
    debug: bool = False

    database_url: str                 # obbligatoria: senza, l'avvio fallisce
    secret_key: str                   # obbligatoria
    access_token_expire_minutes: int = 30
    allowed_origins: list[str] = ["http://localhost:3000"]


@lru_cache
def get_settings() -> Settings:
    return Settings()

# Output:
# manca: database_url - missing
# manca: secret_key - missing
# False 30 ['http://localhost:3000']
# ['https://federicocalo.dev', 'https://www.federicocalo.dev']
# SettingsError - error parsing value for field "allowed_origins" from source "EnvSettingsSource"

Tre risultati da leggere. Senza variabili, l'errore elenca tutte le impostazioni mancanti, non solo la prima. Con le due obbligatorie presenti, gli altri campi prendono il default (False, 30, una lista con l'origine locale). E c'è la trappola: una lista in una variabile d'ambiente dev'essere JSON. Scrivere ALLOWED_ORIGINS=https://federicocalo.dev sembra naturale e dà un SettingsError. Il messaggio completo contiene l'indicazione del campo; io ne riporto la prima riga. Se in produzione imposti le origini consentite a mano, ricordati le virgolette e le parentesi quadre.

La struttura di un progetto che cresce

Un solo main.py va bene finché le rotte stanno in una schermata. Appena sono dieci, la separazione che serve è semplice: un APIRouter per ogni dominio (users, items), un modulo di configurazione, e un main.py che li assembla con include_router, assegnando prefisso e tag. Lo schema generale è quello della pagina Bigger Applications. Qui sotto il minimo che gira davvero: tre file, un router e una rotta di salute, con la documentazione spenta o accesa dal flag della configurazione.

# app/routers/users.py
from fastapi import APIRouter

router = APIRouter()


@router.get("/")
def elenco_utenti():
    return [{"id": 1, "nome": "Mario Rossi"}]

# app/main.py
from fastapi import Depends, FastAPI

from app.config import Settings, get_settings
from app.routers import users

settings = get_settings()

app = FastAPI(
    title=settings.app_name,
    version=settings.app_version,
    docs_url="/docs" if settings.debug else None,
    redoc_url="/redoc" if settings.debug else None,
    openapi_url="/openapi.json" if settings.debug else None,
)
app.include_router(users.router, prefix="/users", tags=["users"])


@app.get("/health", tags=["system"])
def health(s: Settings = Depends(get_settings)):
    return {"status": "healthy", "version": s.app_version}

# Output:
# {'status': 'healthy', 'version': '1.0.0'} [{'id': 1, 'nome': 'Mario Rossi'}] 200

La rotta /health è deliberatamente stupida: restituisce lo stato e la versione, non tocca database né servizi esterni. Serve a un bilanciatore o a un controllo di sistema per sapere se il processo risponde. Se la fai dipendere dal database, un'interruzione del database rende «non sani» anche processi che funzionano, e chi li controlla può decidere di scartarli proprio quando servirebbero. Alla struttura aggiungerai, quando servono, uno strato di servizi per la logica di business e uno per l'accesso ai dati: finché non hai due rotte che condividono la stessa logica, sono cartelle vuote.

Quando FastAPI non è la risposta?

Se la tua applicazione è soprattutto pagine HTML con sessioni, form e un'area di amministrazione, un framework che porta tutto questo di serie risparmia lavoro: FastAPI è pensato per costruire API, e le parti che non ha le assembli tu. Se invece il tuo servizio è una API con contratto esplicito, come un endpoint che un front-end o un altro servizio chiama, il contratto nei tipi è esattamente il vantaggio. Il modello asincrono è una seconda scelta da fare con consapevolezza, e ha un articolo a sé: async/await in Python, event loop e coroutine.

Come si validano gli input in modo sicuro?

La validazione di FastAPI garantisce che un campo abbia il tipo e i limiti dichiarati; non garantisce che il suo contenuto sia innocuo. Una stringa di 50 caratteri valida può contenere una query SQL, ed è un problema di come costruisci la query, non di come dichiari il modello. Per quel lato vedi SQL injection e input validation.

Che cosa sai fare adesso

Sai avviare un servizio FastAPI e leggere il contratto che le annotazioni producono: parametri di percorso e di query con i loro limiti, corpo validato da un modello, risposta filtrata da un altro. Sai riconoscere un 422 e dire in quale parte della richiesta sta il difetto. Sai gestire gli errori sapendo che il gestore generico non passa dal CORS e che il TestClient rilancia, sai spegnere la documentazione per intero con openapi_url=None e perché non è una difesa, e sai caricare la configurazione da variabili d'ambiente con il formato giusto per le liste. Il passo successivo, per capire perché FastAPI scala sulle attese di rete, è l'event loop.

Fonti

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti