Valider un fichier importé contre un schéma

Vérifier qu’un fichier JSON reçu d’un partenaire respecte le contrat convenu, et dire précisément ce qui ne va pas.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que le contrat est déjà exécutable et que le validateur est le même des deux côtés de l’échange. Trois choses valent d’être copiées de l’extrait, parce qu’aucune n’est le réglage par défaut : les formats sont assertés — sinon `jsonschema` accepte « 2026-02-30 » sans un mot, et `ajv` refuse de compiler le schéma —, toutes les erreurs sont collectées et non la première, et le schéma compilé est gardé, ce qui fait gagner deux ordres de grandeur en Python et davantage en JavaScript sur la mesure du relevé. Le point de rupture n’est pas dans le validateur mais dans le modèle de données : un montant décimal ne s’exprime pas dans un flottant binaire, et il s’écrit en centimes entiers.

JSON Schema 2020-12, formats assertés, toutes les erreurs rendues

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/validate-an-imported-file-against-a-schema/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Valider un document importé contre le schéma qu'il est censé suivre.

Niveau N0. JSON Schema est une norme publiée, et un validateur est un
programme qui l'applique : chaque échec revient avec l'endroit où il s'est
produit et la règle qu'il a enfreinte. Il n'y a rien à deviner et rien à
apprendre — un schéma est le contrat, écrit une fois, et le même document se
valide toujours de la même façon.

`jsonschema` l'applique ici, `ajv` en JavaScript. Deux choses qu'ils font tous les
deux et qui surprennent, et que ce fichier règle au lieu d'en hériter.

`format` est une annotation, pas une assertion. Les deux bibliothèques y
réagissent différemment, et les deux réactions surprennent quelqu'un :
`jsonschema` accepte « 12/01/2026 » et « 2026-02-30 » contre
`"format": "date"` sans un mot, tandis qu'`ajv` refuse purement et simplement
de compiler le schéma tant que les formats ne sont pas enregistrés. Les
enregistrer, plus bas, est ce qui met les deux d'accord — et fait refuser ces
deux dates aux deux.

Et un validateur s'arrête au premier échec tant qu'on ne lui dit pas le
contraire. Un import se vérifie une fois et se corrige une fois : toutes les
erreurs sont donc collectées, plafonnées, et le rapport dit combien ont été
laissées de côté plutôt que de faire comme s'il n'y en avait pas.

