Trait RuniqueForm
Structure de base
Chaque formulaire contient un champ form: Forms et implémente deux traits : FormAccess (ses champs et l'accès au Forms interne) et RuniqueForm (les étapes de validation, toutes facultatives) :
use runique::prelude::*;
#[derive(Serialize, Debug, Clone)]
#[serde(transparent)]
pub struct UsernameForm {
pub form: Forms,
}
impl FormAccess for UsernameForm {
fn register_fields(form: &mut Forms) {
form.field(
&TextField::text("username")
.label("Nom d'utilisateur")
.required()
.placeholder("Entrez un nom d'utilisateur"),
);
}
impl_form_access!();
}
impl RuniqueForm for UsernameForm {}
Équivalent sans macro (pour référence)💡
impl_form_access!()génèrefrom_form(),forms()etforms_mut(), pour une struct dont le champ s'appelleform. Un formulaire tiré d'un modèle (#[form(schema = …)]) n'a besoin d'aucun des deux blocs : la macro les génère.Note de sécurité :
forms()donne accès à la structure interne. Pour l'extraction des données nettoyées, passez toujours par les méthodescleaned_*du trait (voir plus bas). Les méthodes d'extraction directe surFormssont réservées à l'usage interne du framework.
fn from_form(form: Forms) -> Self {
Self { form }
}
fn forms(&self) -> &Forms {
&self.form
}
fn forms_mut(&mut self) -> &mut Forms {
&mut self.form
}
Méthodes du trait RuniqueForm
Cycle de vie du formulaire (ordre d'appel) :
register_fields() → déclare les champs
↓
req.form("nom") → construit l'instance, la remplit si la requête renvoie son nom
↓
ValidationForm::try_form() → allow_get() / allow_post(), puis is_valid()
↓
is_valid() → pipeline de validation
↓ validate() par champ (requis, format, longueur…)
↓ clean_field(name) [optionnel] pour CHAQUE champ, même invalide
↓ clean() [optionnel] seulement si tout est valide jusque-là
↓ finalize() (hachage selon la PasswordConfig, chemins d'upload)
↓ contrôle de type chaque valeur doit se lire au type de sa colonne
↓
form.save(&db) / delete(&db) → écriture, sur le formulaire validé
↓
database_error() → [optionnel] gestion d'erreur DB
↓
clear() → [optionnel] vide le formulaire après traitement
Référence des méthodes :
register_fields(form) — Déclare les champs du formulaire.
from_form(form) — Construit l'instance depuis un Forms.
forms() / forms_mut() — Accesseurs de bas niveau vers le Forms interne (utile pour le rendu ou la configuration dynamique des champs). Ne pas utiliser pour extraire les données.
clean_field(name) (optionnel) — Validation métier par champ individuel. Retourne bool. Appelée pour chaque champ après les validate(), y compris pour un champ dont la validation standard a échoué (vide, mauvais format) : testez la valeur avant de poser une erreur, sinon vous remplacez le message standard.
clean() (optionnel) — Validation croisée entre plusieurs champs. Retourne Result<(), StrMap>. Appelée seulement si tous les champs sont valides et si chaque clean_field a renvoyé true. Elle voit encore les mots de passe en clair : finalize() vient après.
is_valid() — Orchestre le pipeline complet. Peut être appelé sur GET comme sur POST : retourne false sans poser d'erreurs si aucune donnée n'a été soumise (premier affichage), valide normalement sinon.
save(&db) / delete(&db) — Sur le formulaire validé rendu par try_form, pour un formulaire relié à une entité : écrit la ligne, rend Ok(form) ou Err(form). Voir « Écriture » plus bas.
database_error(&err) — Analyse une erreur DB et la positionne sur le bon champ. ValidationForm::save l'appelle déjà : le Err(form) revient avec l'erreur. À appeler soi-même seulement après une écriture maison qui renvoie un DbErr.
clear() — Vide toutes les valeurs des champs (hors CSRF et nom du formulaire) et remet submitted à false. À appeler après avoir lu les données nettoyées, avant un redirect ou un re-rendu vide.
Accès aux données — `cleaned_*()`
Après is_valid(), accédez aux données via les méthodes cleaned_* du trait :
self.cleaned::<String>("username") // Option<String> (None si vide)
self.cleaned::<i32>("age") // Option<i32>
self.cleaned::<f64>("price") // Option<f64> (gère , → .)
self.cleaned::<bool>("active") // Option<bool>
// … voir la page Helpers pour la liste complète
Ces méthodes sont whitelistées : elles retournent None si le champ n'est pas déclaré dans register_fields(). Quand le champ est vide, elles se rabattent sur un paramètre de route, puis de query string, de même nom : cette valeur-là n'est pas passée par la validation du champ. save() ne l'utilise jamais (il écrit la valeur validée du champ).
cleaned::<String>retourneOption<String>. Pour obtenir uneStringvide par défaut :self.cleaned::<String>("x").unwrap_or_default().
Surcharge des champs — affichage et contraintes
Ces méthodes permettent de modifier le comportement d'un champ après register_fields(), sans toucher à sa déclaration. Elles sont chainables et disponibles sur le trait RuniqueForm directement.
Affichage
form.label("titre", "Titre de l'article") // surcharge le libellé
.placeholder("titre", "Ex : Mon article")
.required("titre", false) // relâche la contrainte required
.readonly("titre", true)
.disabled("brouillon", true)
.attr("photo", "accept", "image/*"); // attribut HTML arbitraire
Taille maximale d'un FileField
Le modèle peut déclarer un plafond immuable via .max_size() dans register_fields(). La surcharge côté formulaire ne peut pas le dépasser :
// Dans register_fields — plafond modèle fixé à 5 MB
form.field(&MediaField::image("photo").max_size(FileSize::mb(5)));
// Dans le handler (`let mut form`) — surcharge autorisée si ≤ plafond.
// `max_size` renvoie `Result<_, String>` : à traiter, pas de `?` dans un handler.
form.max_size("photo", FileSize::mb(3)).ok(); // Ok
if let Err(msg) = form.max_size("photo", FileSize::mb(6)) {
// dépasse le plafond du modèle : `msg` l'explique, la limite reste 5 MB
}
Séparation des responsabilités : le modèle contrôle la contrainte maximale, le formulaire peut l'affiner vers le bas. Même principe que Django
ModelFormpour lesmax_length.
Nom du formulaire — plusieurs formulaires sur une page
request.form("nom") construit le formulaire et lui donne son nom. Ce nom est rendu dans un champ caché runique_form (en premier, avant le CSRF), et préfixe les id HTML des champs (id_nom-champ). Le formulaire n'est rempli avec les données de la requête que si celle-ci renvoie ce même nom ; try_form ne valide que ce formulaire-là. Sur une page qui affiche plusieurs formulaires :
- seul le formulaire envoyé reçoit les valeurs tapées : un autre formulaire de la page ne les voit jamais, même avec un champ du même nom (
emaildans deux formulaires) ; - l'erreur CSRF éventuelle ne s'affiche que sur le formulaire envoyé ;
- les autres reviennent de
try_formenErr, sans aucune erreur posée, avecis_submitted()faux.
Le nom n'est jamais lu dans la requête pour nommer le formulaire : seul request.form(nom) le pose. Un formulaire écrit à la main (sans {% form.x %}) doit porter lui-même le champ caché runique_form avec le bon nom.
let username_form = ValidationForm::try_form(request.form::<UsernameForm>("username"), &request).await;
let email_form = ValidationForm::try_form(request.form::<EmailForm>("email"), &request).await;
`is_valid()` — appel sur GET et POST
is_valid() valide indifféremment sur GET et POST, mais ne regarde jamais la méthode HTTP — c'est ValidationForm::try_form(form, &request) qui décide, via allow_get/allow_post, si la validation doit même être tentée. Par défaut allow_get est false (protection CSRF : GET est la seule méthode exemptée, un formulaire mutant ne doit jamais s'auto-valider dessus). Un formulaire de recherche en lecture seule est le cas qui doit explicitement lever cette garde :
- Premier GET (formulaire vide) —
allow_getretombe suris_submitted()→false,try_formrenvoieErrsans tenter la validation, aucune erreur posée. Le template affiche un formulaire propre. - GET avec query params (formulaire de recherche) —
is_submitted()→true,try_formvalide et renvoieOk. Permet de faire des recherches via GET sans code supplémentaire. - POST — comportement standard : valide, pose les erreurs sur les champs si invalide (nécessite aussi un
allow_postqui l'autorise — le défaut couvre déjà ce cas).
impl RuniqueForm for SearchForm {
// ...
fn allow_get(&self, _request: &Request) -> bool {
self.is_submitted()
}
}
// Handler GET+POST unifié — fonctionne sans if/else sur la méthode
pub async fn search(mut request: Request) -> AppResult<Response> {
let form: SearchForm = request.form("my_form");
let form = match ValidationForm::try_form(form, &request).await {
Ok(validated) => {
let query = validated.cleaned::<String>("q").unwrap_or_default();
// lancer la recherche...
validated.into_form()
}
// Premier GET ou GET/POST invalide : aucune erreur si rien n'a été
// soumis, erreurs de champ posées sinon.
Err(form) => form,
};
context_update!(request => { "search_form" => &form });
request.render("search.html")
}
Pour distinguer « premier affichage » de « formulaire envoyé mais refusé » (afficher un message d'erreur global dans le second cas seulement), testez
form.is_submitted()dans la brancheErr(form). Aprèstry_form, il n'est vrai que pour le formulaire dont le nom est revenu : sur une page à plusieurs formulaires, chacun répond pour lui-même, en POST comme en GET. Ne testez pasrequest.method.is_safe(): la méthode est la même pour tous les formulaires de la requête.Err(form) if form.is_submitted() => { /* envoyé et refusé : erreurs + message */ } Err(form) => { /* pas envoyé : simple affichage */ }
Pipeline de validation `is_valid()`
L'appel form.is_valid().await déclenche 5 étapes dans l'ordre (uniquement si le formulaire est soumis) :
- Validation des champs — Chaque champ exécute son
validate(): requis, longueur, format (email viavalidator, URL viavalidator, JSON viaserde_json, UUID viauuid, IP viastd::net::IpAddr…) clean_field(name)— Validation métier par champ, appelée pour chaque champ après l'étape 1, même pour un champ dont la validation standard a échouéclean()— Validation croisée sur l'ensemble du formulaire (ex:mdp1 == mdp2), seulement si les étapes 1 et 2 ont tout accepté ; les mots de passe sont encore en clair à cette étapefinalize()— Transformations finales (hachage automatique des champsPasswordsi la config globale est en modeAuto, préparation des chemins d'upload)- Contrôle de type — Chaque champ relié à une colonne doit se lire au type de cette colonne : un formulaire valide est un formulaire que l'écriture ne peut pas refuser sur un type
`clean_field` — validation métier par champ
clean_field est appelée pour chaque champ après les validations standard, que celles-ci aient réussi ou non. Elle permet d'implémenter une règle métier sur un champ précis (unicité, format personnalisé, valeur interdite…).
- Retourne
truesi le champ est valide,falsesinon - En cas d'échec, poser l'erreur manuellement sur le champ via
set_error() - Est invoquée même si le champ a déjà échoué (vide, format) : vérifiez la valeur avant de poser une erreur, sinon vous écrasez le message standard
#[async_trait]
impl RuniqueForm for UsernameForm {
// ...
async fn clean_field(&mut self, name: &str) -> bool {
if name == "username" {
let val = self.cleaned::<String>("username").unwrap_or_default();
if val.to_lowercase().contains("admin") {
if let Some(f) = self.forms_mut().fields.get_mut("username") {
f.set_error("Le nom 'admin' est réservé".to_string());
}
return false;
}
}
true
}
}
💡
clean_fieldest idéale pour les règles isolées sur un champ : valeur interdite, format personnalisé, vérification d'unicité légère. Pour les règles qui impliquent plusieurs champs à la fois, utilisezclean().⚠️ Ne pas appeler
clean_fielddepuisclean: le pipeline garantit queclean_fields'est déjà exécuté pour chaque champ avant l'appel declean. Rappelerclean_fielddepuiscleanserait redondant et risquerait de poser une erreur en double sur un champ. De plus,cleann'est invoquée que si tous lesclean_fieldont retournétrue— depuisclean, tous les champs sont déjà individuellement valides.
`clean` — validation croisée
clean est appelée une fois que tous les champs ont passé leur validation (standard + clean_field). Elle permet de croiser les valeurs de plusieurs champs.
- Retourne
Ok(())si le formulaire est valide - Retourne
Err(StrMap)avec une map{ "nom_du_champ" => "message d'erreur" }en cas d'échec
#[async_trait]
impl RuniqueForm for RegisterForm {
// ...
async fn clean(&mut self) -> Result<(), StrMap> {
let mdp1 = self.cleaned::<String>("password").unwrap_or_default();
let mdp2 = self.cleaned::<String>("password_confirm").unwrap_or_default();
if mdp1 != mdp2 {
let mut errors = StrMap::new();
errors.insert(
"password_confirm".to_string(),
"Les mots de passe ne correspondent pas".to_string(),
);
return Err(errors);
}
Ok(())
}
}
⚠️ Important : Les champs
Passwordsont hachés lors definalize()uniquement si la config globale estPasswordConfig::Auto(mode par défaut) et que.no_hash()n'a pas été appliqué au champ. En modeManual,DelegatedouCustom,finalize()ne hache pas — c'est à l'application de le faire. Utilisezclean()pour toute comparaison de mots de passe en clair — c'est la seule étape où ils sont encore lisibles.Formulaire de connexion (login) : le champ password ne doit pas être haché — il sera comparé au hash stocké en base. Désactivez le hachage automatique avec
.no_hash():TextField::password("password").no_hash().required()
Écriture — `save` et `delete`
Un formulaire relié à une entité (model!{}, extend!{}, #[form(schema = …)]) s'écrit depuis le formulaire validé que rend ValidationForm::try_form. Runique parcourt ses champs et écrit la ligne : aucune méthode d'écriture à implémenter. Un formulaire sans entité n'a ni save ni delete : son handler fait le travail après try_form.
match ValidationForm::try_form(form, &request).await {
Ok(form) => match form.save(&request.engine.db).await {
Ok(_) => {
success!(request.notices => "Article enregistré");
Ok(Redirect::to("/articles").into_response())
}
Err(form) => {
context_update!(request => { "article_form" => &form });
request.render(template)
}
},
Err(form) => {
context_update!(request => { "article_form" => &form });
request.render(template)
}
}
save et delete rendent le formulaire dans les deux cas : Ok(form) une fois écrit (rediriger, ou clear() puis ré-afficher), Err(form) avec la raison posée sur ses champs ou ses erreurs.
La ligne vient de l'URL
La clé est cherchée dans les paramètres de la route, par le nom donné à try_form :
{<nom>_<clé>}— par exemple{article_id}pour le formulaire"article";- sinon
{<clé>}— par exemple{id}; - sinon aucune :
savecrée la ligne.
Avec une clé, save met à jour cette ligne ; delete exige une clé. Une clé qui ne se lit pas au type de la clé du modèle, ou une ligne absente, donne « Ressource introuvable » et rien n'est écrit. Deux formulaires sur une page pointent chacun leur ligne par leur propre nom.
Ce qui est écrit
- Hors admin : SeaORM écrit (
inserten création,ActiveModel::save()en mise à jour) ; le comportement du modèle (ActiveModelBehavior::before_save/after_save,auto_now) s'exécute. Tous les champs déclarés par le formulaire sont écrits, colonnes secrètes comprises : un champ mot de passe ne l'est que sous la forme de son hash (jamais le texte saisi, jamais un champ.no_hash()), et laissé vide en mise à jour, il garde l'ancienne valeur. Les champs verrouillés (readonly,disabled) ne sont jamais écrits. Une colonne qui n'est pas un champ est écrite seulement si le code lui a donné une valeur paradd_value. Un champ verrouillé n'est pas non plus rempli depuis la requête :cleaned()renvoie la valeur posée par le code, jamais celle qu'un visiteur aurait renvoyée. Vérifier que l'utilisateur a le droit de toucher la ligne est le rôle du handler, comme poursearch!. - Dans l'admin (panneau et routes déclarées
admin) : la porte écrit, au nom du compte vérifié — droits, propriétaire, historique, dans une transaction. Les colonnes secrètes n'y sont jamais écrites.
Où placer la logique métier
- Valeurs à vérifier ou ajuster :
clean_field,clean. - Valeurs imposées par le code (compte connecté, ligne parente) :
add_valueavanttry_form, sur une colonne qui n'est pas un champ — voir Colonnes posées par le code. - Effets extérieurs (email, cache) et écritures dans d'autres tables : dans le handler, après un
saveréussi.
`clear()` — vider le formulaire après traitement
clear() vide toutes les valeurs des champs (hors token CSRF) et remet submitted à false.
Accessible partout où self est &mut Self — dans un handler ou dans une méthode du formulaire lui-même.
Depuis un handler
if let Ok(validated) = ValidationForm::try_form(form, &request).await {
let mut form = validated.into_form();
let title = form.cleaned::<String>("title").unwrap_or_default(); // 1. lire avant clear
// enregistrer...
form.clear(); // 2. vider
context_update!(request => {
"note_form" => &form,
"messages" => flash_now!(success => "Enregistré !"), // rendu direct
});
return request.render(template); // 3. re-rendre avec formulaire vide
}
Depuis le formulaire lui-même (save(&mut self))
Passer save en &mut self permet d'encapsuler le clear directement — le handler n'a rien à faire :
impl BlogForm {
pub async fn save(
&mut self,
db: &DatabaseConnection,
) -> Result<blog::Model, DbErr> {
let record = blog::ActiveModel {
title: Set(self.cleaned::<String>("title").unwrap_or_default()),
// ...
..Default::default()
};
let result = record.insert(db).await;
if result.is_ok() {
self.clear(); // vide automatiquement après succès
}
result
}
}
Où clear() ne peut pas être appelé
- Dans une méthode
&self(lecture seule) — ne compile pas - Dans
clean()ouclean_field()— s'exécutent pendantis_valid(), avant que les données soient lues parsave(); appelerclear()ici viderait le formulaire avant la sauvegarde
💡 Avec redirect (PRG) : si le handler redirige après succès (
Redirect::to(...)),clear()n'est pas nécessaire — la nouvelle requête GET crée automatiquement une instance fraîche et vide.