Valider une requête d’API contre son contrat

Vérifier qu’une requête reçue correspond à ce que le document OpenAPI de l’API déclare pour ce point d’accès.

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 ce niveau tourne devant chaque requête, où seule une réponse immédiate et rejouable a sa place. Trois détails de l’extrait valent d’être copiés, parce qu’ils sont chacun une façon de se tromper en silence : le chemin exact l’emporte sur le gabarit, sans quoi « /factures/resume » est validé contre le schéma d’une facture ; le « nullable: true » d’OpenAPI 3.0 est traduit, sans quoi `jsonschema` refuse un null que le contrat permet et `ajv` l’accepte ; et le document compilé est gardé sous l’identité du document, jamais sous son contenu, sans quoi fabriquer la clé du cache coûte plus que la validation qu’elle épargne, et le coûte à chaque requête. Ce que le contrat ne peut pas faire est dit plutôt que sous-entendu : il décrit la forme du corps, pas l’arithmétique qu’il doit respecter.

Opération retrouvée dans le document, corps validé contre son schéma

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/validate-an-api-payload-against-its-contract/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Contrôler le corps d'une requête contre le document OpenAPI qui décrit le
point d'accès.

Niveau N0. Un document OpenAPI est le contrat, et à l'intérieur chaque corps
de requête est un schéma JSON. Valider une charge utile, c'est donc deux
recherches et une validation : trouver l'opération visée par la requête,
prendre le schéma qu'elle déclare, l'appliquer. Les trois sont exactes, et le
même document se valide de la même façon côté client et côté serveur.

L'opération est trouvée comme la norme le dit : un chemin exact l'emporte sur
un chemin gabarité, si bien que « /factures/resume » est le point d'accès de
synthèse et non une facture dont l'identifiant serait « resume ». Se tromper
sur cette priorité, c'est valider une requête contre le mauvais schéma et
répondre oui.

Les références sont laissées au validateur. Poser tout le document à la
racine du schéma et désigner l'opération par un pointeur JSON suffit à ce que
chaque « $ref: '#/components/schemas/…' » se résolve tout seul, aussi profond
et aussi récursif soit-il.

Un décompte est normalisé. Les deux bibliothèques ne signalent pas de la même
façon une propriété en trop — `ajv` en rend une par propriété, `jsonschema` une
seule pour toutes — si bien que les échecs « additionalProperties » au même
endroit sont ramenés à un. Toutes les autres règles restent telles quelles.

