La validation des entrées est la première ligne de défense de toute application web. Des données invalides causent des plantages, corrompent les bases de données, permettent des attaques par injection et créent des bugs subtils qui se manifestent en production des semaines après le déploiement. Pourtant, dans la plupart des frameworks, la validation est un code après coup dispersé dans les gestionnaires, dupliqué entre les points de terminaison et appliqué de manière incohérente.
Les blocs validate de FLIN sont déclaratifs, composables et appliqués avant que votre code de gestionnaire ne s'exécute. Vous déclarez à quoi le corps de la requête doit ressembler, et FLIN rejette automatiquement les requêtes malformées avec des réponses d'erreur structurées. Pas de bibliothèque de validation. Pas de vérification manuelle. Pas moyen d'oublier.
Le bloc validate
Un bloc validate déclare la forme et les contraintes attendues du corps de la requête :
flinroute POST {
validate {
name: text @required @minLength(2) @maxLength(100)
email: text @required @email
age: int @min(13) @max(120)
role: text @one_of("user", "admin", "moderator")
bio: text @maxLength(500)
}
// Si nous arrivons ici, toutes les validations sont passées
// body.name est garanti être une chaîne de 2-100 caractères
// body.email est garanti être un e-mail valide
// body.age est garanti être un entier entre 13 et 120
user = User {
name: body.name,
email: body.email,
age: body.age,
role: body.role || "user",
bio: body.bio || ""
}
save user
response { status: 201, body: user }
}Si un champ échoue à la validation, FLIN retourne un 400 Bad Request avec des informations d'erreur détaillées :
json{
"error": "Validation failed",
"status": 400,
"fields": {
"name": "Must be at least 2 characters",
"email": "Must be a valid email address",
"age": "Must be at least 13"
}
}Le code du gestionnaire ne s'exécute jamais. Les données invalides n'atteignent jamais la base de données. La réponse d'erreur indique au client exactement quels champs ont échoué et pourquoi.
Décorateurs disponibles
FLIN fournit un ensemble complet de décorateurs de validation :
Décorateurs de type
| Type | Description | Coercition |
|---|---|---|
text | Valeur chaîne | Depuis tout type via to_text() |
int | Valeur entière | Depuis chaîne via to_int() |
float | Valeur à virgule flottante | Depuis chaîne via to_float() |
bool | Valeur booléenne | Depuis "true"/"false"/"1"/"0" |
file | Fichier téléversé | Depuis partie multipart |
[type] | Tableau de valeurs | Analysé comme tableau JSON |
Décorateurs de contrainte
flinvalidate {
// Champs obligatoires
name: text @required // Doit être présent et non vide
// Contraintes de chaîne
slug: text @minLength(3) @maxLength(50) @pattern("^[a-z0-9-]+$")
email: text @email // Doit correspondre au format e-mail
url: text @url // Doit être une URL valide
phone: text @phone // Doit être un numéro valide
// Contraintes numériques
age: int @min(0) @max(150)
price: float @min(0.01) @max(999999.99)
quantity: int @required @min(1) @max(10000)
// Contraintes d'énumération
status: text @one_of("active", "inactive", "pending")
priority: int @one_of(1, 2, 3, 4, 5)
// Contraintes de fichier
avatar: file @max_size("5MB") @allow_types("image/png", "image/jpeg")
documents: [file] @max_count(10) @max_size("25MB")
// Message de validation personnalisé
password: text @required @minLength(8) @message("Password must be at least 8 characters")
}Le décorateur @required
Les champs sans @required sont optionnels. S'ils sont absents de la requête, ils prennent une valeur vide ("" pour text, 0 pour int, false pour bool, none pour file).
flinvalidate {
name: text @required // Doit être présent
nickname: text // Optionnel, valeur par défaut ""
email: text @required @email // Doit être présent ET valide
}Le décorateur @pattern
Pour une validation qui ne correspond pas à un décorateur intégré, utilisez @pattern avec une expression régulière :
flinvalidate {
slug: text @required @pattern("^[a-z0-9][a-z0-9-]*[a-z0-9]$")
postal_code: text @pattern("^[0-9]{5}$")
hex_color: text @pattern("^#[0-9a-fA-F]{6}$")
}Coercition de type
Le bloc validate effectue la coercition de type pour les données encodées en formulaire. Les valeurs de formulaire sont toujours des chaînes, mais le validateur les convertit au type déclaré :
flinvalidate {
quantity: int @required @min(1)
// Le formulaire envoie "5" (chaîne) -> le validateur convertit en 5 (int)
price: float @required @min(0.01)
// Le formulaire envoie "29.99" (chaîne) -> le validateur convertit en 29.99 (float)
active: bool
// Le formulaire envoie "true" (chaîne) -> le validateur convertit en true (bool)
}Les requêtes JSON ont déjà des valeurs typées, donc la coercition est un no-op. Cela signifie que le même bloc validate fonctionne pour les requêtes JSON et les requêtes encodées en formulaire.
Validation d'objets imbriqués
Pour les corps de requête complexes avec des objets imbriqués :
flinvalidate {
user: {
name: text @required @minLength(2)
email: text @required @email
}
address: {
street: text @required
city: text @required
postal_code: text @required @pattern("^[0-9]{5}$")
country: text @required @one_of("CI", "SN", "NG", "GH", "KE")
}
items: [{
product_id: int @required
quantity: int @required @min(1)
}]
}Le client envoie :
json{
"user": { "name": "Thales", "email": "[email protected]" },
"address": { "street": "Rue des Jardins", "city": "Abidjan", "postal_code": "01234", "country": "CI" },
"items": [
{ "product_id": 1, "quantity": 2 },
{ "product_id": 5, "quantity": 1 }
]
}Chaque champ imbriqué est validé individuellement. Les erreurs sont rapportées avec une notation par chemin pointé :
json{
"error": "Validation failed",
"fields": {
"address.postal_code": "Must match pattern ^[0-9]{5}$",
"items[1].quantity": "Must be at least 1"
}
}Comment la validation est implémentée
Le bloc validate se compile en une fonction de validation qui s'exécute avant le gestionnaire :
rustpub struct ValidateField {
name: String,
field_type: FieldType,
required: bool,
constraints: Vec<Constraint>,
custom_message: Option<String>,
}
pub enum Constraint {
MinLength(usize),
MaxLength(usize),
Min(f64),
Max(f64),
Pattern(Regex),
Email,
Url,
Phone,
OneOf(Vec<Value>),
MaxSize(usize),
AllowTypes(Vec<String>),
MaxCount(usize),
}
fn validate_body(
body: &Value,
fields: &[ValidateField],
) -> Result<Value, ValidationErrors> {
let mut errors = HashMap::new();
let mut coerced = body.clone();
for field in fields {
let value = body.get(&field.name);
// Vérifier obligatoire
if field.required && (value.is_none() || value == Some(&Value::Empty)) {
errors.insert(field.name.clone(), "This field is required".into());
continue;
}
if let Some(val) = value {
// Coercition de type
let typed = coerce(val, &field.field_type)?;
// Vérification des contraintes
for constraint in &field.constraints {
if let Err(msg) = check_constraint(&typed, constraint) {
errors.insert(
field.name.clone(),
field.custom_message.as_deref().unwrap_or(&msg).to_string(),
);
break;
}
}
coerced.set(&field.name, typed);
}
}
if errors.is_empty() {
Ok(coerced)
} else {
Err(ValidationErrors { fields: errors })
}
}La fonction de validation :
1. Itère sur tous les champs déclarés.
2. Vérifie @required en premier.
3. Effectue la coercition de type.
4. Évalue chaque contrainte dans l'ordre (s'arrête au premier échec par champ).
5. Retourne soit le corps coercé, soit une carte d'erreurs par champ.
Schémas de validation réutilisables
Lorsque plusieurs points de terminaison partagent les mêmes règles de validation, FLIN permet d'extraire les validateurs en schémas réutilisables :
flin// Définir une fois
schema UserInput {
name: text @required @minLength(2) @maxLength(100)
email: text @required @email
role: text @one_of("user", "admin", "moderator")
}
// Utiliser dans plusieurs routes
route POST {
validate UserInput
// ...
}
route PUT {
validate UserInput
// Même validation, gestionnaire différent
}Validation vs gardes
La validation et les gardes servent des objectifs différents et se complètent :
Les gardes protègent l'accès : qui peut appeler ce point de terminaison ? Les gardes s'exécutent avant même que le corps de la requête ne soit analysé. Un utilisateur non authentifié est rejeté avant que son corps de requête ne soit lu.
Les validateurs protègent les données : quelle forme doivent avoir les entrées ? Les validateurs s'exécutent après que les gardes soient passés et que le corps soit analysé. Ils s'assurent que les données sont bien formées avant que le gestionnaire ne les traite.
flinguard auth // Qui : utilisateurs authentifiés uniquement
guard role("admin") // Qui : administrateurs uniquement
guard rate_limit(10, 60) // Fréquence : 10/minute
route POST {
validate { // Quoi : données utilisateur bien formées
name: text @required
email: text @required @email
}
// Si nous arrivons ici :
// 1. L'utilisateur est authentifié (guard auth)
// 2. L'utilisateur est admin (guard role)
// 3. La requête est dans la limite de débit (guard rate_limit)
// 4. Le corps a un nom et un e-mail valides (validate)
}Quatre couches de protection, chacune exprimée de manière déclarative, chacune appliquée automatiquement.
Cohérence des réponses d'erreur
Chaque réponse d'erreur de validation suit la même structure :
json{
"error": "Validation failed",
"status": 400,
"fields": {
"field_name": "Human-readable error message"
}
}Cette cohérence signifie que le code frontend peut gérer les erreurs de validation de manière générique :
javascript// Code frontend (tout framework)
const response = await fetch('/api/users', { method: 'POST', body: data });
if (response.status === 400) {
const { fields } = await response.json();
Object.entries(fields).forEach(([field, message]) => {
showError(field, message);
});
}Les noms de champs dans la réponse d'erreur correspondent aux noms de champs dans le corps de la requête. Pas de mapping nécessaire.
La validation de FLIN n'est pas une bibliothèque que vous installez. Ce n'est pas un middleware que vous configurez. C'est une fonctionnalité du langage qui se compile en code de vérification de type efficace, produit des messages d'erreur clairs et ne peut pas être contournée. Chaque point de terminaison API qui utilise un bloc validate est protégé contre les entrées malformées, à chaque fois, automatiquement.
Dans le prochain article, nous regardons comment nous avons vérifié que toutes ces fonctionnalités de sécurité fonctionnent correctement : 75 tests de sécurité couvrant l'authentification, l'autorisation, la limitation de débit, la validation des entrées et les opérations cryptographiques.
Ceci est la partie 113 de la série « Comment nous avons construit FLIN », documentant comment un CEO à Abidjan et un CTO IA ont conçu et construit un langage de programmation à partir de zéro.
Navigation de la série : - [112] Authentification WhatsApp OTP pour l'Afrique - [113] Validateurs de corps de requête (vous êtes ici) - [114] 75 tests de sécurité : comment nous avons tout vérifié - [115] Gardes personnalisés et middleware de sécurité