Retour au cours

backend / fastapi

Validation avancée avec Pydantic v2

Leçon 201 exercice

Explication

Ce que vous allez apprendre

  • Distinguer BeforeValidator et AfterValidator selon le moment où ils s'exécutent
  • Créer un type métier réutilisable et composable avec Annotated
  • Accéder aux autres champs déjà validés via ValidationInfo.data
  • Comprendre l'usage et les limites de la validation contextuelle
  • Activer le mode strict=True quand une API doit refuser toute conversion implicite de type

Dans quel contexte ?

Sur le modèle PasswordChange de app/schemas/auth.py, un utilisateur peut envoyer un confirm_password différent de new_password sans que l'API ne le détecte, si la validation ne compare que chaque champ isolément. Utiliser ValidationInfo.data dans le field_validator de confirm_password permet d'accéder à la valeur déjà validée de new_password et de rejeter la requête si les deux ne correspondent pas, avant même que le mot de passe ne soit modifié en base.

Au-delà de la validation champ par champ

Les leçons précédentes sur Pydantic couvraient des contraintes sur un champ isolé, comme sa longueur. Mais des besoins réels dépassent souvent ce cadre.

Vérifier que deux champs sont cohérents entre eux, ou transformer une donnée AVANT de la valider, en sont deux exemples. Voyons d'abord un détail important : l'ordre des validateurs compte.

Un BeforeValidator s'exécute AVANT que Pydantic ne tente de convertir la valeur dans le type attendu. Utile pour nettoyer une donnée brute, comme des espaces, avant même de vérifier son format.

Un AfterValidator, à l'inverse, s'exécute une fois que le type de base est déjà validé. Pour ajouter une règle métier supplémentaire, comme un format de téléphone précis.

Combinés via Annotated, ils créent des types métier réutilisables dans plusieurs modèles. Sans dupliquer la logique de validation à chaque fois qu'on en a besoin.

Une fois ces validateurs maîtrisés, une limite se pose : un validateur ne voit par défaut que la valeur du champ qu'il valide. ValidationInfo.data résout ce problème.

Il donne accès aux champs DÉJÀ validés au moment où ce validateur s'exécute. Ça permet des vérifications croisées, comme "confirm_password doit être égal à new_password".

Il existe un outil encore plus flexible, mais à utiliser avec parcimonie : la validation contextuelle. Passer un context à model_validate() permet à un même modèle de valider différemment selon la situation d'appel.

C'est puissant, par exemple pour réserver la modification d'un prix aux administrateurs. Mais ça rend la logique de validation moins prévisible en la lisant isolément, à réserver aux cas où cette flexibilité est réellement nécessaire.

ValidateurS'exécuteCas d'usage
BeforeValidatorAvant la conversion de typeNettoyer une chaîne brute (trim, casse)
AfterValidatorAprès la conversion de typeRègle métier sur une valeur déjà typée
field_validator + ValidationInfo.dataPendant la validation, avec accès aux champs déjà validésComparer deux champs entre eux

Piège fréquent

Par défaut, Pydantic est plutôt permissif : une chaîne "5" peut être acceptée là où un entier est attendu, à cause de la conversion automatique de type. Le mode strict=True désactive cette tolérance quand une API doit être rigoureusement exigeante sur les types reçus.

Commandes & code

Validation avancée avec Pydantic v2

python
from pydantic import BaseModel, field_validator, model_validator, ValidationInfo
from typing import Any


# field_validator avec accès aux autres champs déjà validés via ValidationInfo
class PasswordChange(BaseModel):
    new_password: str
    confirm_password: str

    @field_validator("confirm_password")
    @classmethod
    def passwords_match(cls, v: str, info: ValidationInfo) -> str:
        if "new_password" in info.data and v != info.data["new_password"]:
            raise ValueError("Les mots de passe ne correspondent pas")
        return v
python
# Validateurs "before" — transforment les données brutes AVANT la validation de type
from pydantic import BeforeValidator
from typing import Annotated


def strip_and_lower(v: Any) -> Any:
    if isinstance(v, str):
        return v.strip().lower()
    return v


class Contact(BaseModel):
    email: Annotated[str, BeforeValidator(strip_and_lower)]
python
# Types custom réutilisables avec Annotated — validation composable
from pydantic import AfterValidator
import re

def validate_phone(v: str) -> str:
    if not re.match(r"^\+33[67]\d{8}$", v):
        raise ValueError("Numéro de mobile français invalide (format +33612345678)")
    return v

PhoneNumber = Annotated[str, AfterValidator(validate_phone)]


class Contact2(BaseModel):
    name: str
    phone: PhoneNumber  # réutilisable dans n'importe quel modèle sans dupliquer la logique
python
# Sérialisation custom — field_serializer pour contrôler le format de sortie
from pydantic import field_serializer
from decimal import Decimal


class Invoice(BaseModel):
    amount: Decimal

    @field_serializer("amount")
    def serialize_amount(self, value: Decimal) -> str:
        return f"{value:.2f} €"  # ex. "49.99 €" au lieu du Decimal brut en JSON
python
# Validation contextuelle — comportement différent selon le contexte d'appel
class ProductUpdate(BaseModel):
    name: str | None = None
    price: float | None = None

    @model_validator(mode="after")
    def check_admin_only_fields(self, info: ValidationInfo) -> "ProductUpdate":
        context = info.context or {}
        if self.price is not None and not context.get("is_admin", False):
            raise ValueError("Seul un admin peut modifier le prix")
        return self


# Utilisation avec contexte dans un endpoint
@router.patch("/products/{id}")
def update_product(id: int, data: dict, current_user: Annotated[User, Depends(get_current_user)]):
    validated = ProductUpdate.model_validate(data, context={"is_admin": current_user.role == "admin"})
    return validated
python
# Validation stricte vs permissive — strict=True refuse la coercition implicite de type
class StrictOrder(BaseModel):
    quantity: int

    model_config = {"strict": True}

# StrictOrder(quantity="5")  -> lève une erreur (pas de coercition string -> int)
# StrictOrder(quantity=5)    -> OK

Résumé

  • BeforeValidator/AfterValidator composés via Annotated créent des types métier réutilisables et testables isolément.
  • ValidationInfo.data donne accès aux champs déjà validés lors de la validation d'un champ suivant.
  • field_serializer contrôle précisément le format de sortie JSON, indépendamment du type Python interne.
  • model_validate(data, context={...}) permet une validation dont le comportement dépend du contexte d'appel (ex. rôle utilisateur).

Exercices pratiques

1 disponible
1

Mission : le mot de passe qui ne se vérifie plus jamais

Objectif : Diagnostiquer un field_validator devenu inopérant à cause de l'ordre de déclaration des champs, puis le remplacer par une validation robuste à cet ordre.

Contexte

Un développeur réorganise PasswordChange dans app/schemas/auth.py par ordre alphabétique et place confirm_password avant new_password. Depuis ce changement purement cosmétique, l'audit de sécurité remarque qu'un utilisateur peut envoyer un confirm_password complètement différent de new_password sans provoquer aucune erreur de validation, alors que le field_validator sur confirm_password utilisant ValidationInfo.data n'a pas été touché.

Résoudre l’exercice →