Aller au contenu

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 :

  1. extra="ignore" (par défaut) — un client envoie {"name": "x", "is_admin": true} ; votre code ignore is_admin silencieusement. Six mois plus tard, un nouveau champ is_admin apparaît côté modèle et un attaquant prend la main.
  2. 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.
  3. 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 :

{ "success": true, "data": { ... }, "meta": null }

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.