Typer une API ML de bout en bout : FastAPI, pydantic et un client TypeScript généré
Le contrat d'API écrit une seule fois en Python, publié en OpenAPI par FastAPI, et le TypeScript du frontend généré à partir de là. Un contrôle en CI vérifie que rien n'a dérivé. Pas de client écrit à la main.
La panne classique n'a rien de spectaculaire. Un champ est renommé côté serveur, le frontend lit toujours l'ancien nom, undefined arrive dans un graphique, et le graphique affiche un état vide qui a l'air normal. Personne ne s'en rend compte pendant une semaine.
La correction est structurelle : arrêter de maintenir le contrat à deux endroits. Je le définis une fois en Python, FastAPI le publie en OpenAPI, et le TypeScript est généré à partir de là. Le seul composant qui doit être juste, c'est le générateur.
La chaîne
Les pièces
- pydantic v2 : modèles de requête et de réponse, avec validation
- FastAPI : les sert, et publie
/openapi.jsonà partir des mêmes objets - openapi-typescript : transforme ce document en
.d.ts - Un contrôle CI qui régénère et échoue à la moindre différence
C'est le dernier point qui rend le reste fiable. Sans lui, le fichier généré devient un instantané que quelqu'un finira par modifier à la main.
Le serveur de modèle
from typing import Annotated, Literal
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI(title="inference", version="class="tok-num">1.0.class="tok-num">0")
class EmbedRequest(BaseModel):
class=class="tok-str">"tok-com"> # Les contraintes vont dans le schéma : elles sont validées à lclass="tok-num">39;exécution
class=class="tok-str">"tok-com"> # ET exportées dans OpenAPI, donc le frontend voit les mêmes bornes que
class=class="tok-str">"tok-com"> # celles imposées par le serveur.
texts: Annotated[list[str], Field(min_length=class="tok-num">1, max_length=class="tok-num">64)]
normalize: bool = True
model: Literal["e5-large", "bge-m3"] = "e5-large"
class EmbedResponse(BaseModel):
vectors: list[list[float]]
dim: int
model: str
class=class="tok-str">"tok-com"> # Sérialisé en nombre JSON ; le déclarer garde le type TS exact.
elapsed_ms: float
@app.post("/embed", response_model=EmbedResponse)
def embed(req: EmbedRequest) -> EmbedResponse:
...Trois habitudes qui se rentabilisent vite.
Literal pour les ensembles fermés. Il devient une union TypeScript : un nom de modèle invalide est une erreur de compilation côté frontend, bien avant le 422 à l'exécution.
Les contraintes dans Field. max_length=64 est appliqué par le serveur, documenté dans OpenAPI et visible par quiconque écrit le client. La même règle à trois endroits, écrite une seule fois.
La version de l'API dans le chemin. /v1/embed permet de générer deux clients pendant une migration. Une version qui n'existe que dans les métadonnées ne sert à rien le jour où il faut faire cohabiter deux versions.
Générer le client
"tok-com"># Régénérer ne coûte rien ; garder la sortie dans git rend la dérive relisible.
.PHONY: types check-types
types:
python -c "import json, app.main as m; print(json.dumps(m.app.openapi(), indent=2, sort_keys=True))" > openapi.json
npx openapi-typescript openapi.json -o frontend/src/api.d.ts
"tok-com">
# CI : régénérer puis comparer. Échoue si quelqu39;un a modifié le serveur
"tok-com"># sans régénérer, ou a retouché le fichier généré à la main.
check-types: types
git diff --exit-code openapi.json frontend/src/api.d.tssort_keys=True compte plus qu'il n'y paraît. Sans lui, un simple changement d'ordre des clés produit des différences qui ne changent rien au sens, le contrôle sonne pour rien, et l'équipe apprend à le contourner.
Je génère depuis app.openapi() directement, sans lancer de serveur : pas de port, pas de démarrage, rien d'aléatoire.
Côté frontend
import type { components } from "./api";
type EmbedRequest = components["schemas"]["EmbedRequest"];
type EmbedResponse = components["schemas"]["EmbedResponse"];
class=class="tok-str">"tok-com">// Une seule fonction typée, utilisée partout. Les types sont importés :
class=class="tok-str">"tok-com">// impossible de se tromper sur les paramètres.
async function embed(req: EmbedRequest): Promise<EmbedResponse> {
const r = await fetch("/api/embed", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(req),
});
if (!r.ok) throw new Error(class="tok-str">`embed failed: ${r.status}`);
return r.json();
}Ce code ne valide pas la réponse. Les types affirment, à la compilation, que le serveur respecte son contrat, et rien ne le vérifie à l'exécution. Pour une API interne protégée par son propre contrôle CI, c'est en général un bon compromis. Pour tout ce qui traverse une frontière de confiance, je valide aussi la réponse à l'exécution : des types générés décrivent tranquillement une réponse qui n'arrivera jamais sous cette forme.
Là où ça casse
Any se propage. Un seul dict[str, Any] dans un modèle pydantic devient Record<string, unknown>, et la garantie s'arrête là. Si un champ est vraiment polymorphe, je le modélise comme une union discriminée.
Les types NumPy ne sont pas des types JSON. np.float32 n'est pas un float, et pydantic le refusera. Je convertis à la frontière, avec .tolist() ou .item(), dans la construction de la réponse. Caché dans un hook de sérialisation, le problème devient invisible.
Les grands tableaux se prêtent mal au JSON. Une réponse d'embeddings de 64 × 1024 flottants pèse plusieurs mégaoctets de texte décimal. Dès que la taille compte, il faut passer à un encodage binaire (Arrow, npy ou float32 en base64), le schéma décrivant toujours l'enveloppe.
Optionnel et nullable sont deux choses différentes. str | None et « le champ peut être absent » génèrent des TypeScript différents. Le comportement par défaut est rarement celui qu'on voulait, donc je choisis explicitement.
Tester le contrat, en plus de le générer
Le contrôle CI prouve que les types générés correspondent au serveur. Il ne dit rien de la correspondance entre le serveur et ce qu'il renvoie vraiment, et ce deuxième point échoue à sa manière.
Tester le schéma, en passant par la route. Un modèle de réponse qui déclare dim: int alors que le handler renvoie un entier NumPy passe tous les tests unitaires qui appellent la fonction directement, puis échoue à la sérialisation. Je fais passer la route par le client de test pour que pydantic s'exécute pour de vrai :
from fastapi.testclient import TestClient
client = TestClient(app)
def test_embed_response_matches_schema():
r = client.post("/embed", json={"texts": ["hello"], "model": "e5-large"})
assert r.status_code == class="tok-num">200
class=class="tok-str">"tok-com"> # Revalider avec le modèle déclaré EST lclass="tok-num">39;assertion : si le handler renvoie
class=class="tok-str">"tok-com"> # une forme que le schéma ne décrit pas, ceci lève une exception.
EmbedResponse.model_validate(r.json())
def test_constraints_are_enforced():
class=class="tok-str">"tok-com"> # class="tok-num">65 éléments contre max_length=class="tok-num">64. La borne nclass="tok-num">39;est écrite quclass="tok-num">39;à un seul
class=class="tok-str">"tok-com"> # endroit : ce test protège le contrat lui-même.
r = client.post("/embed", json={"texts": ["x"] * class="tok-num">65})
assert r.status_code == class="tok-num">422Un changement de schéma est un changement d'API. Ajouter un champ optionnel ne casse rien. Rendre obligatoire un champ optionnel, restreindre un Literal ou renommer quoi que ce soit, si. La décision se prend en lisant la différence générée en CI, et c'est une deuxième raison de commiter les fichiers générés.
Garder le document OpenAPI de chaque version. Avec le openapi.json précédent sous la main, on compare deux versions et on répond mécaniquement à « a-t-on cassé un client ? », sans compter sur la mémoire de qui que ce soit.
L'intuition mathématique : une source, zéro copie
Pourquoi un contrat écrit à un seul endroit tient-il mieux que deux contrats soigneusement maintenus ? Parce que le nombre de façons de diverger grandit avec le nombre de copies.
Avec k copies du même contrat (le modèle pydantic, le client TypeScript, la documentation…), il y a k(k − 1)/2 paires qui doivent rester d'accord : 1 paire pour 2 copies, 3 pour 3, 6 pour 4. Et chaque changement doit être reporté à la main dans les k − 1 autres copies. Si chaque report est juste avec une probabilité p, la probabilité qu'aucune dérive n'apparaisse après m changements vaut :
P(aucune dérive) = p^(m(k − 1))
Avec des reports justes 98 fois sur 100, 50 changements et 3 copies : 0,98^100 ≈ 13 %. Dans 87 % des cas, au moins une dérive s'est glissée quelque part. Même un travail soigné perd à ce jeu, parce que les petites probabilités d'erreur s'accumulent. Générer le client depuis une source unique ramène k − 1 à zéro, et p^0 = 1, à condition qu'un contrôle CI vérifie que le fichier généré correspond bien à la source. Sans ce contrôle, le fichier généré redevient une copie comme les autres.
Pourquoi ça tient
Le système de types aide, mais ce qui fait vraiment marcher l'ensemble, c'est qu'il existe un seul endroit où le contrat est écrit, et un contrôle qui échoue dès que quelque chose en aval n'est pas d'accord avec lui.
On obtiendrait la même garantie avec protobuf, ou avec un schéma écrit à la main et un générateur de code. Dans tous les cas, c'est le contrôle CI qui apporte la garantie. Un fichier généré que personne ne vérifie finit par vivre sa vie comme un fichier écrit à la main.