Retour au cours

backend / fastapi

Pydantic — validation

Leçon 31 exercice

Explication

Ce que vous allez apprendre

  • Ajouter des contraintes de validation sur un champ avec Field(...)
  • Écrire un field_validator pour transformer ou valider un champ isolé
  • Écrire un model_validator pour valider une relation entre plusieurs champs
  • Rejeter explicitement les champs inconnus d'un payload avec extra="forbid"
  • Construire un modèle de sortie séparé pour ne jamais exposer un champ sensible

Dans quel contexte ?

Sur l'endpoint POST /users de app/routers/users.py, un client envoie {"username": "ab", "email": "pas-un-email", "age": 200}. Sans validation, ce payload pourrait corrompre la logique métier plus loin dans le code (un âge de 200 ans, un email inutilisable pour envoyer une confirmation). Avec un modèle Pydantic UserCreate correctement contraint, FastAPI rejette automatiquement cette requête avec une erreur 422 détaillée, avant même que la fonction de la route ne s'exécute.

Une image pour commencer

Imagine un videur à l'entrée d'une boîte de nuit, qui vérifie l'identité et l'âge de chaque personne AVANT de la laisser entrer. Pydantic joue exactement ce rôle pour les données qui arrivent dans ton application.

Il vérifie leur forme, leurs types, leurs contraintes. Et rejette poliment, avec une erreur claire, tout ce qui ne correspond pas, avant que ce code n'ait la moindre chance de corrompre ta logique métier.

Voyons d'abord le niveau de validation le plus simple : un champ isolé. Field(...) valide sa longueur, ses bornes numériques, ou son format via une regex.

Pour aller plus loin, field_validator permet une logique CUSTOM sur ce même champ. Comme normaliser un nom d'utilisateur en minuscules avant de le stocker.

Il reste un niveau de validation encore plus large : les relations ENTRE plusieurs champs. model_validator(mode="after") s'en charge, par exemple pour s'assurer qu'une date de fin est bien après une date de début.

Une fois ces trois niveaux maîtrisés, une protection souvent sous-estimée mérite d'être connue. Par défaut, Pydantic ignore silencieusement les champs inconnus envoyés dans un payload.

Ça semble inoffensif, mais ça peut masquer une vraie erreur côté client. Une faute de frappe dans un nom de champ passe alors totalement inaperçue.

extra="forbid" transforme ce silence en erreur explicite. Ça aide à détecter des bugs d'intégration tôt plutôt que de les laisser se propager silencieusement.

Niveau de validationOutilExemple
Champ isolé, contrainte simpleField(...)Longueur, bornes numériques, regex
Champ isolé, logique customfield_validatorNormaliser un username en minuscules
Relation entre plusieurs champsmodel_validator(mode="after")end_date après start_date

Il reste un pont important à connaître entre la base de données et l'API. from_attributes=True permet de construire directement un modèle Pydantic à partir d'un objet SQLAlchemy.

Ce point de jonction reviendra concrètement dans les leçons sur SQLAlchemy plus loin dans ce cours. Pour l'instant, retiens simplement qu'il existe.

Piège de sécurité

Créer un modèle de SORTIE séparé du modèle de base de données, comme UserOut, permet de ne JAMAIS exposer un champ sensible (un mot de passe hashé, par exemple) dans une réponse API, même par accident. Ne réutilise jamais directement ton modèle de base de données comme response_model.

Commandes & code

Pydantic — validation

python
from pydantic import BaseModel, EmailStr, Field, field_validator
from datetime import datetime


class UserCreate(BaseModel):
    username: str = Field(min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_]+$")
    email: EmailStr
    age: int = Field(ge=13, le=120)
    bio: str | None = Field(default=None, max_length=500)

    @field_validator("username")
    @classmethod
    def username_lowercase(cls, v: str) -> str:
        return v.lower()  # normalise avant stockage
python
# Validation croisée entre plusieurs champs
from pydantic import model_validator


class DateRange(BaseModel):
    start_date: datetime
    end_date: datetime

    @model_validator(mode="after")
    def check_dates_order(self) -> "DateRange":
        if self.end_date <= self.start_date:
            raise ValueError("end_date doit être postérieure à start_date")
        return self
python
# Types spécialisés Pydantic — validation prête à l'emploi
from pydantic import BaseModel, HttpUrl, PositiveInt, PositiveFloat, conlist


class Product(BaseModel):
    name: str
    price: PositiveFloat
    stock: PositiveInt
    website: HttpUrl | None = None
    tags: conlist(str, min_length=1, max_length=10)  # liste de 1 à 10 éléments
python
# model_config — comportement global du modèle
from pydantic import ConfigDict


class StrictProduct(BaseModel):
    model_config = ConfigDict(
        str_strip_whitespace=True,  # trim automatique des strings
        extra="forbid",              # rejette les champs inconnus du payload
        frozen=True,                  # immuable après création
    )

    name: str
    price: float
python
# Sérialisation contrôlée — exclure des champs sensibles à la réponse
class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    # PAS de champ "hashed_password" ici : jamais exposé côté API

    model_config = ConfigDict(from_attributes=True)  # permet la conversion depuis un objet ORM


# usage avec un objet SQLAlchemy
# user_out = UserOut.model_validate(db_user)
python
# Utilisation directe (hors endpoint) — utile pour comprendre les erreurs de validation
from pydantic import ValidationError

try:
    UserCreate(username="ab", email="pas-un-email", age=200)
except ValidationError as e:
    print(e.errors())
    # [{'type': 'string_too_short', 'loc': ('username',), ...},
    #  {'type': 'value_error', 'loc': ('email',), ...},
    #  {'type': 'less_than_equal', 'loc': ('age',), ...}]

Résumé

  • Field(...) ajoute des contraintes (longueur, bornes numériques, regex) directement sur les types.
  • field_validator valide/transforme un champ isolé, model_validator(mode="after") valide entre plusieurs champs.
  • extra="forbid" rejette les champs inattendus — utile pour détecter des erreurs client tôt.
  • from_attributes=True permet de construire un modèle Pydantic directement depuis un objet ORM SQLAlchemy.

Exercices pratiques

1 disponible
1

Mission : le compte utilisateur invalide qui passe quand même

Objectif : Corriger un modèle Pydantic UserCreate trop permissif qui laisse passer des payloads incohérents sur POST /users.

Contexte

Sur POST /users de app/routers/users.py, un client a réussi à créer un compte avec {"username": "ab", "email": "pas-un-email", "age": 200, "role": "superadmin"}. Le champ role n'existe même pas dans le modèle mais n'a provoqué aucune erreur, et un age de 200 ans a été accepté tel quel.

Résoudre l’exercice →