Valider un formulaire côté serveur
Vérifier que les données reçues d’un formulaire sont complètes et cohérentes avant de les enregistrer.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0 n’est pas ici la première marche d’un escalier, c’est la seule qui tienne. Une règle de formulaire est écrite avant d’être appliquée : elle se lit, elle se teste unitairement, et elle se montre à qui la conteste. Les trois niveaux au-dessus échangeraient cette propriété contre une décision qu’on ne peut ni rejouer ni justifier, alors que c’est exactement ce qu’on demande à une validation : pouvoir la rejouer et la justifier.
Schéma déclaratif, un message d’erreur par champ
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Valider un formulaire sur le serveur : un schéma déclaratif, une erreur par
champ.
Niveau N0. Déterministe, bibliothèque standard seule. Les règles restent
lisibles, et chaque refus peut être expliqué à la personne qui a tapé le
formulaire.
Quatre décisions portent la conception.
D'abord, le schéma est une donnée, pas du code. Il peut être écrit à côté du
formulaire, lu par quelqu'un qui n'écrit pas de Python, et confié à la
version JavaScript qui garde le même formulaire dans le navigateur. Un motif
n'a le même sens des deux côtés qu'avec des classes explicites : les
raccourcis d'un chiffre et d'un caractère de mot dépassent l'ASCII en Python
et s'y arrêtent en JavaScript ; écrivez donc [0-9] et [A-Za-z].
Ensuite, la réponse associe un nom de champ à un message, jamais un booléen.
Un formulaire qui répond « non » sans dire quel champ est en faute envoie
l'utilisateur à la chasse, et le développeur dans les journaux.
Enfin, un message par champ : les vérifications s'arrêtent à la première
règle enfreinte. Dire à quelqu'un que son mot de passe est à la fois trop
court *et* mal formé, c'est du bruit ; qu'il corrige la première chose,
renvoie le formulaire, et voie la suivante.
Enfin, les blancs de bord tombent de toute chaîne avant le moindre contrôle,
et une chaîne que cela vide est traitée comme absente. Le navigateur envoie ce
que l'utilisateur a tapé, et la saisie semi-automatique d'un téléphone ajoute
une espace : sans cela, « ada@example.com » précédée d'une espace est refusée
par un motif dont personne ne voit la faute, et un champ obligatoire rempli de
deux espaces passe pour rempli. La norme HTML retire les blancs de la valeur
d'un champ de courriel pour la même raison.
Une chose que le schéma doit porter, et que ce fichier ne peut pas : une
longueur maximale sur chaque chaîne. Un champ sans « max » accepte des
mégaoctets, et le motif s'applique alors à des mégaoctets.
"""
import re
import unicodedata
# The types a form field can hold once decoded. `bool` is excluded from
# `integer` on purpose: in Python a boolean *is* an int, and a checkbox is not
# an age. 36.0 is an integer, as it is in JavaScript.
TYPES = {
"string": lambda v: isinstance(v, str),
"integer": lambda v: not isinstance(v, bool)
and (isinstance(v, int) or (isinstance(v, float) and v.is_integer())),
}
def trimmed(value):
"""A string without its edge whitespace; anything else is left alone."""
return value.strip() if isinstance(value, str) else value
def check(value, rule):
"""Return the first broken rule as a message, or None if the value passes."""
kind = rule.get("type", "string")
if not TYPES[kind](value):
return f"must be of type {kind}"
# For a string the bounds read as a length; for a number, as a value. The
# length counts code points once composed (NFC), as the JavaScript version
# does: "é" counts one however it was typed, and an emoji made of several
# code points, such as a flag, still counts several.
text = unicodedata.normalize("NFC", str(value))
size, unit = (len(text), " characters") if kind == "string" else (value, "")
if "min" in rule and size < rule["min"]:
return f"must be at least {rule['min']}{unit}"
if "max" in rule and size > rule["max"]:
return f"must be at most {rule['max']}{unit}"
if "pattern" in rule and not re.fullmatch(rule["pattern"], text):
return rule.get("message", "is not in the expected format")
return None
def validate(data, schema):
"""
Check a submitted form against a schema, and return {field: message}.
An empty mapping means the form is valid. A missing key, an explicit None,
an empty string and a string of spaces are the same thing here, because
that is what a browser posts for a field the user left alone.
"""
# A field the schema does not declare is refused, not ignored: whoever
# stores the submission would otherwise store it too.
errors = {field: "is not a field of this form" for field in data if field not in schema}
for field, rule in schema.items():
value = trimmed(data.get(field))
if value is None or value == "":
if rule.get("required"):
errors[field] = "is required"
continue
message = check(value, rule)
if message is not None:
errors[field] = message
return errorsJavaScript
/**
* Valider un formulaire sur le serveur : un schéma déclaratif, une erreur par
* champ.
*
* Niveau N0. Déterministe, aucune dépendance. Les règles restent lisibles, et
* chaque refus peut être expliqué à la personne qui a tapé le formulaire.
*
* Quatre décisions portent la conception.
*
* D'abord, le schéma est une donnée, pas du code. Il peut être écrit à côté du
* formulaire, lu par quelqu'un qui n'écrit pas de JavaScript, et confié à la
* version Python qui garde le même formulaire de l'autre côté. Un motif n'a le
* même sens des deux côtés qu'avec des classes explicites : les raccourcis d'un
* chiffre et d'un caractère de mot dépassent l'ASCII en Python et s'y arrêtent
* en JavaScript ; écrivez donc [0-9] et [A-Za-z].
*
* Ensuite, la réponse associe un nom de champ à un message, jamais un booléen.
* Un formulaire qui répond « non » sans dire quel champ est en faute envoie
* l'utilisateur à la chasse, et le développeur dans les journaux.
*
* Enfin, un message par champ : les vérifications s'arrêtent à la première
* règle enfreinte. Dire à quelqu'un que son mot de passe est à la fois trop
* court *et* mal formé, c'est du bruit ; qu'il corrige la première chose,
* renvoie le formulaire, et voie la suivante.
*
* Enfin, les blancs de bord tombent de toute chaîne avant le moindre contrôle,
* et une chaîne que cela vide est traitée comme absente. Le navigateur envoie ce
* que l'utilisateur a tapé, et la saisie semi-automatique d'un téléphone ajoute
* une espace : sans cela, « ada@example.com » précédée d'une espace est refusée
* par un motif dont personne ne voit la faute, et un champ obligatoire rempli de
* deux espaces passe pour rempli. La norme HTML retire les blancs de la valeur
* d'un champ de courriel pour la même raison.
*
* Une chose que le schéma doit porter, et que ce fichier ne peut pas : une
* longueur maximale sur chaque chaîne. Un champ sans « max » accepte des
* mégaoctets, et le motif s'applique alors à des mégaoctets.
*/
// The types a form field can hold once decoded. `Number.isInteger` rejects
// NaN, Infinity and 1.5 in one go, which is what a form needs.
const TYPES = {
string: (v) => typeof v === 'string',
integer: (v) => Number.isInteger(v),
};
/** A string without its edge whitespace; anything else is left alone. */
export function trimmed(value) {
return typeof value === 'string' ? value.trim() : value;
}
/** Return the first broken rule as a message, or null if the value passes. */
export function check(value, rule) {
const kind = rule.type ?? 'string';
if (!TYPES[kind](value)) return `must be of type ${kind}`;
// For a string the bounds read as a length; for a number, as a value.
// The length counts code points once composed (NFC), as the Python version
// does: "é" counts one however it was typed, and an emoji made of several
// code points, such as a flag, still counts several.
const text = String(value).normalize('NFC');
const [size, unit] = kind === 'string' ? [[...text].length, ' characters'] : [value, ''];
if (rule.min !== undefined && size < rule.min) return `must be at least ${rule.min}${unit}`;
if (rule.max !== undefined && size > rule.max) return `must be at most ${rule.max}${unit}`;
// Anchored, so a pattern matches the whole field and not a fragment of it.
if (rule.pattern !== undefined && !new RegExp(`^(?:${rule.pattern})$`).test(text)) {
return rule.message ?? 'is not in the expected format';
}
return null;
}
/**
* Check a submitted form against a schema, and return {field: message}.
*
* An empty object means the form is valid. A missing key, an explicit null, an
* empty string and a string of spaces are the same thing here, because that is
* what a browser posts for a field the user left alone.
*/
export function validate(data, schema) {
// A field the schema does not declare is refused, not ignored: whoever
// stores the submission would otherwise store it too.
const errors = {};
for (const field of Object.keys(data)) {
if (!Object.hasOwn(schema, field)) errors[field] = 'is not a field of this form';
}
for (const [field, rule] of Object.entries(schema)) {
const value = trimmed(data[field]);
if (value === undefined || value === null || value === '') {
if (rule.required) errors[field] = 'is required';
continue;
}
const message = check(value, rule);
if (message !== null) errors[field] = message;
}
return errors;
}Essayer
interactifLe code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.
Une ligne par champ. Les messages de la troisième colonne sont ceux que l’extrait produit lui-même.
Ou partez d’un exemple
La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.
rien attrapé dans ce texte
Donné
email: marie.durand@exemple.fr display_name: Marie Durand age: 34 website: https://menuiserie-dubois.fr
Ce qu’il renvoie
Formulaire accepté
Aucune règle du schéma n’est enfreinte.
Le détail
| Champ | Valeur soumise | Réponse du serveur |
|---|---|---|
| marie.durand@exemple.fr | accepté | |
| display_name | Marie Durand | accepté |
| age | 34 | accepté |
| website | https://menuiserie-dubois.fr | accepté |
Là, ça casse
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : la saisie ne quitte pas votre infrastructure
- Les messages renvoyés nomment le champ et la règle enfreinte, jamais la valeur saisie : ce qui atterrit dans vos journaux d’erreur reste ce que vous y mettez, à ceci près qu’un champ non déclaré est nommé tel qu’il a été envoyé
Point de rupture
Aucune règle ne dit si ce qui est saisi existe, ni s’il veut dire quelque chose. Le test soumet `ada@no-such-mailbox.example` : la forme est correcte, le schéma accepte, et personne ne lit le courrier envoyé là. Il soumet aussi un pseudonyme fait de deux caractères de largeur nulle : les blancs de bord tombent avant le contrôle, mais un U+200B n’est pas un blanc, `min` le compte comme un caractère, et le profil s’affiche vide. Le savoir demande une vérification externe — un message de confirmation, une interrogation du domaine — c’est-à-dire un autre besoin.
Quand passer au niveau suivant
Des saisies bien formées mais fausses commencent à vous coûter quelque chose de visible : des comptes créés qui ne confirment jamais, des courriers qui reviennent. Aucun niveau au-dessus n’y répond ; ce qu’il vous faut est une vérification externe, qui est un autre besoin.
Les quatre options
- RecommandéN0Schéma déclaratif, un message d’erreur par champGratuit · Instantané
- N1Niveau sans objet ici
- N2Niveau sans objet ici
- 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. L’âge minimal, la longueur d’un pseudonyme, le format d’adresse accepté : ce sont des décisions écrites et opposables, pas des régularités à retrouver dans des données. Un classifieur entraîné sur les saisies passées apprendrait ce qui a été accepté hier, erreurs comprises, et ne saurait toujours pas dire quel champ reprendre.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Même raison qu’en N1, avec un service permanent à exploiter en plus. Un modèle auto-hébergé ne rend pas une règle plus juste : il rend son verdict plus cher à obtenir, plus lent à rendre, et impossible à relire dans le schéma que l’équipe maintient.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Une validation doit refuser de façon déterministe, et justifier son refus devant la personne dont elle refuse la saisie. Un modèle qui accepte parfois et refuse parfois la même saisie ne valide rien : il donne un avis. Deux envois identiques doivent recevoir la même réponse, et cette réponse doit se lire dans une règle qu’on peut produire — un schéma, une ligne de code, un test — et non dans une phrase dont on ne peut ni rejouer la production ni montrer le fondement. S’y ajoute que le contenu d’un formulaire est du texte écrit par la personne validée : le confier à un modèle qui lit ce texte comme une consigne, c’est la laisser peser sur le verdict qui la concerne.
Pour aller plus loin
- JSON Schema — Validation, le vocabulaire d’un schéma déclaratif
- OWASP — Input Validation Cheat Sheet
- OWASP — LLM01 Prompt Injection, une saisie qui modifie la conduite du modèle qui la lit
- WHATWG HTML — définition normative d’une adresse électronique valide
- MDN — Validation de formulaire, et pourquoi le contrôle côté client ne suffit pas
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.