Une conversion est faite à la main, parce que les deux bibliothèques ne sont
pas d'accord dessus. OpenAPI 3.0 écrit un null facultatif « nullable: true »,
ce qui n'est pas du JSON Schema : `ajv` l'honore, `jsonschema` l'ignore et
refuse un null que le contrat permet. Il est réécrit ici dans le « type » que
la norme emploie, pour que les deux langages répondent la même chose.
"""

from __future__ import annotations

from jsonschema import Draft202012Validator, FormatChecker

# The methods an operation can be written under, as OpenAPI spells them.
METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")

MAX_ERRORS = 200

# This runs on a request path, and compiling a document costs more than
# checking one body against it — far more in JavaScript, where `ajv` generates
# code, than in Python, where `jsonschema` interprets. The compiled validators
# are kept here.
#
# Keyed by the **identity** of the document, never by its contents. Serialising
# the contract to build a cache key would undo the cache and more: on a
# hundred-and-twenty-nine-kilobyte document — four hundred paths, an ordinary
# enterprise API — `json.dumps(document, sort_keys=True)` took thirty times the
# validation it saves, and it took it on every request. A document is an object
# the caller loads once at start-up; its identity is what tells two of them
# apart, and the entry keeps a reference to it, which is what stops its id from
# being reused by something else.
#
# The cap is what stops a caller that builds a new document per request from
# filling memory: the oldest document goes first.
_COMPILED: dict = {}
MAX_COMPILED = 64


def validate_request(document, method: str, path: str, body) -> dict:
    """
    Say whether `body` matches what the document declares for this endpoint.

    `operation` in the report is the operation that was matched, so a caller
    can see which schema the answer came from: a request validated against the
    wrong path is a yes that means nothing.

    `error_count` counts what this function kept, not what the validator
    raised: two `additionalProperties` failures at the same path are one entry
    here, because the two libraries do not report them the same way and this
    entry answers the same thing in both languages.
    """
    if not isinstance(document, dict) or not isinstance(path, str):
        return _report(False, None, [], 0, "this document is not usable")

    template = _match_path(document.get("paths") or {}, path)
    if template is None:
        return _report(False, None, [], 0, f"no path in the document matches {path}")
    operation = (document["paths"][template] or {}).get(str(method).lower())
    if not isinstance(operation, dict):
        return _report(False, None, [], 0, f"{method} is not declared on {template}")

    name = f"{str(method).lower()} {template}"
    content = ((operation.get("requestBody") or {}).get("content") or {})
    schema = (content.get("application/json") or {}).get("schema")
    if schema is None:
        # No body declared: anything sent is outside the contract, and the
        # contract is what this function answers about.
        return _report(body is None, name, [], 0, None if body is None else "no body is declared")

    pointer = (f"#/paths/{_escape(template)}/{str(method).lower()}"
               f"/requestBody/content/{_escape('application/json')}/schema")
    validator = _compiled(document, pointer)
    if validator is None:
        return _report(False, name, [], 0, "this document is not usable")
    try:
        failures = sorted(validator.iter_errors(body),
                          key=lambda e: (_pointer_of(e.absolute_path), str(e.validator)))
    except Exception:  # noqa: BLE001 - a broken contract is an answer too
        return _report(False, name, [], 0, "this document is not usable")

    kept = []
    for error in failures:
        entry = {"path": _pointer_of(error.absolute_path), "rule": str(error.validator)}
        if entry["rule"] == "additionalProperties" and entry in kept:
            continue
        kept.append(entry)
    return _report(not kept, name, kept[:MAX_ERRORS], len(kept), None)


def _compiled(document, pointer: str):
    """The compiled validator for this operation, or None when it is not usable."""
    held, validators = _COMPILED.get(id(document), (None, None))
    if held is not document:
        validators = {}
        _COMPILED[id(document)] = (document, validators)
        if len(_COMPILED) > MAX_COMPILED:
            del _COMPILED[next(iter(_COMPILED))]
    if pointer not in validators:
        try:
            root = {**_without_nullable(document), "$ref": pointer}
            validators[pointer] = Draft202012Validator(root, format_checker=FormatChecker())
        except Exception:  # noqa: BLE001 - a broken contract is an answer too
            validators[pointer] = None
    return validators[pointer]


def _match_path(paths, path: str) -> str | None:
    """
    The path template this request belongs to, exact match winning.

    When two templates of the same length still match — `/factures/{id}` and
    `/{ressource}/{id}` — the one with the fewest variables wins, then the one
    whose first concrete segment comes earliest. Sorting the strings would
    settle it by the order of `{` in ASCII, which is an accident, and this
    entry makes precedence one of its three arguments.
    """
    if path in paths:
        return path
    wanted = path.strip("/").split("/")

    def precedence(template: str):
        steps = template.strip("/").split("/")
        variables = sum(1 for s in steps if s.startswith("{") and s.endswith("}"))
        concret = next((i for i, s in enumerate(steps)
                        if not (s.startswith("{") and s.endswith("}"))), len(steps))
        return (variables, concret, template)

    for template in sorted(paths, key=precedence):
        steps = template.strip("/").split("/")
        if len(steps) != len(wanted):
            continue
        if all(s.startswith("{") and s.endswith("}") or s == w for s, w in zip(steps, wanted)):
            return template
    return None


def _without_nullable(value):
    """OpenAPI 3.0's `nullable: true`, rewritten into the type JSON Schema uses."""
    if isinstance(value, list):
        return [_without_nullable(item) for item in value]
    if not isinstance(value, dict):
        return value
    out = {key: _without_nullable(item) for key, item in value.items() if key != "nullable"}
    if value.get("nullable") is True and isinstance(out.get("type"), str):
        out["type"] = [out["type"], "null"]
    return out


def _escape(step: str) -> str:
    return step.replace("~", "~0").replace("/", "~1")


def _pointer_of(path) -> str:
    return "".join(f"/{_escape(str(step))}" for step in path)


def _report(valid, operation, errors, total, reason) -> dict:
    return {"valid": valid, "operation": operation, "errors": errors,
            "error_count": total, "truncated": total > len(errors), "reason": reason}

JavaScript

