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 condocs_url=None./redoc: ReDoc, una lettura più da manuale; si spegne conredoc_url=None./openapi.json: lo schema leggibile dalle macchine, da cui si generano i client; si spegne conopenapi_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
- FastAPI - First Steps - le tre rotte della documentazione generata (
/docs,/redoc,/openapi.json), consultata il 2026-11-12 - FastAPI - Metadata and Docs URLs - i parametri
contact,license_infoeopenapi_url=None, consultata il 2026-11-12 - FastAPI - Handling Errors -
HTTPException, i gestori personalizzati e la risposta 422, consultata il 2026-11-12 - FastAPI - Settings and Environment Variables -
BaseSettings, la dipendenza elru_cache, consultata il 2026-11-12 - FastAPI - Bigger Applications -
APIRoutereinclude_router, consultata il 2026-11-12 - Starlette - Exceptions - l'ordine dello stack di middleware, con
ServerErrorMiddlewareall'esterno, consultata il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare