API REST in Python: validazione con Pydantic

API REST in Python: validazione con Pydantic

Nella puntata precedente abbiamo tipizzato l'input con i modelli, ma tipizzare non basta: un titolo può essere una stringa e al tempo stesso essere vuoto, un anno può essere un intero e al tempo stesso essere impossibile. In questa puntata introduciamo la validazione vera e propria. Vedremo come imporre vincoli sui campi con Field, come scrivere validatori personalizzati con field_validator e model_validator, come FastAPI restituisca automaticamente errori strutturati con il codice 422, come normalizzare i dati in ingresso e come gestire in modo pulito un vincolo di unicità violato. La validazione in FastAPI è affidata a Pydantic, quindi tutto ciò che vedremo vale per qualunque modello.

Vincoli sui campi con Field

Il modo più diretto per vincolare un campo è la funzione Field, che accetta parametri come la lunghezza minima e massima di una stringa o i limiti inferiore e superiore di un numero. Arricchiamo i modelli di input in app/models.py.

from sqlmodel import Field, SQLModel


class BookBase(SQLModel):
    # min_length evita stringhe vuote; max_length limita la dimensione
    title: str = Field(min_length=1, max_length=255)
    author: str = Field(min_length=1, max_length=255)
    # ge e le impongono un intervallo plausibile per l'anno
    year: int | None = Field(default=None, ge=1450, le=2100)
    isbn: str | None = Field(default=None, max_length=20)

Questi vincoli valgono per ogni modello che eredita da BookBase, quindi sia in creazione sia, dove i campi sono presenti, in aggiornamento. Da questo momento una richiesta con un titolo vuoto o un anno fuori intervallo verrà respinta prima ancora di raggiungere il nostro codice.

La risposta di errore automatica

Quando la validazione fallisce, FastAPI produce automaticamente una risposta con codice 422 Unprocessable Content e un corpo che descrive gli errori uno per uno. Proviamo a creare un libro senza titolo e con un anno impossibile.

curl -i -X POST http://127.0.0.1:8000/api/books \
  -H "Content-Type: application/json" \
  -d '{"author":"Anonimo","year":3000}'

La risposta avrà codice 422 e un corpo con la chiave detail, che raccoglie la lista degli errori, ciascuno con la posizione del campo, un messaggio leggibile e il tipo di violazione.

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "title"],
      "msg": "Field required",
      "input": {"author": "Anonimo", "year": 3000}
    },
    {
      "type": "less_than_equal",
      "loc": ["body", "year"],
      "msg": "Input should be less than or equal to 2100",
      "input": 3000
    }
  ]
}

La chiave loc indica dove si trova il problema — nel corpo, campo title — permettendo a un frontend di collegare ogni messaggio all'input corrispondente. Ottenere tutto questo senza scrivere una riga di gestione degli errori è uno dei motivi per cui FastAPI è così produttivo. Nella settima puntata vedremo come uniformare questa risposta al resto degli errori dell'API; per ora ci basta sapere che la struttura è coerente e prevedibile.

Validatori personalizzati con field_validator

Quando i vincoli dichiarativi non bastano, possiamo scrivere validatori personalizzati. Il decoratore field_validator collega una funzione a uno o più campi: riceve il valore, lo controlla e o lo restituisce (eventualmente trasformato) o solleva un errore. Aggiungiamo un controllo sulla forma dell'ISBN.

from pydantic import field_validator
from sqlmodel import Field, SQLModel


class BookBase(SQLModel):
    title: str = Field(min_length=1, max_length=255)
    author: str = Field(min_length=1, max_length=255)
    year: int | None = Field(default=None, ge=1450, le=2100)
    isbn: str | None = Field(default=None, max_length=20)

    @field_validator("isbn")
    @classmethod
    def validate_isbn(cls, value: str | None) -> str | None:
        if value is None:
            return value
        # Rimuove trattini e spazi, poi verifica che restino 13 cifre
        digits = value.replace("-", "").replace(" ", "")
        if not digits.isdigit() or len(digits) != 13:
            raise ValueError("L'ISBN deve essere un ISBN-13 valido.")
        return digits

Due aspetti sono notevoli. Primo, il validatore non si limita a controllare: restituisce il valore ripulito, con trattini e spazi rimossi, così nel database salveremo sempre l'ISBN in forma normalizzata. Secondo, l'errore sollevato con ValueError viene raccolto da Pydantic e confluisce nella stessa risposta 422 strutturata vista prima, con il messaggio che abbiamo scritto. Non dobbiamo fare nulla di speciale perché appaia nel posto giusto.

Validazione che coinvolge più campi