Ce qui revient, c'est un chemin et un nom de règle par échec. La formulation
du message appartient à la bibliothèque et diffère entre les deux ; le chemin
et la règle sont ce sur quoi votre code doit se brancher.
"""

from __future__ import annotations

import json

from jsonschema import Draft202012Validator, FormatChecker

# A file with one systematic mistake produces one error per record. Two hundred
# is enough to see the pattern; the count says how many more there were.
MAX_ERRORS = 200

# Compiling a schema costs far more than checking one document against it, and
# an import checks thousands against the same schema. The compiled form is kept
# here, keyed by the schema itself; the cap is what stops a caller that builds
# a new schema per record from filling memory.
_COMPILED: dict[str, object] = {}
MAX_COMPILED = 32


def validate_import(document, schema) -> dict:
    """
    Check `document` against `schema`, and return every failure.

    `errors` carries, for each one, the JSON Pointer of the value and the name
    of the rule it broke — the two things a caller can act on — plus the
    library's own message, which is for a human to read and not for code to
    match on.

    A schema that is itself invalid is reported as such rather than raised:
    on an import path, a broken contract is an incident to log, not a crash.
    """
    validator = _compiled(schema)
    if validator is None:
        return _report(False, [], 0, "this schema is not usable")
    try:
        failures = sorted(validator.iter_errors(document), key=_order)
    except Exception:  # noqa: BLE001 - a broken contract is an answer too
        return _report(False, [], 0, "this schema is not usable")
    kept = [{"path": _pointer(error.absolute_path),
             "rule": str(error.validator),
             "message": error.message}
            for error in failures[:MAX_ERRORS]]
    return _report(not failures, kept, len(failures), None)


def _compiled(schema):
    """The compiled validator for this schema, or None when it is not usable."""
    try:
        key = json.dumps(schema, sort_keys=True)
    except TypeError:
        return None
    if key not in _COMPILED:
        try:
            # The schema itself is checked first: a rule the standard does not
            # define — « objet » for « object » — would otherwise only surface
            # as an exception in the middle of a file, on the first value that
            # uses it.
            Draft202012Validator.check_schema(schema)
            _COMPILED[key] = Draft202012Validator(schema, format_checker=FormatChecker())
        except Exception:  # noqa: BLE001 - a broken contract is an answer too
            _COMPILED[key] = None
        if len(_COMPILED) > MAX_COMPILED:
            del _COMPILED[next(iter(_COMPILED))]
    return _COMPILED[key]


def _order(error):
    """
    A single order, in both languages: by place in the document, then by rule.
    An index sorts as a number, so record 10 comes after record 2 and not
    after record 1 as a string comparison would have it.
    """
    steps = [(0, step, "") if isinstance(step, int) else (1, 0, str(step))
             for step in error.absolute_path]
    return (steps, str(error.validator))


def _pointer(path) -> str:
    """The JSON Pointer of a value, which is what both libraries can produce."""
    return "".join(f"/{str(step).replace('~', '~0').replace('/', '~1')}" for step in path)


def _report(valid: bool, errors: list, total: int, reason: str | None) -> dict:
    return {"valid": valid, "errors": errors, "error_count": total,
            "truncated": total > len(errors), "reason": reason}

JavaScript

snippets/validate-an-imported-file-against-a-schema/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Valider un document importé contre le schéma qu'il est censé suivre.
 *
 * Niveau N0. JSON Schema est une norme publiée, et un validateur est un
 * programme qui l'applique : chaque échec revient avec l'endroit où il s'est
 * produit et la règle qu'il a enfreinte. Il n'y a rien à deviner et rien à
 * apprendre — un schéma est le contrat, écrit une fois, et le même document se
 * valide toujours de la même façon.
 *
 * `ajv` l'applique ici, `jsonschema` en Python. Deux choses qu'ils font tous les
 * deux et qui surprennent, et que ce fichier règle au lieu d'en hériter.
 *
 * `format` est une annotation, pas une assertion. Les deux bibliothèques y
 * réagissent différemment, et les deux réactions surprennent quelqu'un :
 * `jsonschema` accepte « 12/01/2026 » et « 2026-02-30 » contre
 * `"format": "date"` sans un mot, tandis qu'`ajv` refuse purement et simplement
 * de compiler le schéma tant que les formats ne sont pas enregistrés. Les
 * enregistrer, plus bas, est ce qui met les deux d'accord — et fait refuser ces
 * deux dates aux deux.
 *
 * Et un validateur s'arrête au premier échec tant qu'on ne lui dit pas le
 * contraire. Un import se vérifie une fois et se corrige une fois : toutes les
 * erreurs sont donc collectées, plafonnées, et le rapport dit combien ont été
 * laissées de côté plutôt que de faire comme s'il n'y en avait pas.
 *
 * Ce qui revient, c'est un chemin et un nom de règle par échec. La formulation
 * du message appartient à la bibliothèque et diffère entre les deux ; le chemin
 * et la règle sont ce sur quoi votre code doit se brancher.
 */

// `ajv` still defaults to draft-07; the 2020-12 dialect, the current one, is
// a separate entry point. Importing the default one instead refuses every
// schema that declares 2020-12, which is the shape the standard now publishes.
import AjvModule from 'ajv/dist/2020.js';
import addFormatsModule from 'ajv-formats';

// Both packages are published as CommonJS and carry their class on `default`.
const Ajv = AjvModule.default ?? AjvModule;
const addFormats = addFormatsModule.default ?? addFormatsModule;

// A file with one systematic mistake produces one error per record. Two
// hundred is enough to see the pattern; the count says how many more there were.
export const MAX_ERRORS = 200;

// Compiling a schema costs far more than checking one document against it, and
// an import checks thousands against the same schema. The compiled form is
// kept here, keyed by the schema itself; the cap is what stops a caller that
// builds a new schema per record from filling memory.
const COMPILED = new Map();
export const MAX_COMPILED = 32;

/** The compiled validator for this schema, or null when it is not usable. */
function compiled(schema) {
  let key;
  try {
    key = JSON.stringify(schema) ?? 'undefined';
  } catch {
    return null;
  }
  if (!COMPILED.has(key)) {
    let validate = null;
    try {
      // The schema itself is checked first: a rule the standard does not
      // define — « objet » for « object » — would otherwise only surface as an
      // exception in the middle of a file, on the first value that uses it.
      const ajv = new Ajv({ allErrors: true, strictSchema: false });
      addFormats(ajv);
      validate = ajv.compile(schema);
    } catch {
      validate = null;
    }
    COMPILED.set(key, validate);
    if (COMPILED.size > MAX_COMPILED) COMPILED.delete(COMPILED.keys().next().value);
  }
  return COMPILED.get(key);
}

/**
 * Check `document` against `schema`, and return every failure.
 *
 * `errors` carries, for each one, the JSON Pointer of the value and the name
 * of the rule it broke — the two things a caller can act on — plus the
 * library's own message, which is for a human to read and not for code to
 * match on.
 *
 * A schema that is itself invalid is reported as such rather than thrown: on
 * an import path, a broken contract is an incident to log, not a crash.
 *
 * @param {unknown} document
 * @param {object} schema
 */
export function validateImport(document, schema) {
  const validate = compiled(schema);
  if (!validate) return report(false, [], 0, 'this schema is not usable');

  validate(document);
  const failures = [...(validate.errors ?? [])].sort(order);
  const kept = failures.slice(0, MAX_ERRORS).map((error) => ({
    path: error.instancePath,
    rule: error.keyword,
    message: error.message,
  }));
  return report(failures.length === 0, kept, failures.length, null);
}

/**
 * A single order, in both languages: by place in the document, then by rule.
 * An index sorts as a number, so record 10 comes after record 2 and not after
 * record 1 as a string comparison would have it.
 */
function order(a, b) {
  const steps = (error) => error.instancePath.split('/').slice(1)
    .map((step) => step.replace(/~1/g, '/').replace(/~0/g, '~'))
    .map((step) => (/^\d+$/.test(step) ? [0, Number(step), ''] : [1, 0, step]));
  const left = steps(a);
  const right = steps(b);
  for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
    if (i >= left.length) return -1;
    if (i >= right.length) return 1;
    for (let k = 0; k < 3; k += 1) {
      if (left[i][k] < right[i][k]) return -1;
      if (left[i][k] > right[i][k]) return 1;
    }
  }
  return a.keyword < b.keyword ? -1 : a.keyword > b.keyword ? 1 : 0;
}

const report = (valid, errors, total, reason) => ({
  valid, errors, error_count: total, truncated: total > errors.length, reason,
});

Essayer

Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.

Le même contrat d’import pour tous les cas, sauf le dernier qui n’a qu’un montant. Les sorties sont celles de l’extrait, exécutées à la construction du site.

  1. Une facture conforme au contrat

    Donné

    Rien à signaler : les quatre champs sont là et bien typés.

    Ce qu’il renvoie

    { "reference": "FA-000123", "montant": 1250, "date": "2026-01-12", "client": "Boulangerie Martin" }

    Ce qu’il en conclut

    Valide

    Rien à signaler : les quatre champs sont là et bien typés.

    Le détail

    CheminRègleMessage
  2. Un nom de champ mal orthographié

    Donné

    « montnat » au lieu de « montant » : le schéma fermé le nomme au lieu de le perdre.

    Ce qu’il renvoie

    { "reference": "FA-000123", "montnat": 1250, "montant": 0, "date": "2026-01-12" }

    Ce qu’il en conclut

    1 erreur(s)

    « montnat » au lieu de « montant » : le schéma fermé le nomme au lieu de le perdre.

    Le détail

    CheminRègleMessage
    (la racine)additionalPropertiesmust NOT have additional properties
  3. Une date qui n’existe pas

    Donné

    Le 30 février : refusé, parce que le contrôle de format est allumé.

    Ce qu’il renvoie

    { "reference": "FA-000123", "montant": 1250, "date": "2026-02-30" }

    Ce qu’il en conclut

    1 erreur(s)

    Le 30 février : refusé, parce que le contrôle de format est allumé.

    Le détail

    CheminRègleMessage
    /dateformatmust match format "date"
  4. Trois erreurs dans le même enregistrement

    Donné

    Toutes rendues, rangées par place dans le document, pas seulement la première.

    Ce qu’il renvoie

    { "reference": "FA-12", "montant": "1 250,00", "date": "12/01/2026" }

    Ce qu’il en conclut

    3 erreur(s)

    Toutes rendues, rangées par place dans le document, pas seulement la première.

    Le détail

    CheminRègleMessage
    /dateformatmust match format "date"
    /montanttypemust be number
    /referencepatternmust match pattern "^FA-[0-9]{6}$"
  5. Un montant ordinaire contre « multipleOf: 0.01 »

    Donné

    Le montant 19,99 : parfaitement ordinaire, et refusé.

    Ce qu’il renvoie

    19.99

    Ce qu’il en conclut

    1 erreur(s)

    Le montant 19,99 : parfaitement ordinaire, et refusé.

    Le détail

    CheminRègleMessage
    (la racine)multipleOfmust be multiple of 0.01

    Là, ça casse« multipleOf: 0.01 » est la façon naturelle d’écrire « des euros et des centimes », et elle refuse 1 363 des 10 000 montants de 0,00 à 99,99 — dont 19,99, 4,35 et 1 234,56 — parce qu’un nombre JSON est un flottant binaire et que 0,01 ne s’y écrit pas exactement. La réponse n’est pas un autre validateur : c’est d’écrire le montant en centimes entiers, ce que les mêmes 10 000 valeurs passent sans une erreur.

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’une bibliothèque
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le fichier est lu dans votre processus et n’est transmis à personne
  • Le rapport d’erreurs porte les valeurs refusées : ce qu’il journalise est ce que contenait le fichier

Point de rupture

Un nombre JSON est un flottant binaire, et « multipleOf: 0.01 » — la façon naturelle d’écrire un montant en euros et centimes — refuse 1 363 des 10 000 montants de 0,00 à 99,99, dont 19,99, 4,35 et 1 234,56. Le témoin est dans le même test : les mêmes 10 000 valeurs, écrites en centimes entiers, passent toutes.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus. Le jour où la règle à vérifier n’est plus dans le fichier — ce SIREN existe-t-il, ce client est-il à jour de ses paiements —, ce n’est plus une validation de schéma, c’est une consultation.

Les quatre options

  1. RecommandéN0JSON Schema 2020-12, formats assertés, toutes les erreurs renduesGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Niveau sans objet ici

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Il n’y a rien à apprendre : le schéma est le savoir, écrit à la main parce que c’est un accord entre deux parties, et le validateur ne fait que l’appliquer. Un modèle classique entraîné sur des fichiers étiquetés valides ou non apprendrait la forme des fichiers que vous avez déjà reçus, ce qui est l’inverse du besoin : un contrat sert justement à refuser ce qu’on n’a jamais vu.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Même argument, et une objection de forme : ce niveau doit rendre un chemin exact — « /4321/montant » — et le nom d’une règle, parce que c’est ce qu’un opérateur corrige. Un petit modèle spécialisé rend un score sur un document, pas une adresse dans ce document, et il faudrait de toute façon écrire le schéma pour savoir sur quoi l’entraîner.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

Un modèle généraliste sait lire un schéma et un document, et il rendra un avis vraisemblable sur leur accord. Ce que la fiche lui reproche est ailleurs : il faudrait lui envoyer le fichier — des données d’un partenaire, souvent personnelles —, payer un appel par enregistrement sur des lots de dizaines de milliers de lignes, et accepter que deux passages sur le même fichier puissent ne pas rendre la même liste d’erreurs. Le validateur, lui, est le même programme que celui du partenaire qui a produit le fichier.

Pour aller plus loin

Source des chiffres

Métadonnées