API REST in Python: response model e serializzazione
Nella puntata precedente restituivamo direttamente il modello tabella Book, esponendo la struttura grezza della sua rappresentazione interna. In questa puntata introduciamo uno strato di trasformazione basato sui modelli Pydantic e sul parametro response_model. Vedremo come separare il modello di lettura dal modello tabella, come dare alle risposte una forma pulita e stabile, come includere campi calcolati o condizionali e come gestire la paginazione delle collezioni con i loro metadati. Al termine il nostro catalogo restituirà JSON progettato per il client, non un semplice riflesso del database.
Perché non restituire il modello tabella
Serializzare direttamente il modello con table=True ha diversi difetti. Espone il nome esatto delle colonne, legando il contratto pubblico dell'API ai dettagli interni dello schema: se un domani rinominassimo o aggiungessimo una colonna, il cambiamento trapelerebbe subito verso i client. Rende difficile nascondere campi sensibili o mostrarne di calcolati. Non offre un punto centralizzato dove decidere il formato di date e numeri. La soluzione è definire modelli dedicati all'input e all'output, distinti dal modello di persistenza, e dichiararli a FastAPI tramite response_model: il modello tabella resta libero di evolvere, mentre i modelli di schema definiscono con precisione il contratto verso l'esterno.
Separare i modelli con SQLModel
SQLModel incoraggia una separazione elegante. Definiamo una classe base con i campi comuni, senza table=True, e ne facciamo derivare sia il modello tabella sia i modelli di schema. Riorganizziamo app/models.py in questo modo.
from datetime import datetime, timezone
from sqlmodel import Field, SQLModel
class BookBase(SQLModel):
# Campi condivisi da input e output; non è una tabella
title: str
author: str
year: int | None = None
isbn: str | None = None
class Book(BookBase, table=True):
# Il modello di persistenza aggiunge i campi gestiti dal database
id: int | None = Field(default=None, primary_key=True)
isbn: str | None = Field(default=None, unique=True, index=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class BookCreate(BookBase):
# Modello di input per la creazione: gli stessi campi della base
pass
class BookUpdate(SQLModel):
# Modello di input per l'aggiornamento: tutti i campi facoltativi
title: str | None = None
author: str | None = None
year: int | None = None
isbn: str | None = None
class BookRead(BookBase):
# Modello di output: definisce esattamente cosa vede il client
id: int
created_at: datetime
Abbiamo ora quattro classi con ruoli distinti: Book è la tabella, BookCreate e BookUpdate descrivono i dati in ingresso, BookRead descrive i dati in uscita. Notiamo che BookRead espone id e created_at ma, potendo scegliere, potrebbe ometterne o riformattarne altri: la risposta è una decisione deliberata, non un effetto collaterale della struttura della tabella.
Dichiarare il response_model
Colleghiamo i modelli alle rotte. Il parametro response_model del decoratore dice a FastAPI quale forma deve avere la risposta: il framework filtra l'oggetto restituito attraverso quel modello, scartando i campi non previsti e validando quelli presenti. Contestualmente sostituiamo la lettura del corpo grezzo con i modelli di input, che FastAPI popola e valida automaticamente a partire dal JSON della richiesta.
from fastapi import APIRouter, Depends, status
from sqlmodel import Session, select
from app.database import get_session
from app.models import Book, BookCreate, BookRead, BookUpdate
router = APIRouter(prefix="/books", tags=["books"])
@router.get("", response_model=list[BookRead])
def index(session: Session = Depends(get_session)):
return session.exec(select(Book).order_by(Book.id.desc())).all()
@router.post("", response_model=BookRead, status_code=status.HTTP_201_CREATED)
def store_book(data: BookCreate, session: Session = Depends(get_session)):
# data è già validato e tipizzato da FastAPI
book = Book.model_validate(data)
session.add(book)
session.commit()
session.refresh(book)
return book
@router.get("/{book_id}", response_model=BookRead)
def show(book: Book = Depends(get_book_or_404)):
return book
@router.put("/{book_id}", response_model=BookRead)
def update_book(
data: BookUpdate,
book: Book = Depends(get_book_or_404),
session: Session = Depends(get_session),
):
# exclude_unset applica solo i campi effettivamente inviati dal client
for field, value in data.model_dump(exclude_unset=True).items():
setattr(book, field, value)
session.add(book)
session.commit()
session.refresh(book)
return book
Diversi cambiamenti meritano un commento. Nella creazione, data è un parametro tipizzato come BookCreate: FastAPI capisce che deve leggerlo dal corpo della richiesta, validarlo e passarcelo già pronto. Costruiamo poi il record con Book.model_validate(data), che converte il modello di input nel modello tabella. Nell'aggiornamento usiamo model_dump(exclude_unset=True): restituisce solo i campi che il client ha effettivamente inviato, permettendo aggiornamenti parziali senza azzerare i campi omessi. Infine, grazie a response_model=BookRead, qualunque cosa restituiamo viene filtrata attraverso il modello di lettura prima di raggiungere il client.
Nascondere e formattare i campi
Poiché il client vede soltanto ciò che BookRead dichiara, nascondere un campo interno è banale: basta non includerlo nel modello di lettura. Se la tabella avesse una colonna internal_notes a uso interno, la ometteremmo da BookRead e non comparirebbe mai nelle risposte, pur restando disponibile nel modello tabella. Allo stesso modo possiamo aggiungere campi calcolati che non esistono nel database, usando il decoratore computed_field di Pydantic.
from pydantic import computed_field
class BookRead(BookBase):
id: int
created_at: datetime
@computed_field
@property
def citation(self) -> str:
# Campo derivato, calcolato al momento della serializzazione
year = self.year if self.year is not None else "s.d."
return f"{self.author}, {self.title} ({year})"
Il campo citation non corrisponde ad alcuna colonna: viene calcolato ogni volta che il libro viene serializzato, offrendo al client una stringa pronta all'uso senza inquinare la tabella.
Escludere i valori nulli
Per impostazione predefinita FastAPI include nella risposta anche i campi con valore None. In certe API si preferisce ometterli, per alleggerire il payload. Il comportamento si controlla per singola rotta con response_model_exclude_none.
@router.get("/{book_id}", response_model=BookRead, response_model_exclude_none=True)
def show(book: Book = Depends(get_book_or_404)):
return book
Con questa impostazione, un libro privo di ISBN non riporterà affatto la chiave isbn nella risposta, anziché riportarla con valore nullo. È una scelta di stile: l'importante è essere coerenti in tutta l'API.
Paginare le collezioni
Restituire tutti i libri in un colpo solo non è sostenibile quando la tabella cresce. Introduciamo la paginazione basata su limit e offset, leggendo i parametri dalla query string, e avvolgiamo il risultato in un modello che accosta ai dati i metadati di navigazione. Definiamo un modello di pagina generico in app/schemas.py.
from pydantic import BaseModel
class PageMeta(BaseModel):
total: int
page: int
per_page: int
pages: int
class Page[T](BaseModel):
# Modello generico: la stessa struttura serve per qualsiasi risorsa
data: list[T]
meta: PageMeta
La sintassi Page[T] definisce un modello generico: potremo usarlo con qualsiasi tipo, scrivendo Page[BookRead] per una pagina di libri. Modifichiamo la rotta di elenco per calcolare il totale, applicare limite e scostamento e comporre la pagina.
from fastapi import Query
from sqlmodel import func
from app.schemas import Page, PageMeta
@router.get("", response_model=Page[BookRead])
def index(
session: Session = Depends(get_session),
page: int = Query(default=1, ge=1),
per_page: int = Query(default=15, ge=1, le=100),
):
# Conta totale dei record per calcolare il numero di pagine
total = session.exec(select(func.count()).select_from(Book)).one()
offset = (page - 1) * per_page
books = session.exec(
select(Book).order_by(Book.id.desc()).offset(offset).limit(per_page)
).all()
pages = (total + per_page - 1) // per_page # arrotondamento per eccesso
meta = PageMeta(total=total, page=page, per_page=per_page, pages=pages)
return Page(data=books, meta=meta)
I vincoli sui parametri di query — ge=1 per la pagina, le=100 per la dimensione — sono già una forma di validazione: impediscono al client di chiedere una pagina zero o un milione di elementi, e FastAPI li documenta nella pagina interattiva. La risposta ha ora una struttura prevedibile.
{
"data": [
{
"id": 42,
"title": "Il nome della rosa",
"author": "Umberto Eco",
"year": 1980,
"isbn": "978-88-452-0705-0",
"created_at": "2026-03-17T09:12:44+00:00"
}
],
"meta": {
"total": 60,
"page": 1,
"per_page": 15,
"pages": 4
}
}
Il client ha così tutto ciò che serve per navigare fra le pagine, e la stessa struttura Page potrà avvolgere qualsiasi altra risorsa introdurremo in futuro, mantenendo uniforme la forma delle collezioni in tutta l'API.
Conclusione
Le nostre risposte hanno ora una forma professionale e sotto controllo. Separando i modelli di input e output dal modello tabella e dichiarandoli con response_model, abbiamo scollegato il contratto pubblico dell'API dalla struttura interna del database. Abbiamo visto come nascondere campi, aggiungerne di calcolati con computed_field, omettere i valori nulli e gestire la paginazione con un modello generico che accosta dati e metadati. Il client riceve JSON progettato per lui, e il nostro schema resta libero di evolvere senza rompere l'interfaccia.
Restano però ancora aperte le porte a dati malformati: pur avendo tipizzato l'input, non abbiamo ancora imposto vincoli sui valori, né gestito casi come un ISBN duplicato. Nella prossima puntata approfondiremo la validazione con Pydantic, aggiungendo vincoli sui campi, validatori personalizzati e una gestione ordinata degli errori di validazione.