Accueil / Articles

JSON valide, contrat non respecté : vérifications en plusieurs niveaux pour détecter les régressions du chargement

Apprenez à détecter les régressions dans les charges utiles JSON qui se parsent correctement : différences sémantiques, un schéma JSON ciblé, des assertions de règles métier en Node, ainsi que les limites des types générés.

Éditeur : REACTAPP.TOP7 min de lecture1572 mots

Sources: Catch JSON Contract Regressions Before They Reach Your App

Considérons un service de commandes. Le point d’entrée répond avec succès, le JSON est analysé, puis le code qui additionne les quantités d’articles produit "021" au lieu de 3. Ce scénario est fictif, mais ce mode de défaillance est courant : dans un en-tête chargé, il est facile de manquer la chaîne "2" ainsi que le nombre 2 ; les deux sont des valeurs JSON valides, mais seule l’une d’elles respecte les conditions du contrat.

Le fichier de test ci-dessous représente la commande reçue. Observez bien le premier article : sa quantity est une chaîne entre guillemets, tandis que le deuxième article utilise un nombre simple. Tout le reste correspond à des données de commande plausibles, y compris des instructions d’expédition null et un montant total totalCents précalculé.

{
  "orderId": "ORD-1042",
  "createdAt": "2026-09-21T09:30:00Z",
  "customer": {"id": "C-017", "name": "Avery Example", "vip": false},
  "items": [
    {"sku": "MUG-BLUE", "quantity": "2", "unitPriceCents": 1250},
    {"sku": "NOTE-A5", "quantity": 1, "unitPriceCents": 650}
  ],
  "shipping": {"city": "Exampleton", "instructions": null},
  "paid": true,
  "totalCents": 3150
}

Un contrôle de syntaxe strict accepte ce document sans problème, et c’est un résultat légitime : le texte respecte la grammaire JSON définie dans RFC 8259, où les chaînes de caractères, les nombres, les valeurs booléennes et null sont tous des valeurs valides. Ce que cette grammaire ne peut pas connaître, ce sont les règles appliquées par votre application :

  • quantity doit être un entier supérieur à zéro
  • paid doit être une valeur booléenne
  • la somme de chaque quantité multipliée par son prix unitaire doit être égale à totalCents

Supposons que la version précédente de cette commande diffère en exact deux endroits : quantity était le nombre 2 et paid était false. Le fait que le flag de paiement passe à true représente un changement d’état attendu. En revanche, le fait que la quantité passe d’un nombre à une chaîne de caractères constitue une régression.

Valeurs de différence par chemin, et non par lignes de texte

Lors de la comparaison des charges utiles, on peut se poser deux questions très différentes. Une comparaison sémantique analyse les deux documents et compare les valeurs à chaque chemin. Elle considère l’ordre des clés d’objet comme irrelevant, de sorte que l’échange de la position de customer.id et customer.name ne constitue pas de changement, tandis qu’elle tient compte de l’ordre des tableaux, ce qui peut entraîner un changement en déplaçant un élément vers un autre index. Pour ce fichier de test, une différence sémantique indique précisément deux chemins : $.items[0].quantity et $.paid. Cela répond à la question « quels données ont changé ? »

Un diff de texte répond à une question différente : « quels caractères ou lignes ont changé ? » Il s’agit néanmoins d’une preuve utile, et normaliser l’indentation ainsi que le formatage des deux côtés élimine une grande partie du bruit. Cependant, le fait que les textes soient identiques ou différents ne prouve pas que deux documents représentent ou non la même valeur JSON, car l’espace blanc, l’ordre des clés et le formatage des nombres peuvent varier sans changer le sens.

Réaliser les vérifications avec Node pur

Vous n’avez pas besoin de service externe pour vérifier ces faits. Le script ci-dessous lit order.json depuis son propre répertoire (via import.meta.url), reconstitue la version précédente à l’aide de structuredClone, et vérifie les différences en utilisant node:assert/strict. Il illustre ensuite le bug, corrige le fichier de test sur une copie et vérifie que le total calculé correspond à totalCents. Enregistrez-le sous le nom check-order.mjs à côté du fichier de test et exécutez node check-order.mjs.

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const incoming = JSON.parse(
  readFileSync(new URL('./order.json', import.meta.url), 'utf8')
);
const before = structuredClone(incoming);
before.items[0].quantity = 2;
before.paid = false;
assert.equal(before.items[0].quantity, 2);
assert.equal(incoming.items[0].quantity, '2');
assert.equal(before.paid, false);
assert.equal(incoming.paid, true);
const naiveCount = incoming.items.reduce(
  (sum, item) => sum + item.quantity,
  0
);
assert.equal(naiveCount, '021');
const corrected = structuredClone(incoming);
corrected.items[0].quantity = 2;
const count = corrected.items.reduce((sum, item) => sum + item.quantity, 0);
const computedTotal = corrected.items.reduce(
  (sum, item) => sum + item.quantity * item.unitPriceCents,
  0
);
assert.equal(count, 3);
assert.equal(computedTotal, corrected.totalCents);
console.log('Contract checks passed');

L’assertion naiveCount est la plus intéressante. La réduction commence par le nombre 0, ajoute la chaîne de caractères "2" pour obtenir "02", car l’opérateur + passe en mode concaténation dès qu’un des opérandes est une chaîne. L’étape suivante ajoute 1, ce qui donne "021". Une valeur numérique initiale ne force pas la conversion des opérandes ultérieurs ; une fois qu’une chaîne entre dans l’accumulateur, tout ce qui suit est concaténéné.