snippets/validate-an-api-payload-against-its-contract/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Contrôler le corps d'une requête contre le document OpenAPI qui décrit le
 * point d'accès.
 *
 * Niveau N0. Un document OpenAPI est le contrat, et à l'intérieur chaque corps
 * de requête est un schéma JSON. Valider une charge utile, c'est donc deux
 * recherches et une validation : trouver l'opération visée par la requête,
 * prendre le schéma qu'elle déclare, l'appliquer. Les trois sont exactes, et le
 * même document se valide de la même façon côté client et côté serveur.
 *
 * L'opération est trouvée comme la norme le dit : un chemin exact l'emporte sur
 * un chemin gabarité, si bien que « /factures/resume » est le point d'accès de
 * synthèse et non une facture dont l'identifiant serait « resume ». Se tromper
 * sur cette priorité, c'est valider une requête contre le mauvais schéma et
 * répondre oui.
 *
 * Les références sont laissées au validateur. Poser tout le document à la
 * racine du schéma et désigner l'opération par un pointeur JSON suffit à ce que
 * chaque « $ref: '#/components/schemas/…' » se résolve tout seul, aussi profond
 * et aussi récursif soit-il.
 *
 * Un décompte est normalisé. Les deux bibliothèques ne signalent pas de la même
 * façon une propriété en trop — `ajv` en rend une par propriété, `jsonschema` une
 * seule pour toutes — si bien que les échecs « additionalProperties » au même
 * endroit sont ramenés à un. Toutes les autres règles restent telles quelles.
 *
 * Une conversion est faite à la main, parce que les deux bibliothèques ne sont
 * pas d'accord dessus. OpenAPI 3.0 écrit un null facultatif « nullable: true »,
 * ce qui n'est pas du JSON Schema : `ajv` l'honore, `jsonschema` l'ignore et
 * refuse un null que le contrat permet. Il est réécrit ici dans le « type » que
 * la norme emploie, pour que les deux langages répondent la même chose.
 */

import AjvModule from 'ajv/dist/2020.js';
import addFormatsModule from 'ajv-formats';

const Ajv = AjvModule.default ?? AjvModule;
const addFormats = addFormatsModule.default ?? addFormatsModule;

// The methods an operation can be written under, as OpenAPI spells them.
export const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];

export const MAX_ERRORS = 200;

// This runs on a request path, and compiling a document costs more than
// checking one body against it — far more here, where `ajv` generates code,
// than in Python, where `jsonschema` interprets. The compiled validators are
// kept here.
//
// Keyed by the **identity** of the document, never by its contents.
// Serialising the contract to build a cache key would undo the cache and more:
// on a hundred-and-twenty-nine-kilobyte document — four hundred paths, an
// ordinary enterprise API — `JSON.stringify(document)` took four fifths of the
// time of a request, and it took it on every one. A document is an object the
// caller loads once at start-up, so a `WeakMap` holds it: nothing here keeps a
// document alive, and nothing has to be evicted.
//
// The cap is per document, on the operations compiled from it.
const COMPILED = new WeakMap();
export const MAX_COMPILED = 64;

/** The compiled validator for this operation, or null when it is not usable. */
function compiled(document, pointer) {
  let validators = COMPILED.get(document);
  if (!validators) {
    validators = new Map();
    COMPILED.set(document, validators);
  }
  if (!validators.has(pointer)) {
    let validate = null;
    try {
      const ajv = new Ajv({ allErrors: true, strictSchema: false });
      addFormats(ajv);
      validate = ajv.compile({ ...withoutNullable(document), $ref: pointer });
    } catch {
      validate = null;
    }
    validators.set(pointer, validate);
    if (validators.size > MAX_COMPILED) validators.delete(validators.keys().next().value);
  }
  return validators.get(pointer);
}

/**
 * Say whether `body` matches what the document declares for this endpoint.
 *
 * `operation` in the report is the operation that was matched, so a caller can
 * see which schema the answer came from: a request validated against the wrong
 * path is a yes that means nothing.
 *
 * `error_count` counts what this function kept, not what the validator raised:
 * two `additionalProperties` failures at the same path are one entry here,
 * because the two libraries do not report them the same way and this entry
 * answers the same thing in both languages.
 *
 * @param {object} document  the parsed OpenAPI document
 * @param {string} method
 * @param {string} path
 * @param {unknown} body
 */
