RuniqueForm trait
Base structure
Each form contains a form: Forms field. Field registration and accessors live on FormAccess; business logic (clean, hooks…) lives on RuniqueForm:
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("Username")
.required()
.placeholder("Enter a username"),
);
}
impl_form_access!();
}
impl RuniqueForm for UsernameForm {}
Equivalent without the macro (for reference)💡 Split:
FormAccessownsregister_fields+ accessors;RuniqueFormowns validation hooks.impl_form_access!()generatesfrom_form(),forms()andforms_mut()insideimpl FormAccess. It's meant for a struct whose field is namedform. A form taken from a model (#[form(schema = …)]) needs neither block: the macro writes them.With
#[form(schema = …)], the macro generatesimpl FormAccessfor you — user code only writesimpl RuniqueForm(clean,customize…).Security note:
forms()provides access to the internal structure. For extracting cleaned data, always use thecleaned_*methods of the trait (see below). Direct extraction methods onFormsare reserved for internal framework use.
fn from_form(form: Forms) -> Self {
Self { form }
}
fn forms(&self) -> &Forms {
&self.form
}
fn forms_mut(&mut self) -> &mut Forms {
&mut self.form
}
RuniqueForm trait methods
Form lifecycle (call order):
FormAccess::register_fields() → declare fields
↓
req.form("name") → build the instance, fill it if the request sends its name back
↓
ValidationForm::try_form() → allow_get() / allow_post(), then is_valid()
↓
is_valid() → validation pipeline
↓ validate() per field (required, format, length…)
↓ clean_field(name) [optional] for EVERY field, even an invalid one
↓ clean() [optional] only if everything is valid so far
↓ finalize() (hashing per the PasswordConfig, upload paths)
↓ type check each value must read as its column's type
↓
form.save(&db) / delete(&db) → write, on the validated form
↓
database_error() → [optional] DB error handling
↓
clear() → [optional] empty the form after processing
Method reference:
register_fields(form) (FormAccess) — Declare the form fields.
from_form(form) (FormAccess) — Build the instance from a Forms.
forms() / forms_mut() (FormAccess) — Low-level accessors for the internal Forms (useful for rendering or dynamic field configuration). Do not use for data extraction.
clean_field(name) (optional) — Per-field business validation. Returns bool. Called for every field after the validate() calls, including a field whose standard validation failed (empty, wrong format): test the value before setting an error, otherwise you replace the standard message.
clean() (optional) — Cross-field validation. Returns Result<(), StrMap>. Called only if every field is valid and every clean_field returned true. It still sees passwords in clear: finalize() comes after.
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.
save(&db) / delete(&db) — On the validated form try_form returns, for a form tied to an entity: writes the row, returns Ok(form) or Err(form). See "Writing" below.
database_error(&err) — Parses a DB error and attaches it to the correct field. ValidationForm::save already calls it: the Err(form) comes back with the error. Call it yourself only after a hand-written save that returns a DbErr.
clear() — Clears all field values (except CSRF and the form's name) and resets submitted to false. Call it after reading cleaned data, before a redirect or empty re-render.
Accessing data — `cleaned_*()`
After is_valid(), access data via the cleaned_* methods on the trait:
self.cleaned::<String>("username") // Option<String> (None if empty)
self.cleaned::<i32>("age") // Option<i32>
self.cleaned::<f64>("price") // Option<f64> (handles , → .)
self.cleaned::<bool>("active") // Option<bool>
// … see the Helpers page for the full list
These methods are whitelisted: they return None if the field is not declared in register_fields(). When the field is empty, they fall back on a route parameter, then a query string parameter, of the same name: that value has not gone through the field's validation. save() never uses it (it writes the field's validated value).
cleaned::<String>returnsOption<String>. For an empty string default:self.cleaned::<String>("x").unwrap_or_default().
Field overrides — display and constraints
These methods let you modify a field's behaviour after register_fields(), without changing its declaration. They are chainable and available directly on the RuniqueForm trait.
Display
form.label("title", "Article title")
.placeholder("title", "e.g. My article")
.required("title", false) // relax the required constraint
.readonly("title", true)
.disabled("draft", true)
.attr("photo", "accept", "image/*"); // arbitrary HTML attribute
Max size on a FileField
The model can declare an immutable ceiling via .max_size() in register_fields(). A form-level override cannot exceed it:
// In register_fields — model ceiling set to 5 MB
form.field(&MediaField::image("photo").max_size(FileSize::mb(5)));
// In the handler (`let mut form`) — override allowed if ≤ ceiling.
// `max_size` returns `Result<_, String>`: handle it, no `?` in a handler.
form.max_size("photo", FileSize::mb(3)).ok(); // Ok
if let Err(msg) = form.max_size("photo", FileSize::mb(6)) {
// exceeds the model ceiling: `msg` explains it, the limit stays 5 MB
}
Separation of concerns: the model owns the maximum constraint, the form can narrow it down. Same principle as Django's
ModelFormformax_length.
Form name — several forms on one page
request.form("name") builds the form and names it. The name is rendered as a hidden runique_form field (first, before CSRF) and prefixes the fields' HTML ids (id_name-field). The form is only filled with the request's data when the request sends that same name back; try_form only validates that form. On a page showing several forms:
- only the form submitted gets what was typed: another form of the page never sees it, even with a field of the same name (
emailin two forms); - a CSRF error, if any, only shows on the form submitted;
- the others come back from
try_formasErr, with no error set andis_submitted()false.
The name is never read from the request to name the form: only request.form(name) sets it. A hand-written form (without {% form.x %}) must carry the hidden runique_form field itself, with the right name.
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()` — calling on GET and POST
is_valid() validates regardless of the HTTP method, but it never looks at the method itself — that's ValidationForm::try_form(form, &request)'s job, via allow_get/allow_post, deciding whether validation should even be attempted. allow_get defaults to false (CSRF protection: GET is the only method exempt from CSRF, and a mutating form must never auto-validate on it). A read-only search form is the case that must explicitly lift that guard:
- First GET (empty form) —
allow_getfalls back tois_submitted()→false,try_formreturnsErrwithout attempting validation, no errors set. The template renders a clean empty form. - GET with query params (search form) —
is_submitted()→true,try_formvalidates and returnsOk. Enables GET-based searches without extra code. - POST — standard behavior: validates and sets field errors if invalid (also needs an
allow_postthat permits it — the default already covers this case).
impl RuniqueForm for SearchForm {
// ...
fn allow_get(&self, _request: &Request) -> bool {
self.is_submitted()
}
}
// Unified GET+POST handler — no method branching needed
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();
// run the search...
validated.into_form()
}
// First GET or invalid GET/POST: no errors if nothing was
// submitted, field errors set otherwise.
Err(form) => form,
};
context_update!(request => { "search_form" => &form });
request.render("search.html")
}
To tell "first display" from "form sent but refused" (showing a global error message in the second case only), test
form.is_submitted()in theErr(form)branch. Aftertry_form, it is true only for the form whose name came back: on a page with several forms, each answers for itself, POST or GET. Don't testrequest.method.is_safe(): the method is the same for every form of the request.Err(form) if form.is_submitted() => { /* sent and refused: errors + message */ } Err(form) => { /* not sent: plain display */ }
`is_valid()` validation pipeline
Calling form.is_valid().await triggers 5 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 every field after step 1, even a field whose standard validation failedclean()— Cross-field validation (e.g.pwd1 == pwd2), only if steps 1 and 2 accepted everything; passwords are still plain text at this stepfinalize()— Final transformations (automatic hashing forPasswordfields if the global config is inAutomode, upload paths prepared)- Type check — Each field bound to a column must read as that column's type: a valid form is one the write can't refuse on a type
`clean_field` — per-field business validation
clean_field is called for each field after the standard validations, whether they passed or not. 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() - Called even if the field already failed (empty, format): check the value before setting an error, otherwise you overwrite the standard message
#[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("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]
impl RuniqueForm for RegisterForm {
// ...
async fn clean(&mut self) -> Result<(), StrMap> {
let pwd1 = self.cleaned::<String>("password").unwrap_or_default();
let pwd2 = self.cleaned::<String>("password_confirm").unwrap_or_default();
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 hashed duringfinalize()only if the global config isPasswordConfig::Auto(the default mode) and.no_hash()was not applied to the field. InManual,Delegated, orCustommode,finalize()does not hash — the application is responsible. Useclean()for any password comparison — it is the only step where they are still readable.Login form: the password field must not be hashed — it will be compared against the stored hash. Disable automatic hashing with
.no_hash():TextField::password("password").no_hash().required()
Writing — `save` and `delete`
A form tied to an entity (model!{}, extend!{}, #[form(schema = …)]) is written from the validated form ValidationForm::try_form returns. Runique walks its fields and writes the row: there is no write method to implement. A form without an entity has neither save nor delete: its handler does the work after try_form.
match ValidationForm::try_form(form, &request).await {
Ok(form) => match form.save(&request.engine.db).await {
Ok(_) => {
success!(request.notices => "Article saved");
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 and delete give the form back either way: Ok(form) once written (redirect, or clear() it and render again), Err(form) with the reason on its fields or its errors.
The row comes from the URL
The key is looked up in the route's parameters, by the name given to try_form:
{<name>_<key>}— e.g.{article_id}for the form"article";- else
{<key>}— e.g.{id}; - else none:
savecreates the row.
With a key, save updates that row; delete needs one. A key that doesn't read as the model's key type, or a missing row, gives "Resource not found" and nothing is written. Two forms on one page each point at their row by their own name.
What is written
- Outside the admin: SeaORM writes (
inserton a create,ActiveModel::save()on an update); the model's behavior (ActiveModelBehavior::before_save/after_save,auto_now) runs. Every field the form declares is written, secret columns included: a password field only as its hash (never the text typed in, never a.no_hash()field), and left empty on an update, it keeps the old value. Locked fields (readonly,disabled) are never written. A column that is no field is written only if the code gave it a value throughadd_value. A locked field isn't filled from the request either:cleaned()returns the value set by code, never one a visitor sent back. Checking that the user may touch the row is the handler's job, as withsearch!. - Inside the admin (panel and routes declared
admin): the gate writes, as the checked account — rights, owner, history, in one transaction. Secret columns are never written there.
Where business logic goes
- Values to check or adjust:
clean_field,clean. - Values imposed by the code (signed-in account, parent row):
add_valuebeforetry_form, on a column that is no field — see Columns set by the code. - Outside effects (email, cache) and writes to other tables: in the handler, after a successful
save.
`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 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. read before clear
// save...
form.clear(); // 2. empty
context_update!(request => {
"note_form" => &form,
"messages" => flash_now!(success => "Saved!"), // direct render
});
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.cleaned::<String>("title").unwrap_or_default()),
// ...
..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.