API REST in Python: response model e serializzazione

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.