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!();
}
Equivalent without the macro (for reference)π‘
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).Security note:
get_form()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 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() / save_as() β persistence (with or without hooks)
β before_save() [optional β hook before persistence]
β on_save() β main logic (INSERT, UPDATEβ¦)
β after_save() [optional β hook after persistence]
β
database_error() β [optional] 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() β 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 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.
on_save(&txn) (optional) β Main persistence logic, executed inside the transaction. This is where DB writes go (INSERT, UPDATE, DELETE). No-op by default.
before_save(ctx, &txn) (optional) β Hook executed inside the transaction before on_save. No-op by default.
after_save(ctx, &txn) (optional) β Hook executed inside the transaction after on_save. No-op by default.
save(&db) β Opens a transaction, calls on_save, commits. Automatic rollback on error.
save_as(ctx, &db) β Like save, but orchestrates all three hooks: before_save β on_save β after_save. Rollback on any error.
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.
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(). They also cover route parameters and query string parameters.
cleaned_stringreturnsOption<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(&FileField::image("photo").max_size(FileSize::mb(5)));
// In the handler β override allowed if β€ ceiling
form.max_size("photo", FileSize::mb(3))?; // Ok
form.max_size("photo", FileSize::mb(6))?; // Err β exceeds model ceiling
Separation of concerns: the model owns the maximum constraint, the form can narrow it down. Same principle as Django's
ModelFormformax_length.
`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_new(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_newreturnsErrwithout attempting validation, no errors set. The template renders a clean empty form. - GET with query params (search form) β
is_submitted()βtrue,try_newvalidates 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();
let form = match ValidationForm::try_new(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 explicitly distinguish "first page load" from "form submitted with invalid data", use
request.method.is_safe()(GET/HEAD).
`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 hashing forPasswordfields if the global config is inAutomode)
`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.cleaned_string("username").unwrap_or_default();
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.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()
Save hooks β `save_as`, `on_save`, `before_save`, `after_save`
Two methods are available to persist form data:
| Method | Description |
|---|---|
save(&db) | Simple transaction: on_save β commit |
save_as(ctx, &db) | Transaction with hooks: before_save β on_save β after_save β commit |
All three hooks run inside the same transaction. If any of them returns Err, the transaction is rolled back and the error is propagated β nothing is written to the DB.
SaveContext β distinguishing Create, Update, Delete
use runique::prelude::SaveContext;
// In the handler:
form.save_as(SaveContext::Create, &db).await?;
form.save_as(SaveContext::Update, &db).await?;
form.save_as(SaveContext::Delete, &db).await?;
Full implementation example
use async_trait::async_trait;
use runique::prelude::*;
use sea_orm::{DatabaseTransaction, DbErr, Set};
#[async_trait]
impl RuniqueForm for ArticleForm {
// ...
async fn before_save(&mut self, ctx: SaveContext, txn: &DatabaseTransaction) -> Result<(), DbErr> {
if ctx == SaveContext::Create {
// e.g. check a quota limit, log intentβ¦
}
Ok(())
}
async fn on_save(&mut self, txn: &DatabaseTransaction) -> Result<(), DbErr> {
let title = self.cleaned_string("title").unwrap_or_default();
article::ActiveModel {
title: Set(title),
..Default::default()
}
.insert(txn)
.await?;
Ok(())
}
async fn after_save(&mut self, ctx: SaveContext, _txn: &DatabaseTransaction) -> Result<(), DbErr> {
if ctx == SaveContext::Create {
// e.g. send a notification, invalidate a cacheβ¦
}
Ok(())
}
}
Note:
before_saveandafter_savealso receive the&DatabaseTransactionβ they can perform their own DB reads or writes within the same transaction.
save()without hooks: if you don't needbefore_save/after_save, usesave()directly β it callson_savewithout the hook overhead.
`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").unwrap_or_default(); // 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.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.
β Form extraction | Typed conversion helpers β