1. Contrats stricts¶
Règle. Toute donnée qui traverse une frontière (HTTP, DB, file de messages, appel inter-service) est décrite par un schéma Pydantic v2
StrictSchema—extra="forbid",frozen=True,with_changes()qui re-valide.
Pourquoi¶
Pydantic en mode par défaut est trop permissif pour une frontière de service. Trois pièges classiques :
extra="ignore"(par défaut) — un client envoie{"name": "x", "is_admin": true}; votre code ignoreis_adminsilencieusement. Six mois plus tard, un nouveau champis_adminapparaît côté modèle et un attaquant prend la main.- Mutabilité — un service partage un objet Pydantic entre deux
workers async. L'un modifie
obj.x, l'autre lit. Course conditionnelle non détectée. model_copy(update=...)qui ne re-valide pas — vous appliquez une mutation logique qui invalide la contrainte d'un champ ; Pydantic accepte.
Ce que StrictSchema impose¶
from mic.contracts import StrictSchema
class CreateMailPayload(StrictSchema):
recipient: str
subject: str
body: str
max_attempts: int = 3
| Comportement | Résultat |
|---|---|
CreateMailPayload(recipient="x@y.z", subject="s", body="b") |
OK |
CreateMailPayload(recipient="x@y.z", subject="s", body="b", admin=True) |
ValidationError (extra field forbidden) |
payload.recipient = "other@y.z" |
ValidationError (frozen) |
payload.with_changes(recipient="other@y.z") |
OK, retourne un nouvel objet re-validé |
payload.with_changes(max_attempts=-1) |
ValidationError (re-validé) |
with_changes(...) est différent de model_copy(update=...) Pydantic :
il re-construit l'objet via model_validate() au lieu de patcher
directement les attributs. Toute contrainte (Field, validator, model
validator) est ré-appliquée.
Recettes courantes¶
Body HTTP entrant¶
class IndexProfilePayload(StrictSchema):
profile_id: str
full_name: str
headline: str | None = None
bio: str | None = None
Le handler reçoit payload: IndexProfilePayload ; le router déclenche un
400 http.validation_error si :
- un champ requis manque,
- un champ inconnu est présent,
- un type est incompatible.
Réponse HTTP — toujours enveloppée¶
from mic.contracts import wrap_http_success
from mic.http import HttpResponseSpec
return HttpResponseSpec(
status_code=201,
body=wrap_http_success({"id": job.id, "status": job.status.value}),
)
Le client reçoit toujours :
Pas de mélange "tantôt {...}, tantôt {"data": {...}}". Le client
sait d'avance ce qu'il extrait.
Mutation logique → nouvel objet¶
# ❌ Mauvais : mutation silencieuse
payload.max_attempts = 5
# ❌ Pire : Pydantic refusera (frozen=True), mais on a vu le motif
# `model_copy(update=...)` qui ne re-valide pas
patched = payload.model_copy(update={"max_attempts": -1}) # n'aurait pas levé
# ✅ Bon : with_changes re-valide
patched = payload.with_changes(max_attempts=5)
Quand ne pas l'utiliser¶
StrictSchema est pour les frontières. À l'intérieur d'un service, un
@dataclass Python suffit souvent — la rigueur Pydantic est un surcoût
si l'objet ne traverse pas une frontière.
Règle de pouce : si un objet va être :
- sérialisé en JSON (HTTP, message queue, log),
- relu depuis du JSON,
- utilisé comme contrat avec un autre service / module,
alors StrictSchema. Sinon, @dataclass.
Contrats connus à l'exécution¶
« Strict » ne veut pas dire « écrit uniquement à la compilation ». Un produit à champs personnalisés peut charger une définition versionnée, construire un validateur à l'exécution et refuser tout payload qui ne lui correspond pas.
La rigueur vient de la validation systématique, de la version identifiable et de l'invalidation cohérente, pas du moment où le schéma est créé. Voir la doctrine des contrats gouvernés.