Des formes de réponse JSON qui survivent cinq ans
Presque toute migration d’API douloureuse remonte à une décision de forme prise en un après-midi et gelée par la première intégration.
Chaque affirmation de cette page est soit mesurée, soit sourcée. Quand elle n’est ni l’une ni l’autre, la page le dit.
Voici une réponse partie dans la v1 de quelqu’un et qui part encore aujourd’hui :
[
{ "id": 8102, "name": "Ada" },
{ "id": 8103, "name": "Grace" }
]
Trois ans plus tard, la collection est assez grande pour exiger une pagination, et il n’y a nulle part où mettre un curseur. Le niveau supérieur est un tableau. L’envelopper change le type que tous les clients analysent déjà : l’équipe livre donc /v2/users et maintient deux chemins de code pour toujours. Rien dans ce tableau n’était faux quand il a été écrit. Il n’avait simplement aucune place pour grandir.
C’est tout le sujet. La conception des réponses ne parle pas d’élégance, elle parle des changements qui restent bon marché.
Enveloppe ou valeur nue
Une enveloppe est un objet de premier niveau avec la charge utile sous une clé :
{
"data": [ { "id": "8102", "name": "Ada" } ],
"nextCursor": "eyJpZCI6ODEwM30",
"hasMore": true
}
L’argument contre est réel : c’est du bruit, et tous les clients écrivent .data. L’argument pour, c’est qu’un objet est extensible et qu’un tableau nu ne l’est pas. Vous pourrez ajouter plus tard un curseur, un total, un avis d’obsolescence ou un identifiant de trace sans changer le type de quoi que ce soit déjà présent.
Ce que je fais : envelopper les collections, renvoyer l’objet nu pour une ressource unique. Une ressource unique est déjà un objet, elle a donc la place de grandir qu’une enveloppe lui aurait donnée. Les collections reçoivent l’enveloppe parce que ce sont elles qui finissent par avoir besoin de métadonnées.
Quoi que vous choisissiez, choisissez une fois. La moitié de vos points de terminaison enveloppés et l’autre moitié nus, c’est pire que l’un ou l’autre. Et ne mettez pas un champ nommé data dans un champ nommé data.
Les types de champ sur lesquels on ne revient pas
Les identifiants sont des chaînes. Toujours, y compris tant que ce sont encore de petits entiers. Un nombre JSON en JavaScript est un double IEEE 754, donc tout identifiant supérieur à 9007199254740991 est arrondi en silence à l’arrivée et vous regardez désormais un autre enregistrement. Twitter a heurté ce mur en passant aux identifiants Snowflake 64 bits en 2010 et a livré id_str à côté d’id ; le motif est resté. La mécanique est dans pourquoi vos identifiants JSON changent de valeur. Le point de conception est plus étroit : un identifiant n’est pas une quantité. Vous ne l’additionnez jamais, ne le triez pas arithmétiquement, ne le moyennez pas : un type numérique ne vous achète rien et vous coûte le jour où vous passez aux UUID.
L’argent est un entier en unités mineures, ou une chaîne décimale. Jamais un flottant.
{ "amountMinor": 1005, "currency": "GBP" }
{ "amount": "10.05", "currency": "GBP" }
1.005 n’est pas exactement représentable en double, donc en JavaScript 1.005 * 100 vaut 100.49999999999999, qui s’arrondit à 100 au lieu de 101. Choisissez une représentation, portez la devise à côté, et ne laissez jamais un price: 10.05 nu entrer dans le schéma, parce que l’en retirer plus tard signifie auditer tous les consommateurs qui font de l’arithmétique dessus.
Les dates sont des chaînes RFC 3339 avec un décalage explicite. "2026-09-05T14:30:00Z". Pas un horodatage Unix, pas "05/09/2026", et surtout pas une heure locale sans décalage, parce que cela s’analyse très bien et se trompe de plusieurs heures. JSON n’a pas de type date, donc cette convention n’existe que si la revue de code la fait respecter. Formats de date et d’heure en JSON couvre le reste.
null, absent et vide
Quatre formes, quatre sens :
| Forme | Sens |
|---|---|
"middleName": "Jane" |
Valeur connue |
"middleName": null |
Connu comme n’ayant pas de valeur |
| clé absente | Inconnu, non chargé, ou non permis |
"tags": [] |
Connu comme ayant zéro étiquette |
L’erreur n’est pas de choisir la mauvaise convention, c’est d’utiliser les quatre de façon incohérente, si bien qu’un client ne peut pas distinguer « cet utilisateur n’a pas de deuxième prénom » de « vous avez demandé une projection partielle ». Décidez par champ et tenez la ligne.
Deux pièges. JSON.stringify supprime les clés dont la valeur est undefined mais conserve null, donc un producteur JavaScript bascule entre absent et null selon qu’une variable a été affectée. Et le required de JSON Schema affirme qu’une clé est présente, pas qu’elle est non nulle : {"name": null} satisfait required: ["name"]. Si vous voulez dire non nul, mettez-le dans le type.
{
"type": "object",
"required": ["name", "middleName"],
"properties": {
"name": { "type": "string" },
"middleName": { "type": ["string", "null"] }
}
}
Générez le premier brouillon depuis un payload réel avec le générateur de schéma, puis corrigez la nullabilité à la main, parce qu’un générateur ne voit que les valeurs présentes dans votre échantillon.
Nommage
Choisissez camelCase ou snake_case, appliquez-le à toutes les clés de tous les points de terminaison, et cessez d’avoir cette conversation. Un mélange de casses dans un même document est le signal le plus clair que deux équipes ont écrit deux moitiés sans se lire, et cela casse l’astuce bon marché consistant à mapper mécaniquement les clés sur des champs de structure. created_at vaut mieux que ts. Une clé qui a besoin d’un commentaire a besoin d’un meilleur nom.
Erreurs
Un corps d’erreur a besoin de trois choses distinctes, et la plupart n’en livrent qu’une :
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 402,
"detail": "Balance is 320 minor units, transfer requires 1005.",
"code": "INSUFFICIENT_FUNDS",
"pointer": "/transfer/amountMinor"
}
Un code machine stable sur lequel le client branche, et que vous vous engagez à ne jamais changer. Un message humain que vous restez libre de reformuler ou de traduire, et sur lequel aucun client ne devrait faire de correspondance. Un pointeur vers le champ fautif, idéalement un JSON Pointer RFC 6901 pour qu’il se résolve mécaniquement contre le corps de la requête.
La RFC 9457, Problem Details for HTTP APIs, normalise type, title, status, detail et instance, et autorise explicitement des membres d’extension : vous pouvez donc l’adopter tout en portant votre propre code. Elle a rendu obsolète la RFC 7807, nom sous lequel la plupart des implémentations existantes la connaissent. L’utiliser vous donne une forme que l’outillage des autres comprend déjà, ce que {"error": "quelque chose s’est mal passé"} ne fera jamais. Pour les échecs de validation, renvoyez-les tous plutôt que le premier.
Les curseurs battent les décalages
La pagination par décalage devient discrètement lacunaire dès qu’il y a des écritures concurrentes. La page un renvoie les lignes 1 à 50. Une ligne est insérée vers le haut. La page deux, offset=50, commence maintenant à ce qui était la ligne 50 : le consommateur voit donc cet enregistrement deux fois. Les suppressions font l’inverse et sautent des enregistrements. Rien ne lève d’erreur ; cela ressort des semaines plus tard sous forme d’écart de rapprochement.
Un curseur encode une position dans un tri stable, normalement la clé de tri plus un identifiant de départage, si bien que les insertions au-dessus n’ont aucune importance. Documentez le curseur comme opaque pour pouvoir changer son encodage plus tard, et renvoyez un hasMore explicite plutôt que de laisser les clients déduire la fin d’une page courte. Passez sur totalCount sauf si quelqu’un en a réellement besoin et que vous acceptez de payer la seconde requête.
Le changement additif est le seul changement gratuit
Le contrat qui rend l’évolution possible vit côté client : les champs inconnus doivent être ignorés. Si cela tient, ajouter un champ ne casse rien et vous pouvez livrer en continu. Si un consommateur valide strictement, ou génère des types avec additionalProperties: false, chaque ajout casse quelqu’un et vous restez en v1 pour toujours. Dites-le dans le premier paragraphe de votre documentation.
Tout le reste est une version : retirer un champ, en renommer un, changer son type, changer ce qu’une valeur signifie, resserrer ce que vous acceptez, ou rendre non nullable un champ nullable. Passer l’échantillon de payload de la dernière livraison et celui d’aujourd’hui dans un diff JSON attrape le changement de type que personne n’avait voulu faire.
Les tableaux hétérogènes coûtent au consommateur plus qu’ils ne vous économisent
{ "items": [
{ "kind": "comment", "body": "..." },
{ "kind": "reaction", "emoji": "..." },
{ "id": 7, "legacy": true }
] }
Chaque consommateur écrit désormais un aiguillage, et chaque consommateur typé statiquement écrit une union étiquetée à la main. Si vous devez mêler des formes, discriminez-les : un kind obligatoire avec un ensemble fermé et documenté de valeurs, présent sur chaque membre. L’union devient alors mécanique. La version impardonnable est le troisième élément, où la forme varie sans étiquette et où les clients reniflent les clés. Même chose pour un champ tantôt chaîne, tantôt objet : cela vous épargne un incrément de version et coûte à chaque client une garde de type pour toujours.
Quand la réponse devient volumineuse
Chaque moteur a un plafond dur de longueur de chaîne, et il est plus bas qu’on ne le croit : sur V8 64 bits (Chrome et Node), c’est 536 870 888 caractères, donc une réponse au-delà d’environ un demi-gigaoctet ne peut même pas être tenue comme chaîne, encore moins analysée. D’autres moteurs sont plus hauts, mais tous ont un plafond, et l’arbre d’objets analysé coûte plusieurs fois ce que coûtait le texte. Bien avant tout cela, une analyse de plusieurs secondes bloque le thread principal.
Trois sorties, par ordre de perturbation de l’API. Paginer plus finement pour qu’aucune réponse ne soit volumineuse. Diffuser des enregistrements délimités par des lignes pour que le consommateur travaille au fil de la réception au lieu d’attendre une accolade fermante (NDJSON et JSON Lines). Ou sortir l’export en masse de l’API synchrone : renvoyez un identifiant de tâche et une URL signée pour le fichier terminé. Gros fichiers JSON couvre le côté consommateur.
La liste de contrôle
- Enveloppez les collections, renvoyez des objets nus pour les ressources uniques, et restez cohérent.
- Les identifiants sont des chaînes. L’argent, des unités mineures ou une chaîne décimale. Les dates, RFC 3339 avec décalage.
- Définissez ce que signifient null, absent et vide, champ par champ.
- Une seule convention de casse sur tous les points de terminaison.
- Les erreurs portent un code stable, un message modifiable et un pointeur de champ. Envisagez la RFC 9457.
- Pagination par curseur, curseurs opaques, un
hasMoreexplicite. - Dites aux clients d’ignorer les champs inconnus, puis gardez tout autre changement derrière une version.
- Discriminez chaque tableau hétérogène avec un
kindobligatoire.
Rien de tout cela n’est coûteux au premier jour. Tout cela est coûteux au millième, et c’est la seule raison pour laquelle il vaut la peine d’en débattre maintenant.