Forms
Documentation / Forms / RuniqueForm trait

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 {}

💡 Split: FormAccess owns register_fields + accessors; RuniqueForm owns validation hooks. impl_form_access!() generates from_form(), forms() and forms_mut() inside impl FormAccess. It's meant for a struct whose field is named form. A form taken from a model (#[form(schema = …)]) needs neither block: the macro writes them.

With #[form(schema = …)], the macro generates impl FormAccess for you — user code only writes impl RuniqueForm (clean, customize…).

Security note: forms() provides access to the internal structure. For extracting cleaned data, always use the cleaned_* methods of the trait (see below). Direct extraction methods on Forms are reserved for internal framework use.

Equivalent without the macro (for reference)
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> returns Option<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 ModelForm for max_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 (email in two forms);
  • a CSRF error, if any, only shows on the form submitted;
  • the others come back from try_form as Err, with no error set and is_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_get falls back to is_submitted() → false, try_form returns Err without attempting validation, no errors set. The template renders a clean empty form.
  • GET with query params (search form) — is_submitted() → true, try_form validates and returns Ok. Enables GET-based searches without extra code.
  • POST — standard behavior: validates and sets field errors if invalid (also needs an allow_post that 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 the Err(form) branch. After try_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 test request.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):

  1. Field validation — Each field runs validate(): required, length, format (email via validator, URL via validator, JSON via serde_json, UUID via uuid, IP via std::net::IpAddr, …)
  2. clean_field(name) — Per-field business validation, called for every field after step 1, even a field whose standard validation failed
  3. clean() — Cross-field validation (e.g. pwd1 == pwd2), only if steps 1 and 2 accepted everything; passwords are still plain text at this step
  4. finalize() — Final transformations (automatic hashing for Password fields if the global config is in Auto mode, upload paths prepared)
  5. 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 true if the field is valid, false otherwise
  • 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_field is ideal for isolated rules on a single field. For rules that involve multiple fields at once, use clean().

⚠️ Do not call clean_field from within clean: the pipeline guarantees that clean_field has already run for every field before clean is called. Calling it again would be redundant and could set a duplicate error on a field. Furthermore, clean is only invoked if all clean_field calls returned true — so from within clean, 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: Password fields are hashed during finalize() only if the global config is PasswordConfig::Auto (the default mode) and .no_hash() was not applied to the field. In Manual, Delegated, or Custom mode, finalize() does not hash — the application is responsible. Use clean() 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:

  1. {<name>_<key>} — e.g. {article_id} for the form "article";
  2. else {<key>} — e.g. {id};
  3. else none: save creates 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 (insert on 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 through add_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 with search!.
  • 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_value before try_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 &self method (immutable) — does not compile
  • In clean() or clean_field() — these run during is_valid(), before save() reads the data; calling clear() 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 →