Module 2 — Validation des entrées avec Pydantic
Le module 1 a livré une application qui répond à /health et à / sans se
poser de questions sur ce qu'elle reçoit. Le service de résiliation, lui,
doit accepter un dossier d'abonné avec au moins vingt champs, en refuser un
sans contract_type ou avec age = -3, et le dire de façon claire à
l'appelant. Cette responsabilité, on ne l'écrit pas à la main : on la
délègue à Pydantic, la bibliothèque qui alimente FastAPI.
Pourquoi une validation stricte, dès la porte d'entrée
Un modèle d'apprentissage est une fonction f(x) définie sur un espace X
précis. Si on lui donne un x en dehors de X — une catégorie inconnue,
une valeur négative pour une durée, une chaîne là où on attend un nombre —
il produira quand même un score, parce que la plupart des chaînes de
prétraitement ne lèvent pas d'exception : elles encodent l'entrée
silencieusement et livrent un résultat aussi silencieusement absurde.
Le pire est que ce score aura l'air d'un vrai score, arrivera dans le tableau de bord Streamlit du cours 38, et déclenchera peut-être un appel commercial vers un abonné qui n'existe pas. La validation à la porte d'entrée est donc la première brique de sûreté de l'API. Elle sépare « mauvaise requête » (à refuser avec 422 ou 400, module 5) et « bonne requête, réponse produite ».
Un modèle de requête et un modèle de réponse
Pydantic v2 s'utilise via la classe BaseModel. On décrit d'abord ce que
l'appelant doit envoyer, puis ce que le service renvoie.
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field
class DossierAbonne(BaseModel):
abonne_id: int = Field(..., ge=1, description="Identifiant interne, jamais un numéro de téléphone.")
age: int = Field(..., ge=18, le=120)
anciennete_mois: int = Field(..., ge=0, le=600)
forfait: Literal["prépayé", "postpayé", "entreprise"]
revenu_moyen_mensuel_eur: float = Field(..., ge=0, le=100_000)
nb_reclamations_12m: int = Field(..., ge=0, le=200)
date_dernier_paiement: date
class ReponsePrediction(BaseModel):
abonne_id: int
probabilite_resiliation: float = Field(..., ge=0.0, le=1.0)
seuil_utilise: float
decision: Literal["à_contacter", "à_surveiller", "sans_action"]
version_modele: str
Les contraintes ne sont pas décoratives : ge (greater or equal), le
(less or equal), Literal[...] et l'annotation date produisent chacune un
rejet en 422 avec un message précis quand la valeur envoyée ne convient
pas. Le service ne verra jamais un âge de 4 000 ans ni un forfait
"gold-platinum" inconnu du modèle.
Brancher les modèles sur la route
Il suffit d'annoter le paramètre et le retour de la fonction.
from fastapi import FastAPI
app = FastAPI()
@app.post("/predict", response_model=ReponsePrediction)
def predict(dossier: DossierAbonne) -> ReponsePrediction:
proba = 0.42 # remplacé au module 4 par model.predict_proba(...)
decision = "à_contacter" if proba >= 0.5 else "à_surveiller"
return ReponsePrediction(
abonne_id=dossier.abonne_id,
probabilite_resiliation=proba,
seuil_utilise=0.5,
decision=decision,
version_modele="churn-2026-08-30",
)
FastAPI voit dossier: DossierAbonne et fait trois choses avant que la
fonction ne s'exécute : il analyse le corps JSON, il valide chaque champ
contre le modèle, et il refuse l'appel avec un code 422 si un champ manque
ou dépasse ses bornes. Après l'exécution, il valide aussi la réponse
contre ReponsePrediction grâce au paramètre response_model : un modèle
qui renverrait une probabilité de 1.03 ferait planter la validation côté
service, jamais côté appelant — c'est un filet de sécurité qui attrape les
régressions de code.
Un exemple visible dans Swagger
Rien de plus décourageant, pour l'équipe qui consomme l'API, qu'un schéma
sans exemple. On ajoute un exemple concret directement dans Field, ou via
model_config.
from pydantic import BaseModel, Field, ConfigDict
class DossierAbonne(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"abonne_id": 12345,
"age": 42,
"anciennete_mois": 36,
"forfait": "postpayé",
"revenu_moyen_mensuel_eur": 45.90,
"nb_reclamations_12m": 2,
"date_dernier_paiement": "2026-08-15",
}
}
)
# ... champs comme plus haut
L'appelant qui ouvre /docs voit un JSON prêt à copier, ce qui divise par
dix le temps de la première intégration.
Le message 422 en détail
Quand une entrée est refusée, FastAPI renvoie un JSON structuré. Un
age = -3 donne :
{
"detail": [
{
"type": "greater_than_equal",
"loc": ["body", "age"],
"msg": "Input should be greater than or equal to 18",
"input": -3,
"ctx": {"ge": 18}
}
]
}
loc désigne le chemin exact du champ fautif, type la règle enfreinte,
input la valeur reçue. C'est utilisable côté appelant sans avoir à
analyser une phrase en anglais : on lit loc et type, on affiche à
l'utilisateur « âge invalide », on continue. Le module 5 explique quand on
préfère renvoyer 400 (mauvaise requête sémantique) plutôt que 422 (schéma
invalide) et comment personnaliser ces réponses.
Validateurs métier : ce que Pydantic ne devine pas
Les contraintes numériques et les Literal[...] couvrent la plupart des
cas. Restent les règles métier : « la date du dernier paiement ne peut pas
être dans le futur », « un forfait entreprise implique un revenu_moyen
supérieur à 100 €ur ». On les écrit avec des validateurs.
from pydantic import BaseModel, field_validator, model_validator
from datetime import date
class DossierAbonne(BaseModel):
# ... champs
@field_validator("date_dernier_paiement")
@classmethod
def paiement_pas_dans_le_futur(cls, v: date) -> date:
if v > date.today():
raise ValueError("date_dernier_paiement doit être passée")
return v
@model_validator(mode="after")
def coherence_forfait_revenu(self) -> "DossierAbonne":
if self.forfait == "entreprise" and self.revenu_moyen_mensuel_eur < 100:
raise ValueError("un forfait entreprise implique un revenu moyen supérieur à 100 EUR")
return self
Un ValueError levé dans un validateur devient un 422 avec le même format
que ci-dessus. On ne lève jamais de HTTPException dans un validateur
Pydantic : ce serait mélanger la couche schéma et la couche transport, et
FastAPI n'attend pas ce type d'exception à cet endroit.
Réponses par lots : un modèle qui contient une liste
Pour la route par lots (module 4), on décrit une requête qui contient une liste, plutôt qu'une simple liste : on garde une place pour la version d'API demandée, un identifiant de lot, une taille maximale.
class RequeteLot(BaseModel):
lot_id: str = Field(..., min_length=1, max_length=64)
dossiers: list[DossierAbonne] = Field(..., min_length=1, max_length=5_000)
class ReponseLot(BaseModel):
lot_id: str
version_modele: str
resultats: list[ReponsePrediction]
Cette forme encapsulée résiste à l'évolution : ajouter mode: Literal["strict", "tolerant"] à RequeteLot ne casse aucun appelant existant, alors qu'ajouter
un champ à une liste anonyme est impossible.
Une erreur courante consiste à réutiliser directement la classe utilisée pour
l'entraînement du modèle (class Sample) comme modèle Pydantic. Le
prétraitement, le mappage des catégories, la version du schéma appartiennent
au monde interne ; le contrat public est plus étroit et plus stable. On
sépare DossierAbonne (public) et l'objet interne consommé par le modèle
(module 4).
En résumé
- Pydantic valide le corps JSON avant que la fonction ne s'exécute : un champ manquant ou hors bornes déclenche 422, jamais 200 avec un score absurde.
- On sépare modèle de requête et modèle de réponse, tous deux
déclarés sur la route via l'annotation et
response_model. - Les contraintes
ge,le,min_length,max_length,Literal[...]et les validateurs métier remplacent des dizaines de lignes deifdéfensifs. - Un exemple concret dans
model_configdivise par dix le temps de la première intégration côté appelant.
Le module 3 s'occupe de la brique la plus lourde : charger le modèle une seule fois au démarrage plutôt qu'à chaque requête.