export function validateRequest(document, method, path, body) {
  if (typeof document !== 'object' || document === null || typeof path !== 'string') {
    return report(false, null, [], 0, 'this document is not usable');
  }

  const paths = document.paths ?? {};
  const template = matchPath(paths, path);
  if (template === null) {
    return report(false, null, [], 0, `no path in the document matches ${path}`);
  }
  const verb = String(method).toLowerCase();
  const operation = (paths[template] ?? {})[verb];
  if (typeof operation !== 'object' || operation === null) {
    return report(false, null, [], 0, `${method} is not declared on ${template}`);
  }

  const name = `${verb} ${template}`;
  const schema = operation.requestBody?.content?.['application/json']?.schema;
  if (schema === undefined) {
    // No body declared: anything sent is outside the contract, and the
    // contract is what this function answers about.
    const nothing = body === null || body === undefined;
    return report(nothing, name, [], 0, nothing ? null : 'no body is declared');
  }

  const pointer = `#/paths/${escape(template)}/${verb}`
    + `/requestBody/content/${escape('application/json')}/schema`;
  const validate = compiled(document, pointer);
  if (!validate) return report(false, name, [], 0, 'this document is not usable');

  validate(body);
  const key = (error) => [error.instancePath, error.keyword];
  const failures = [...(validate.errors ?? [])].sort((a, b) => (
    key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0
  ));
  const kept = [];
  for (const error of failures) {
    const entry = { path: error.instancePath, rule: error.keyword };
    const deja = kept.some((k) => k.path === entry.path && k.rule === entry.rule);
    if (entry.rule === 'additionalProperties' && deja) continue;
    kept.push(entry);
  }
  return report(kept.length === 0, name, kept.slice(0, MAX_ERRORS), kept.length, null);
}

/** The path template this request belongs to, exact match winning. */
function matchPath(paths, path) {
  if (path in paths) return path;
  const wanted = path.replace(/^\/|\/$/g, '').split('/');
  const variable = (step) => step.startsWith('{') && step.endsWith('}');
  // When two templates of the same length still match — `/factures/{id}` and
  // `/{ressource}/{id}` — the one with the fewest variables wins, then the one
  // whose first concrete segment comes earliest. Sorting the strings would
  // settle it by the order of `{` in ASCII, which is an accident, and this
  // entry makes precedence one of its three arguments.
  const precedence = (template) => {
    const steps = template.replace(/^\/|\/$/g, '').split('/');
    const concret = steps.findIndex((step) => !variable(step));
    return [steps.filter(variable).length, concret < 0 ? steps.length : concret, template];
  };
  const ordonnes = Object.keys(paths).sort((a, b) => {
    const [va, ca, ta] = precedence(a);
    const [vb, cb, tb] = precedence(b);
    return va - vb || ca - cb || (ta < tb ? -1 : ta > tb ? 1 : 0);
  });
  for (const template of ordonnes) {
    const steps = template.replace(/^\/|\/$/g, '').split('/');
    if (steps.length !== wanted.length) continue;
    const fits = steps.every((step, i) => (
      (step.startsWith('{') && step.endsWith('}')) || step === wanted[i]
    ));
    if (fits) return template;
  }
  return null;
}

/** OpenAPI 3.0's `nullable: true`, rewritten into the type JSON Schema uses. */
function withoutNullable(value) {
  if (Array.isArray(value)) return value.map(withoutNullable);
  if (typeof value !== 'object' || value === null) return value;
  const out = {};
  for (const [key, item] of Object.entries(value)) {
    if (key !== 'nullable') out[key] = withoutNullable(item);
  }
  if (value.nullable === true && typeof out.type === 'string') out.type = [out.type, 'null'];
  return out;
}

const escape = (step) => step.replaceAll('~', '~0').replaceAll('/', '~1');