Remarquez ce que le script ne recommande pas. Modifier la valeur de référence en un nombre est un moyen d’exprimer le contrat correct, et non une indication de devoir envelopper chaque champ reçu dans Number(). La conversion silencieuse cache les erreurs de la partie produisant les données. Il faut soit corriger le service qui génère les chaînes, soit définir une règle de normalisation explicite et documentée au point d’entrée des données dans votre système.

Transformer la règle de type en schéma

Les règles relatives au texte narratif sont faciles à oublier ; celles qui peuvent être exécutées, en revanche, ne le sont pas. Le schéma Draft 2020-12 ci-dessous encode les exigences importantes pour cette régression. Il exige orderId, items, paid et totalCents, impose la présence d’au moins un élément, et restreint quantity à un entier dont la valeur minimale est 1, ainsi que unitPriceCents à un entier non négatif.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["orderId", "items", "paid", "totalCents"],
  "properties": {
    "orderId": {"type": "string"},
    "items": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["sku", "quantity", "unitPriceCents"],
        "properties": {
          "sku": {"type": "string"},
          "quantity": {"type": "integer", "minimum": 1},
          "unitPriceCents": {"type": "integer", "minimum": 0}
        }
      }
    },
    "paid": {"type": "boolean"},
    "totalCents": {"type": "integer", "minimum": 0}
  }
}

La validation du fichier d’entrée selon ce schéma échoue avec un message du type $.items[0].quantity: type attendu : entier. En modifiant cette seule valeur par le nombre 2, la validation réussit. C’est précisément ce type de chemin ciblé que l’on souhaite obtenir lors d’une vérification de régression.

Savoir ce que votre validateur couvre réellement

Les validateurs diffèrent quant à la partie de la spécification qu’ils implémentent. Certains évitent délibérément de résoudre des cibles $ref distantes, d’évaluer les expressions régulières pattern ou de vérifier les mots-clés format ; par défaut, la spécification considère format comme une annotation plutôt que comme une assertion. Assurez-vous que le validateur que vous utilisez dans les tests couvre les mêmes mots-clés que ceux sur lesquels repose votre contrat en production, sinon un schéma peut sembler plus strict que les vérifications qui le mettent en œuvre.

Cas limites méritant un test chacun

Un bon contrat rejette les entrées incorrectes pour des raisons distinctes. Chacune d’entre elles doit échouer indépendamment :

  • L’absence totale de items (violation de la contrainte required)
  • items représentant un tableau vide (minItems)
  • Une quantité nulle (minimum)
  • paid est envoyé sous forme de chaîne "true" (type)
  • Il est tout aussi important de savoir ce qui doit être transmis : une commande non payée avec paid: false. Le mot-clé required ne vérifie que la présence de cette propriété, et non que sa valeur soit vraie. Les guides officiels de JSON Schema expliquent comment required est lié aux propriétés d’objet et comment chaque élément d’array est vérifié par rapport au subschema items.

    Ce que le schéma omet intentionnellement

    Ceci est un schéma ciblé, et non une spécification complète de commande. Les détails du client, les informations d’expédition, la sémantique des timestamps et les champs supplémentaires ne sont pas contraints. La règle arithmétique est également délibérément hors champ : le fait que les éléments de la ligne additionnent bien à totalCents relève d’une logique d’application que le JSON Schema standard ne peut pas exprimer, c’est pourquoi le script Node la vérifie séparément. En superposant les deux approches, chaque outil reste spécialisé dans ce pour quoi il est conçu.

    Les types générés décrivent un seul échantillon

    Il est tentant de coller un en-tête dans un générateur JSON vers TypeScript et de considérer le résultat comme le contrat. Lorsqu’on l’exécute sur l’échantillon corrigé, un tel générateur produit des éléments tels que quantity: number, createdAt: string et instructions: null au sein des types imbriqués. Il s’agit là de descriptions précises de cet unique échantillon, et rien de plus :

    • JSON ne dispose que d’un seul type numérique, donc number ne peut pas désigner un « entier positif ».
    • Un seul exemple ne permet pas de déterminer quels champs sont optionnels.
    • Un champ qui est actuellement null peut légitimement contenir une chaîne de caractères demain.
    • Les types TypeScript disparaissent en temps de exécution et ne vérifient jamais les octets reçus ; le manuel TypeScript sur les assertions de type indique clairement que ces assertions ne effectuent aucune vérification en temps de exécution.

    Si vous souhaitez une définition unique qui gère à la fois la validation en temps de exécution et les types statiques, consultez notre article sur le partage d’un schéma Zod entre le frontend et le backend.

    La précision doit également s’appliquer aux limites

    Les nombres présentent un autre piège. JavaScript interprète {"id":9007199254740993} comme 9007199254740992, car cette valeur dépasse Number.MAX_SAFE_INTEGER et est arrondie au double le plus proche. Si un identifiant doit conserver chaque chiffre, son contrat de définition devrait le définir comme une chaîne de caractères. Essayer de corriger cela après JSON.parse est trop tard ; les chiffres ont déjà disparu.

    Points clés

    • Gardez une version nettoyée d’un en-tête connu pour être valide, et n’utilisez jamais de identifiants réels ou de données personnelles dans les données de test.
    • Considérez les vérifications comme des couches successives : syntaxe stricte, comparaison des valeurs sémantiques, validation du schéma, puis règles métier que le schéma ne peut pas exprimer.
    • Générez les types TypeScript uniquement une fois le contrat défini, et ne les confondez jamais avec une validation en temps de exécution.
  • Visez des erreurs qui indiquent clairement le chemin et la cause, comme "$.items[0].quantity est passé de nombre entier à chaîne, ce qui provoque l’échec de la validation et empêche l’addition", plutôt que "l’ordre semble incorrect">.