Aller au contenu

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

def create_user(payload: dict) -> dict:
    return {"id": payload["emial"]}    # typo, jamais détectée

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.