Stringly-typed APIs¶
Anti-pattern. Les payloads et réponses sont des
dict[str, Any](ou pire :dict). Tout est validé "manuellement", c'est-à-dire pas du tout.
Symptômes¶
# ❌ Le métier prend un dict
def create_user(payload: dict) -> dict:
if "email" not in payload:
return {"error": "email required"}
if not payload["email"]:
return {"error": "email empty"}
user = User(email=payload["email"], name=payload.get("name", ""))
session.add(user)
return {"id": user.id, "email": user.email}
# ❌ Côté handler
@app.post("/users")
def create_user_endpoint(request: Request):
data = json.loads(request.body)
return create_user(data)
Pourquoi c'est tentant¶
- "Pydantic est lourd, on perd 5 ms par requête."
- "On n'aura jamais besoin de Swagger / OpenAPI."
- "Ça change tout le temps, ce n'est pas la peine."
Pourquoi c'est mauvais¶
Erreurs à l'exécution, pas à la vérification de types¶
mypy ne peut rien faire sur dict[str, Any]. Pyright non plus. Les
linters non plus. Tout doit être testé à la main.
Pas de doc auto-générée¶
Avec un StrictSchema, FastAPI génère un OpenAPI complet. Avec un
dict, le client doit lire votre code source pour savoir ce qu'il
peut envoyer. Et il se trompe régulièrement.
Vulnérabilité d'assignation de masse¶
# Si le service métier transmet le dict aveuglément
user = User(**payload)
# Et que payload contient {"is_admin": true}, vous avez un problème.
Avec un StrictSchema(extra="forbid"), c'est rejeté à la frontière
avec un 400.
Refactor cassé¶
Vous renommez email en contact_email dans la DB. Quelques mois
plus tard, vous découvrez que 3 endpoints utilisent encore l'ancien
nom dans leurs dict["email"]. Pas d'erreur visible, juste un comportement
décalé.
Le bon pattern¶
# ✅ Pydantic StrictSchema en frontière
from mic.contracts import StrictSchema, wrap_http_success
from mic.http import HttpResponseSpec
class CreateUserPayload(StrictSchema):
email: str = Field(min_length=3, max_length=255)
name: str = Field(default="", max_length=100)
def _create_user(*, payload: CreateUserPayload) -> HttpResponseSpec:
user = User(email=payload.email, name=payload.name)
self._session.add(user)
self._session.commit()
return HttpResponseSpec(
status_code=201,
body=wrap_http_success({"id": user.id, "email": user.email}),
)
| Avantage | Comment |
|---|---|
| Erreurs détectées à la vérification de types | mypy connaît payload.email |
| OpenAPI auto-généré | FastAPI lit le schéma |
| Validation à la frontière | extra="forbid", min_length, etc. |
| Refactor sûr | Renommez le champ → mypy crie partout |
| Tests plus précis | CreateUserPayload(...) avec contraintes |
"Je veux quand même un dict en interne"¶
OK pour les payloads JSON opaques (champs qu'on stocke mais ne manipule pas) :
class AuditEvent(SQLModel, table=True):
actor: str
action: str
payload: dict[str, Any] | None # ← OK : opaque, jamais consulté en métier
Mais à la frontière HTTP, toujours un schéma strict.
Ce que cet anti-pattern n'interdit pas¶
Un champ créé par un utilisateur, un formulaire piloté par configuration ou un métamodèle chargé depuis une base ne sont pas connaissables à la compilation. Les représenter par un nom dynamique n'est pas du laxisme.
La contrainte se déplace : la définition devient une source de vérité versionnée, compilée en validateur, mise en cache et invalidée explicitement. Le payload reste strictement validé et sa version de contrat reste identifiable. MIC appelle ce modèle un contrat gouverné.
Ce qui reste interdit est le repli silencieux vers un dict[str, Any] dès que
le schéma devient dynamique.
La règle d'or¶
Si une donnée traverse une frontière (HTTP, file de messages, autre service), elle est validée par un contrat strict statique ou gouverné. Sinon, libre à vous.