Alcune regole non riguardano un singolo campo ma la loro relazione. Per questi casi si usa model_validator, che riceve l'intero modello dopo che i singoli campi sono stati validati. Supponiamo di voler impedire che l'anno sia successivo all'anno corrente, una regola che dipende dal contesto e non da un limite fisso.

from datetime import datetime, timezone

from pydantic import model_validator


class BookBase(SQLModel):
    # ... campi come sopra ...

    @model_validator(mode="after")
    def check_year_not_in_future(self):
        current_year = datetime.now(timezone.utc).year
        if self.year is not None and self.year > current_year:
            raise ValueError("L'anno di pubblicazione non può essere nel futuro.")
        return self

La modalità after indica che il validatore viene eseguito dopo la costruzione del modello, quando tutti i campi sono già disponibili e convertiti. Restituiamo self per confermare che il modello è valido. Anche in questo caso l'eventuale errore confluisce nella risposta 422.

Normalizzare i dati prima della validazione

A volte i dati vanno ripuliti prima ancora di essere validati: rimuovere spazi superflui agli estremi, uniformare maiuscole e minuscole. La modalità before di model_validator interviene sui dati grezzi in ingresso, prima che i vincoli dei campi vengano applicati.

@model_validator(mode="before")
@classmethod
def normalize(cls, data):
    # Interviene sul dizionario grezzo prima della validazione dei campi
    if isinstance(data, dict):
        for key in ("title", "author"):
            if isinstance(data.get(key), str):
                data[key] = data[key].strip()
    return data

Con questo accorgimento un titolo inviato con spazi iniziali o finali viene ripulito prima che scatti il controllo min_length, evitando che una stringa fatta di soli spazi superi la validazione per poi risultare vuota nei fatti.

Gestire l'unicità dell'ISBN

C'è un vincolo che Pydantic non può verificare da solo: l'unicità dell'ISBN, che dipende dallo stato del database. Abbiamo dichiarato la colonna unique nel modello tabella, quindi il database rifiuterà un duplicato, ma lo farà sollevando un'eccezione tecnica, IntegrityError, che senza intervento si tradurrebbe in un errore 500. Intercettiamola nella rotta di creazione e traduciamola in un errore chiaro con codice 409 Conflict.

from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError


@router.post("", response_model=BookRead, status_code=status.HTTP_201_CREATED)
def store_book(data: BookCreate, session: Session = Depends(get_session)):
    book = Book.model_validate(data)
    session.add(book)
    try:
        session.commit()
    except IntegrityError:
        session.rollback()
        # 409 Conflict: la richiesta è valida ma confligge con lo stato attuale
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Esiste già un libro con questo ISBN.",
        )
    session.refresh(book)
    return book

Il rollback annulla la transazione fallita, riportando la sessione a uno stato utilizzabile. Affidare l'unicità al database, anziché controllarla prima con un'interrogazione, evita una sottile condizione di corsa: fra il controllo e l'inserimento, un'altra richiesta potrebbe inserire lo stesso ISBN. Il vincolo del database è l'unica garanzia realmente atomica, e intercettarne la violazione è il modo corretto di gestirla.

Riusare la validazione con tipi annotati

Quando lo stesso vincolo ricorre in più modelli, conviene definirlo una volta sola come tipo riutilizzabile, sfruttando Annotated. Possiamo creare un alias di tipo per il titolo e usarlo ovunque serva.

from typing import Annotated

from pydantic import Field as PydanticField

# Tipo riutilizzabile: una stringa non vuota di al più 255 caratteri
NonEmptyTitle = Annotated[str, PydanticField(min_length=1, max_length=255)]


class BookBase(SQLModel):
    title: NonEmptyTitle
    # ... resto invariato ...

Questo approccio centralizza la definizione del vincolo: se un domani decidessimo che i titoli possono arrivare a 300 caratteri, cambieremmo un punto solo e la modifica si propagherebbe a ogni modello che usa quel tipo.

Conclusione

La nostra API non accetta più dati arbitrari. Abbiamo imposto vincoli sui campi con Field, scritto validatori personalizzati per singolo campo e per l'intero modello, normalizzato i dati in ingresso e visto come FastAPI produca automaticamente risposte di errore 422 strutturate e leggibili. Abbiamo inoltre gestito correttamente l'unicità dell'ISBN affidandola al database e traducendone la violazione in un pulito 409, e imparato a riusare i vincoli con i tipi annotati.

La nostra API è ora robusta, ma ancora completamente aperta: chiunque può creare, modificare ed eliminare libri. Nella prossima puntata colmeremo questa lacuna introducendo l'autenticazione con token JWT e l'autorizzazione basata sulla proprietà delle risorse, così da controllare chi può accedere all'API e quali operazioni può compiere.