const report = (valid, operation, errors, total, reason) => ({
  valid, operation, 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 document OpenAPI pour tous les cas ; seule la requête change. Les sorties sont celles de l’extrait, exécutées à la construction du site.

  1. Une facture conforme, avec un SIREN nul permis par le contrat

    Donné

    Le « nullable: true » d’OpenAPI 3.0 est traduit avant la validation.

    Ce qu’il renvoie

    POST /factures { "reference": "FA-000123", "montant_ht": 1000, "tva": 200, "montant_ttc": 1200, "client": { "nom": "Boulangerie Martin", "siren": null } }

    Ce qu’il en conclut

    Conforme — post /factures

    Le « nullable: true » d’OpenAPI 3.0 est traduit avant la validation.

    Le détail

    CheminRègle
  2. Un montant en texte et une référence trop courte

    Donné

    Deux erreurs, chacune avec son chemin et la règle enfreinte.

    Ce qu’il renvoie

    POST /factures { "reference": "FA-12", "montant_ht": "1 000,00", "tva": 200, "montant_ttc": 1200 }

    Ce qu’il en conclut

    2 erreur(s) — post /factures

    Deux erreurs, chacune avec son chemin et la règle enfreinte.

    Le détail

    CheminRègle
    /montant_httype
    /referencepattern
  3. Le point d’accès de synthèse

    Donné

    « /factures/resume » n’est pas une facture nommée « resume » : le chemin exact gagne.

    Ce qu’il renvoie

    GET /factures/resume null

    Ce qu’il en conclut

    Conforme — get /factures/resume

    « /factures/resume » n’est pas une facture nommée « resume » : le chemin exact gagne.

    Le détail

    CheminRègle
  4. Un chemin que le document ne déclare pas

    Donné

    Nommé comme tel, et non confondu avec un corps invalide.

    Ce qu’il renvoie

    POST /clients {}

    Ce qu’il en conclut

    Hors contrat

    no path in the document matches /clients

    Le détail

    CheminRègle
  5. Une facture dont les montants ne tombent pas juste

    Donné

    1 000 et 200 de TVA, pour un total déclaré de 999.

    Ce qu’il renvoie

    POST /factures { "reference": "FA-000123", "montant_ht": 1000, "tva": 200, "montant_ttc": 999 }

    Ce qu’il en conclut

    Conforme — post /factures

    1 000 et 200 de TVA, pour un total déclaré de 999.

    Le détail

    CheminRègle

    Là, ça casseLa requête est conforme, et elle est fausse. Aucun mot-clé de JSON Schema ne met deux champs en rapport : le contrat décrit la forme du corps, pas l’arithmétique qu’il doit respecter. Cette règle-là s’écrit dans votre code métier, et c’est le seul endroit où elle puisse vivre.

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é : la requête est contrôlée dans votre processus et n’est transmise à personne
  • Le rapport d’erreurs nomme les chemins fautifs, pas les valeurs : ce qu’il journalise ne contient pas le corps de la requête

Point de rupture

Un contrat OpenAPI ne sait pas additionner : une facture déclarant 1 000 hors taxes, 200 de TVA et 999 de total est parfaitement conforme, parce qu’aucun mot-clé de JSON Schema ne met deux champs en rapport. Le témoin est dans le même test : le même montant écrit « 1 000,00 » est refusé, avec son chemin et sa règle.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus. Le jour où la règle à vérifier met deux champs en rapport, ou dépend de ce que contient votre base, elle s’écrit dans votre code métier — c’est le seul endroit où elle puisse vivre, et aucun contrat ne l’y remplace.

Les quatre options

  1. RecommandéN0Opération retrouvée dans le document, corps validé contre son schémaGratuit · 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 document est le savoir, négocié entre deux équipes, et le validateur ne fait que l’appliquer. Un modèle classique entraîné sur des requêtes étiquetées valides ou non apprendrait la forme des requêtes déjà reçues, alors qu’un contrat existe pour refuser ce qu’on n’a jamais vu — et la seule chose qu’il ne pourrait pas rendre est justement la seule qui serve : le chemin exact de la valeur fautive.

N2Niveau sans objet ici

Niveau sans objet ici

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

Même argument, avec un modèle résident en plus sur le chemin d’une requête HTTP. La contrainte décisive est le budget : ce contrôle s’exécute avant le traitement, sur chaque appel, et il doit coûter une fraction de ce que coûte le traitement lui-même. La mesure de cette fiche le place dans la classe de latence la plus basse du site ; une inférence, même petite, ne s’y tient pas.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

Un appel de modèle par requête entrante ajoute un aller-retour réseau devant chaque traitement, facture chaque appel, et fait sortir chez un tiers le corps d’une requête que votre client vous a confié. Il rend en outre un avis, là où la passerelle a besoin d’un chemin et d’une règle pour écrire la réponse 400 que le client devra lire — et deux appels sur la même requête peuvent ne pas nommer les mêmes champs.

Pour aller plus loin

Source des chiffres

Métadonnées