backend / fastapi
Validation avancée avec Pydantic v2
Explication
Ce que vous allez apprendre
- Distinguer
BeforeValidatoretAfterValidatorselon 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=Truequand 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.
| Validateur | S'exécute | Cas d'usage |
|---|---|---|
BeforeValidator | Avant la conversion de type | Nettoyer une chaîne brute (trim, casse) |
AfterValidator | Après la conversion de type | Règle métier sur une valeur déjà typée |
field_validator + ValidationInfo.data | Pendant la validation, avec accès aux champs déjà validés | Comparer 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
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# 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)]# 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# 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# 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# 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) -> OKRésumé
BeforeValidator/AfterValidatorcomposés viaAnnotatedcréent des types métier réutilisables et testables isolément.ValidationInfo.datadonne accès aux champs déjà validés lors de la validation d'un champ suivant.field_serializercontrô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
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é.