Validate an API payload against its contract
Check that an incoming request matches what the API’s OpenAPI document declares for that endpoint.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the contract is already executable and this level runs in front of every request, where only an immediate, replayable answer belongs. Three details of the snippet are worth copying, because each is a way of being quietly wrong: the exact path beats the template, or “/factures/resume” gets validated against an invoice schema; OpenAPI 3.0’s “nullable: true” is translated, or jsonschema refuses a null the contract allows while ajv accepts it; and the compiled document is kept under the document’s identity, never under its contents, or building the cache key costs more than the validation it saves, and costs it on every request. What the contract cannot do is stated rather than implied: it describes the shape of the body, not the arithmetic it has to satisfy.
Operation found in the document, body validated against its schema
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Check a request body against the OpenAPI document that describes the endpoint.
Rung N0. An OpenAPI document is the contract, and inside it every request body
is a JSON Schema. So validating a payload is two lookups and one validation:
find the operation the request is aimed at, take the schema it declares, apply
it. All three are exact, and the same document validates the same way on the
client side and on the server side.
The operation is found the way the standard says: an exact path wins over a
templated one, so `/factures/resume` is the summary endpoint and not an invoice
whose identifier happens to be « resume ». Getting that precedence wrong
validates a request against the wrong schema and says yes.
References are left to the validator. Putting the whole document at the root of
the schema and pointing at the operation with a JSON Pointer means every
`$ref: '#/components/schemas/…'` inside it resolves on its own, however deep
and however recursive.
One count is normalised. The two libraries do not report an unexpected
property the same way — `ajv` gives one failure per property, `jsonschema` a
single one for all of them — so `additionalProperties` failures at the same
place are collapsed into one. Every other rule is left as it comes.
One conversion is done by hand, because the two libraries disagree about it.
OpenAPI 3.0 writes an optional null as `nullable: true`, which is not JSON
Schema: `ajv` honours it, `jsonschema` ignores it and refuses a null the
contract allows. It is rewritten here into the `type` the standard uses, so
both languages answer the same thing.
"""
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
/**
* Check a request body against the OpenAPI document that describes the endpoint.
*
* Rung N0. An OpenAPI document is the contract, and inside it every request
* body is a JSON Schema. So validating a payload is two lookups and one
* validation: find the operation the request is aimed at, take the schema it
* declares, apply it. All three are exact, and the same document validates the
* same way on the client side and on the server side.
*
* The operation is found the way the standard says: an exact path wins over a
* templated one, so `/factures/resume` is the summary endpoint and not an
* invoice whose identifier happens to be « resume ». Getting that precedence
* wrong validates a request against the wrong schema and says yes.
*
* References are left to the validator. Putting the whole document at the root
* of the schema and pointing at the operation with a JSON Pointer means every
* `$ref: '#/components/schemas/…'` inside it resolves on its own, however deep
* and however recursive.
*
* One count is normalised. The two libraries do not report an unexpected
* property the same way — `ajv` gives one failure per property, `jsonschema` a
* single one for all of them — so `additionalProperties` failures at the same
* place are collapsed into one. Every other rule is left as it comes.
*
* One conversion is done by hand, because the two libraries disagree about it.
* OpenAPI 3.0 writes an optional null as `nullable: true`, which is not JSON
* Schema: `ajv` honours it, `jsonschema` ignores it and refuses a null the
* contract allows. It is rewritten here into the `type` the standard uses, so
* both languages answer the same thing.
*/
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,
});Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
The same OpenAPI document for every case; only the request changes. The outputs are the snippet’s own, run when the site is built.
A conforming invoice, with a null company number the contract allows
Given
OpenAPI 3.0’s “nullable: true” is translated before validation.
What it returns
POST /factures { "reference": "FA-000123", "montant_ht": 1000, "tva": 200, "montant_ttc": 1200, "client": { "nom": "Boulangerie Martin", "siren": null } }
What it concludes
Matches — post /factures
OpenAPI 3.0’s “nullable: true” is translated before validation.
In detail
Path Rule An amount as text and a reference too short
Given
Two errors, each with its path and the rule it broke.
What it returns
POST /factures { "reference": "FA-12", "montant_ht": "1 000,00", "tva": 200, "montant_ttc": 1200 }
What it concludes
2 error(s) — post /factures
Two errors, each with its path and the rule it broke.
In detail
Path Rule /montant_ht type /reference pattern The summary endpoint
Given
“/factures/resume” is not an invoice called “resume”: the exact path wins.
What it returns
GET /factures/resume null
What it concludes
Matches — get /factures/resume
“/factures/resume” is not an invoice called “resume”: the exact path wins.
In detail
Path Rule A path the document does not declare
Given
Named as such, and not confused with an invalid body.
What it returns
POST /clients {}
What it concludes
Outside the contract
no path in the document matches /clients
In detail
Path Rule An invoice whose amounts do not add up
Given
1,000 plus 200 of tax, for a declared total of 999.
What it returns
POST /factures { "reference": "FA-000123", "montant_ht": 1000, "tva": 200, "montant_ttc": 999 }
What it concludes
Matches — post /factures
1,000 plus 200 of tax, for a declared total of 999.
In detail
Path Rule Here it breaksThe request conforms, and it is wrong. No JSON Schema keyword relates two fields to each other: the contract describes the shape of the body, not the arithmetic it has to satisfy. That rule belongs in your business code, and it is the only place it can live.
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the request is checked inside your own process and is sent to nobody
- The error report names the offending paths, not the values: what it logs does not contain the request body
Breaking point
An OpenAPI contract cannot add up: an invoice declaring 1,000 before tax, 200 of tax and 999 as the total conforms perfectly, because no JSON Schema keyword relates two fields to each other. The witness is in the same test: the same amount written “1 000,00” is refused, with its path and its rule.
When to move up a level
There is no level above this one. The day the rule to check relates two fields, or depends on what your database holds, it belongs in your business code — that is the only place it can live, and no contract replaces it there.
The four options
- RecommendedN0Operation found in the document, body validated against its schemaFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
There is nothing to learn: the document is the knowledge, negotiated between two teams, and the validator only applies it. A classic model trained on requests labelled valid or not would learn the shape of the requests already received, while a contract exists to refuse what has never been seen — and the one thing it could not return is the only thing that helps: the exact path of the offending value.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same argument, with a resident model added on the path of an HTTP request. The decisive constraint is budget: this check runs before the handler, on every call, and it has to cost a fraction of what the handler costs. This entry measures it in the lowest latency class the site uses; an inference, however small, does not fit there.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
One model call per incoming request adds a network round trip in front of every handler, bills every call, and sends a third party the body of a request your client entrusted to you. It also returns an opinion, where the gateway needs a path and a rule to write the 400 response the client will have to read — and two calls on the same request may not name the same fields.
Further reading
- OpenAPI 3.1 — la spécification, et son alignement sur JSON Schema 2020-12
- OpenAPI 3.0 — le mot-clé nullable, et ce qui le distingue de JSON Schema
- ajv — la bibliothèque JavaScript, et ses points d’entrée par dialecte
- jsonschema — la bibliothèque Python
Where the figures come from
- OpenAPI 3.1.0, section « Paths Object » (consultée) : la résolution d’un chemin, et la précédence d’un segment concret sur un segment gabarité
- OpenAPI 3.0.3, « Schema Object » (consultée) : « nullable » est une extension propre à OpenAPI 3.0, absente de JSON Schema
- jsonschema sur PyPI (consulté) : version 4.26.0, publiée le 7 janvier 2026, licence MIT
- ajv, documentation des dialectes (consultée) : le point d’entrée par défaut est draft-07, et le dialecte 2020-12 s’importe depuis « ajv/dist/2020 »
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.