RuniqueForm trait
Base structure
Each form contains a form: Forms field and implements the RuniqueForm trait:
use runique::prelude::*;
#[derive(Serialize, Debug, Clone)]
#[serde(transparent)]
pub struct UsernameForm {
pub form: Forms,
}
impl RuniqueForm for UsernameForm {
fn register_fields(form: &mut Forms) {
form.field(
&TextField::text("username")
.label("Username")
.required()
.placeholder("Enter a username"),
);
}
impl_form_access!();
}
💡
impl_form_access!()automatically generatesfrom_form(),get_form()andget_form_mut(). If your field is not namedform, pass the name as an argument:impl_form_access!(formulaire).
Equivalent without the macro (for reference)
fn from_form(form: Forms) -> Self {
Self { form }
}
fn get_form(&self) -> &Forms {
&self.form
}
fn get_form_mut(&mut self) -> &mut Forms {
&mut self.form
}
RuniqueForm trait methods
Form lifecycle (call order):
register_fields() → declare fields
↓
build() / build_with_data() → build the instance
↓
is_valid() → validation pipeline
↓ validate() per field (required, format, length…)
↓ clean_field(name) [optional — per-field business rule]
↓ clean() [optional — cross-field validation]
↓ finalize() (Argon2 hashing, final transforms)
↓
save() / database_error() → persistence or DB error handling
↓
clear() → [optional] empty the form after processing
Method reference:
register_fields(form) — Declare the form fields.
from_form(form) — Build the instance from a Forms.
get_form() / get_form_mut() — Accessors for the internal Forms.
clean_field(name) (optional) — Per-field business validation. Returns bool. Called after validate() for each field.
clean() (optional) — Cross-field validation. Returns Result<(), StrMap>. Called once all fields are valid.
is_valid() — Orchestrates the full pipeline. Safe to call on both GET and POST: returns false without setting field errors if no data has been submitted (first page load), validates normally otherwise.
is_submitted() — Returns true if the form received data (POST, or GET with non-empty query params).
database_error(&err) — Parses a DB error and attaches it to the correct field.
clear() — Clears all field values (except CSRF) and resets submitted to false. Call it after reading cleaned data, before a redirect or empty re-render.
build(tera, csrf_token) — Build an empty form.
build_with_data(data, tera, csrf) — Build, fill, and validate.
`is_valid()` — calling on GET and POST
is_valid() is designed to be called regardless of the HTTP method:
- First GET (empty form) — returns
false, no errors set on fields. The template renders a clean empty form. - GET with query params (search form) — validates normally, enabling GET-based searches without extra code.
- POST — standard behavior: validates and sets field errors if invalid.
// Unified GET+POST handler — no method branching needed
pub async fn search(
mut request: Request,
Prisme(mut form): Prisme<SearchForm>,
) -> AppResult<Response> {
if form.is_valid().await {
let query = form.get_string("q");
// run the search...
}
// First GET: is_valid() == false, no errors → clean empty form
// Submitted GET invalid: is_valid() == false, errors shown
context_update!(request => { "search_form" => &form });
request.render("search.html")
}
is_submitted()is available when you need to explicitly distinguish "first page load" from "form submitted with invalid data".
`is_valid()` validation pipeline
Calling form.is_valid().await triggers 4 steps in order (only if the form is submitted):
- Field validation — Each field runs
validate(): required, length, format (email viavalidator, URL viavalidator, JSON viaserde_json, UUID viauuid, IP viastd::net::IpAddr, …) clean_field(name)— Per-field business validation, called for each field after step 1 (only if standard validation passed)clean()— Cross-field validation (e.g.pwd1 == pwd2); passwords are still plain text at this stepfinalize()— Final transformations (automatic Argon2 hashing forPasswordfields)
`clean_field` — per-field business validation
clean_field is called for each field after its standard validation. Use it to implement a business rule on a specific field (reserved value, custom format, lightweight uniqueness check…).
- Returns
trueif the field is valid,falseotherwise - On failure, set the error manually on the field via
set_error() - Not called if the required field is already empty (standard validation fails first)
#[async_trait::async_trait]
impl RuniqueForm for UsernameForm {
// ...
async fn clean_field(&mut self, name: &str) -> bool {
if name == "username" {
let val = self.get_form().get_string("username");
if val.to_lowercase().contains("admin") {
if let Some(f) = self.get_form_mut().fields.get_mut("username") {
f.set_error("The name 'admin' is reserved".to_string());
}
return false;
}
}
true
}
}
💡
clean_fieldis ideal for isolated rules on a single field. For rules that involve multiple fields at once, useclean().⚠️ Do not call
clean_fieldfrom withinclean: the pipeline guarantees thatclean_fieldhas already run for every field beforecleanis called. Calling it again would be redundant and could set a duplicate error on a field. Furthermore,cleanis only invoked if allclean_fieldcalls returnedtrue— so from withinclean, all fields are already individually valid.
`clean` — cross-field validation
clean is called once all fields have passed validation (standard + clean_field). Use it to cross-check values across fields.
- Returns
Ok(())if the form is valid - Returns
Err(StrMap)with a map{ "field_name" => "error message" }on failure
#[async_trait::async_trait]
impl RuniqueForm for RegisterForm {
// ...
async fn clean(&mut self) -> Result<(), StrMap> {
let pwd1 = self.form.get_string("password");
let pwd2 = self.form.get_string("password_confirm");
if pwd1 != pwd2 {
let mut errors = StrMap::new();
errors.insert(
"password_confirm".to_string(),
"Passwords do not match".to_string(),
);
return Err(errors);
}
Ok(())
}
}
⚠️ Important:
Passwordfields are automatically hashed duringfinalize()by default (Argon2), unlesspassword_initis called inmain.rswithPasswordConfig::Manual,Delegated, orCustom. Useclean()for any password comparison — it is the only step where they are still readable.
`clear()` — empty the form after processing
clear() resets all field values (except the CSRF token) and sets submitted back to false.
Available anywhere self is &mut Self — from a handler or from a method on the form itself.
From a handler
if form.is_valid().await {
let path = form.cleaned_string("image"); // 1. read before clear
// save to DB...
form.clear(); // 2. empty
success!(request.notices => "File uploaded!");
context_update!(request => { "image_form" => &form });
return request.render(template); // 3. re-render with empty form
}
From the form itself (save(&mut self))
Declaring save with &mut self lets you encapsulate the clear inside — the handler does nothing extra:
impl BlogForm {
pub async fn save(
&mut self,
db: &DatabaseConnection,
) -> Result<blog::Model, DbErr> {
let record = blog::ActiveModel {
title: Set(self.form.get_string("title")),
// ...
..Default::default()
};
let result = record.insert(db).await;
if result.is_ok() {
self.clear(); // automatically empty after success
}
result
}
}
Where clear() cannot be called
- In a
&selfmethod (immutable) — does not compile - In
clean()orclean_field()— these run duringis_valid(), beforesave()reads the data; callingclear()here would wipe the form before it is saved
💡 With redirect (PRG): if the handler redirects after success (
Redirect::to(...)),clear()is not needed — the new GET request automatically creates a fresh empty instance.