JSON Schema : les parties qui décident si la validation fonctionne
La plupart des configurations JSON Schema cassées ne lèvent rien. Elles renvoient valide sur des données fausses.
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.
Une vérification de syntaxe vous dit qu’un document s’analyse. Elle ne dit rien sur le fait que {"user_id": null, "email": 4} soit quelque chose que votre service devrait accepter. C’est à combler cet écart que sert un schéma, et la différence mérite d’être posée avec précision : le validateur répond à « est-ce du JSON bien formé », un schéma répond à « est-ce le document qu’on m’avait promis ».
Le mode de défaillance qui coûte du temps réel n’est pas un schéma qui refuse de bonnes données. Cela se remarque en quelques minutes. C’est un schéma qui accepte de mauvaises données, compile sans broncher et renvoie valid pendant un an.
Choisir un draft
Il y a deux réponses, et les deux se défendent.
2020-12 pour du travail neuf. C’est l’identifiant auquel OpenAPI 3.1 s’aligne, donc si vous rédigez une description d’API vous êtes déjà dans ce dialecte. Attention au nom : 2020-12 est la date où le draft a été figé, et il a été annoncé début 2021. Les documents à cet URI ont été republiés pour la dernière fois en juin 2022, ce qui était un correctif au texte de la spécification, pas une nouvelle version.
draft-07 pour l’outillage le plus large. Il possède la plus longue traîne de support à travers les langages, les éditeurs et les générateurs de code, et beaucoup de validateurs hors de l’écosystème JavaScript le considèrent encore comme le défaut. Si votre schéma doit être consommé par des outils que vous ne maîtrisez pas, draft-07 est le plancher pragmatique.
Ce qu’il ne faut pas faire, c’est omettre $schema en espérant que ça passe. Un schéma sans dialecte déclaré est interprété selon ce que le validateur a bien voulu prendre par défaut, c’est-à-dire exactement l’ambiguïté que vous cherchiez à supprimer.
Les changements de 2020-12 que vous toucherez vraiment
Trois, par ordre de fréquence des morsures.
prefixItems remplace la forme tableau d’items. Dans draft-07, items avec une valeur de tableau signifiait une validation positionnelle de tuple, et additionalItems contraignait le reste. Dans 2020-12, les positions vont dans prefixItems et items est toujours un schéma unique qui s’applique à tout ce que prefixItems ne couvre pas.
unevaluatedProperties et unevaluatedItems sont les versions conscientes de la composition d’additionalProperties et d’additionalItems. Le pourquoi est plus bas, car c’est cette paire que l’on rate.
$dynamicRef et $dynamicAnchor remplacent $recursiveRef et $recursiveAnchor de 2019-09. À moins d’écrire des schémas récursifs extensibles, un type arbre qu’un appelant peut spécialiser, vous n’y toucherez jamais. Si vous héritez d’un schéma 2019-09 qui utilise $recursiveRef, il ne fonctionne pas sous un validateur 2020-12.
Le point d’entrée Ajv qui échoue en silence
L’export par défaut d’Ajv n’implémente que draft-07. Les dialectes 2020-12 et 2019-09 vivent à des points d’entrée séparés. Câblez-le de la façon évidente :
const Ajv = require("ajv"); // draft-07, quoi que dise votre $schema
const ajv = new Ajv({ strict: false });
const schema = {
type: "object",
properties: {
point: {
type: "array",
prefixItems: [{ type: "number" }, { type: "number" }],
minItems: 2,
maxItems: 2
}
}
};
ajv.validate(schema, { point: ["north", "west"] }); // true
prefixItems n’est pas un mot-clé que ce validateur connaît. Les mots-clés inconnus sont ignorés, donc on vérifie que la valeur est un tableau de deux entrées, et rien d’autre. Les types d’éléments que vous avez déclarés ne sont jamais regardés. Vous obtenez true sur des données que votre schéma interdit clairement.
Ajv a bien des garde-fous. Le mode strict, actif par défaut, proteste devant un mot-clé inconnu, et un $schema qui nomme un méta-schéma non chargé lèvera une erreur. Les deux sont désactivés en routine : strict: false est la première chose que l’on ajoute quand un schéma utilise un vocabulaire qu’Ajv ne reconnaît pas, et quantité de schémas générés partent sans $schema du tout. Retirez l’un ou l’autre et vous obtenez le succès silencieux.
Le câblage correct :
const Ajv2020 = require("ajv/dist/2020");
const addFormats = require("ajv-formats");
const ajv = new Ajv2020({ strict: true, allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);
validate({ point: ["north", "west"] }); // false
console.log(validate.errors);
Pour 2019-09, le point d’entrée est ajv/dist/2019. Il vaut la peine d’ajouter un test qui affirme qu’un document connu comme mauvais échoue. Une installation de validation sans test négatif est une installation dont personne n’a prouvé qu’elle marche.
format ne valide rien par défaut
Dans 2019-09 et 2020-12, format est une annotation, pas une assertion. La spécification le dit explicitement. Un validateur conforme qui voit {"type": "string", "format": "email"} a le droit de noter « cette valeur a été annotée comme une adresse e-mail » puis d’accepter "pas une adresse" sans broncher. La plupart font exactement cela, sauf si vous activez l’assertion.
Ainsi ce schéma :
{
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"created_at":{ "type": "string", "format": "date-time" }
},
"required": ["email"]
}
attrape un email manquant et attrape un email qui est un nombre. Il n’attrape pas "email": "banane" et il n’attrape pas "created_at": "hier", à moins qu’on ait dit au validateur d’asserter les formats.
Dans Ajv, cela veut dire deux choses et non une : installer et enregistrer ajv-formats, qui fournit les implémentations réelles, et utiliser le point d’entrée de votre dialecte pour que le bon vocabulaire soit en jeu. Ajv n’embarque aucune implémentation de format. Sans ajv-formats, un format est soit une erreur de format inconnu en mode strict, soit un no-op avec le mode strict désactivé. Ni l’un ni l’autre n’est de la validation.
Si la forme d’une valeur compte vraiment, doublez le format d’un pattern, ou de minLength et maxLength. Une expression régulière est assertée par tous les validateurs sur tous les drafts, sans greffon ni option. format est une documentation que certains validateurs peuvent être persuadés d’appliquer.
Les dates sont la victime habituelle ici, puisque JSON n’a pas de type date et que chaque horodatage de votre payload n’est en réalité qu’une chaîne.
additionalProperties face à unevaluatedProperties
additionalProperties ne connaît que properties et patternProperties dans le même objet schéma. Il ne voit rien de ce qu’un $ref ou une branche allOf a apporté. Cette seule phrase explique presque tous les rapports de bug du type « mon schéma refuse un champ pourtant clairement défini » :
{
"allOf": [{ "$ref": "#/$defs/base" }],
"properties": { "role": { "type": "string" } },
"additionalProperties": false
}
Toute propriété définie dans base est désormais refusée, parce qu’à ce niveau la seule propriété connue est role. Remplacez la dernière ligne par "unevaluatedProperties": false et le mot-clé s’exécute après que les applicateurs en place ont fait leur travail, voit tout ce que base a évalué et ne refuse que ce dont rien n’a rendu compte.
Règle empirique : un objet autonome dont toutes les propriétés sont déclarées localement prend additionalProperties: false. Tout ce qui est composé avec allOf, $ref, if/then ou oneOf prend unevaluatedProperties: false. unevaluatedItems est la même relation pour les tableaux et s’associe naturellement à prefixItems.
required veut dire présent, pas rempli
required est une liste de clés qui doivent exister. C’est tout ce que c’est.
{ "type": "object", "required": ["user_id"] }
{"user_id": null} satisfait cela. {"user_id": ""} aussi. Si null n’est pas acceptable, dites-le dans le schéma de la propriété, car "type": "string" exclut null de lui-même et ["string", "null"] l’admet :
{
"properties": { "user_id": { "type": "string", "minLength": 1 } },
"required": ["user_id"]
}
L’erreur miroir consiste à marquer un champ nullable comme non requis. Une clé parfois absente et une clé parfois nulle sont deux contrats différents pour le consommateur, et en choisir un délibérément fait partie de la conception de la réponse.
Générer un schéma à partir d’échantillons
Écrire à la main un schéma pour un payload de quarante champs est assez fastidieux pour qu’on y renonce. En générer un depuis une vraie réponse est le point de départ pratique, avec une exigence qui décide si le résultat est utilisable.
Un tableau d’objets doit être fusionné sur tous les éléments, pas échantillonné sur le premier. Ne prenez que l’élément zéro et deux choses tournent mal : une clé qui n’apparaît qu’à partir de l’élément trois est entièrement absente de properties, et une clé présente par hasard dans l’élément zéro mais absente ensuite est marquée required, si bien que le schéma refuse des données que vous savez valides. Le comportement correct est l’union des clés pour properties et l’intersection pour required, avec les types unis clé par clé.
C’est ce que fait le générateur de schéma. Donnez-lui une page de sortie d’API réelle plutôt qu’un exemple tapé à la main, puis modifiez le résultat : resserrez les chaînes dont le format est connu, ajoutez enum là où l’ensemble est fermé, et tranchez la question d’additionalProperties objet par objet. Une sortie générée est un brouillon, pas un contrat.
Les nombres méritent aussi un coup d’œil. Un générateur voit 9007199254740993 et écrit "type": "integer", ce qui est honnête sur le type et muet sur le fait que JavaScript ne peut pas contenir cette valeur.
Où l’effort est rentable
Les contrats d’API, où le schéma est ce contre quoi le producteur teste et ce avec quoi le consommateur valide, si bien qu’un changement cassant échoue en CI plutôt que dans les journaux d’un client. La validation de configuration, où une faute de frappe dans un fichier de déploiement devient une erreur avec un JSON Pointer vers la clé fautive au lieu d’un déréférencement de null trois services plus loin. Et la génération de code, où une définition produit à la fois la documentation de référence et les types TypeScript, de sorte que les trois ne puissent pas diverger.
Rien de tout cela n’arrive parce qu’on écrit un schéma. Cela arrive parce qu’on écrit un schéma qu’un validateur correctement câblé applique réellement.