Forms (Form<T> + #[derive(Form)])
Declare form validation as field attributes on a struct; the Form<T> extractor parses + validates the body and the handler branches on the structured FormErrors.
Declarative form validation. Annotate a struct with #[derive(Form)] and per-field #[form(...)] rules; write Form<T> in the handler signature. The extractor parses the application/x-www-form-urlencoded body, runs validation, and hands the handler a Result<T, FormErrors> to branch on. No Option<String>-per-field error structs, no hand-rolled looks_like_email, no parallel form-only types.
The one rule
Validation errors originate at the ORM's WriteError. Every surface MAPS them, none REDEFINES them. REST 400 bodies, admin form spans, and HTML form re-renders all consume the same WriteError accessors (field_errors() + non_field_errors()). A custom field validator declares a WriteError::Validator { field, message } once and surfaces everywhere, with no per-surface translation drift.
FormErrors is a thin wrapper around WriteError that adds the template-friendly flat view ({{ errors.name }} instead of {{ errors.fields.name[0] }}).
Quick start
use serde::{Deserialize, Serialize};use umbral::forms::Form; #[derive(Debug, Default, Deserialize, Serialize, umbral::forms::Form)]#[form(normalize_strings)] // auto-trim every String fieldpub struct ContactForm { #[form(required, length(min = 1, max = 100))] name: String, #[form(required, email, max_length = 254)] email: String, #[form(optional, max_length = 30)] phone: String, #[form(required, length(min = 1, max = 200))] subject: String, #[form(required, length(min = 10, max = 5000))] message: String,} pub async fn submit_contact(form: Form<ContactForm>) -> impl IntoResponse { match form.into_result() { Ok(valid) => persist_and_redirect(valid).await, Err(errs) => render_form_with_errors(errs), }}The extractor never rejects on validation failure. Handlers always receive a Form<T> and decide what to render: re-render the page with errors, return a 4xx, queue a retry, whatever fits.
Field attributes
Under #[form(...)], per field:
| Attribute | Effect |
|---|---|
required | Reject empty values. Default for non-Option<T> types; required is the explicit/declarative form. |
optional | Accept empty values (skip Required check). Forced on for Option<T> fields. |
email | Validate as a syntactic email (@ + dot in domain, non-empty local part). Renders <input type="email">. |
url | Validate as a URL (scheme + host). Renders <input type="url">. |
phone | Validate against an E.164-shaped phone pattern. |
regex = "..." | Validate the value against an arbitrary regex pattern. Pair with message = "..." for the error text ({field} interpolates the field name). |
message = "..." | Custom error message for the field's regex validator. |
password | Render as <input type="password">. Same validation rules as text. |
min_length = N | Minimum character count. |
max_length = N | Maximum character count. |
length(min = N, max = M) | Combined shorthand. Either bound optional. |
Validators compose in declaration order (required → min_length/max_length → regex), and an empty/missing optional value skips the later checks. Example combined field:
#[form(required, max_length = 32, regex = r"^[a-z0-9_]+$", message = "{field} must be lowercase letters, digits, or underscore")]pub handle: String,Container-level (on the struct):
| Attribute | Effect |
|---|---|
#[form(normalize_strings)] | Auto-trim every String field before validation runs. Eliminates the per-field form.x = form.x.trim().to_string() boilerplate. |
Reuse the Model struct (recommended)
You don't need a parallel form type. Apply #[derive(Form)] directly to your persisted Model struct. The macro reads the existing #[umbral(...)] field attrs and skips server-managed fields automatically:
| Field marker | Skipped from form? | How it gets filled |
|---|---|---|
field named id | ✓ | DB auto-increment / is_default_pk sentinel |
#[umbral(primary_key)] | ✓ | DB auto-increment / sentinel |
#[umbral(auto_now_add)] | ✓ | ORM stamps Utc::now() on INSERT |
#[umbral(auto_now)] | ✓ | ORM stamps Utc::now() on INSERT + UPDATE |
#[umbral(noform)] | ✓ | Caller's responsibility (or Default::default() for safe values) |
| everything else | validated per #[form(...)] | parsed from the user's input |
The macro fills skipped fields via ..Default::default(), so the struct (and every choice enum on it) must derive Default.
use serde::{Deserialize, Serialize};use umbral::orm::{ChoiceField, Model};use chrono::{DateTime, Utc}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, umbral::orm::Choices, Default)]#[choices(rename_all = "lowercase")]#[serde(rename_all = "lowercase")]pub enum ContactStatus { #[default] // ← Default::default() = New New, Read, Replied, Closed,} #[derive(Debug, Clone, Default, sqlx::FromRow, Serialize, Deserialize, Model, umbral::forms::Form)]#[form(normalize_strings)]pub struct ContactMessage { pub id: i64, // skipped: implicit PK #[umbral(string)] // admin display hint #[form(required, length(min = 1, max = 100))] pub name: String, #[form(required, email, max_length = 254)] pub email: String, #[form(optional, max_length = 30)] pub phone: Option<String>, #[form(required, length(min = 1, max = 200))] pub subject: String, #[form(required, length(min = 10, max = 5000))] pub message: String, #[umbral(choices, noform)] // skipped: defaults to New pub status: ContactStatus, #[umbral(noform)] // skipped: None default pub ip_address: Option<String>, #[umbral(auto_now_add)] // skipped: ORM stamps Utc::now() pub created_at: DateTime<Utc>,} pub async fn submit_contact(form: Form<ContactMessage>) -> impl IntoResponse { let msg = match form.into_result() { Ok(m) => m, Err(errs) => return errs.render("contact.html"), }; // msg.status = ContactStatus::New, msg.ip_address = None, // msg.created_at = epoch (the ORM overwrites with Utc::now() on INSERT). ContactMessage::objects().create(msg).await?; Ok(Redirect::to("/contact?sent=1"))}The user's submission AND the persisted Model are now one declaration. No ContactForm mirror, no field-by-field copy, no drift when you add a field.
Rendering errors in templates
FormErrors::as_template_ctx() returns a serde_json::Map where each field key maps to the first error message for that field, plus a form key with the first non-field error if any. Templates write the flat shape:
{% if errors.form %} <div class="banner-error">{{ errors.form }}</div>{% endif %} <label for="email">Email</label><input name="email" type="email" value="{{ form.email }}" class="{% if errors.email %}border-rose-300{% endif %}">{% if errors.email %} <p class="error">{{ errors.email }}</p>{% endif %}Handler-side, the whole failure path is one call. FormErrors::render binds form to the raw pairs the user submitted (every keystroke kept), errors to the flat view above (with the summary banner defaulted when no non-field error supplied one), and returns 422:
Err(errs) => return errs.render("contact.html"),Need extra context keys on the re-render (page flags, chrome)? errs.render_with("contact.html", extra_map), and your keys win on collision. For templates that need to render every error per field (multiple validators firing on one input), call errs.field_errors() and pass the BTreeMap<String, Vec<String>> directly.
How errors flow
User input │ ▼Form<T>::from_request (parse body → HashMap<String, String>) │ ▼T::validate(&data) (FormValidate trait, derived) │ ├── Ok(T) → handler runs persistence │ └── Err(ValidationErrors) │ ▼ FormErrors::from(ValidationErrors) │ (lifts to WriteError::Multiple internally) │ ▼ handler renders: ├ as_template_ctx() → flat HTML form errors ├ field_errors() → {"field": ["msg"]} for REST ├ non_field_errors() → form-level banner messages └ into_write_error() → feed to admin form-span rendererThe lift through WriteError is what unifies all surfaces. A custom validator that produces WriteError::Validator { field, message } works across REST, admin, and HTML forms without a per-surface adapter.
Constraints (v1)
- Content type:
application/x-www-form-urlencodedonly.multipart/form-data(file uploads) is a follow-up. - Body size: capped at 16 MiB by default, configurable via
UMBRAL_MAX_FORM_BODY_BYTES(orSettings::max_form_body_bytesin code /umbral.toml). Set it to0to disable the cap entirely - handy in dev. Over-limit requests return 413. - Field types:
String,i8..i64/u8..u64/isize/usize,f32/f64,bool, andOption<T>of those. Server-managed Model fields (DateTime, choice enums) get skipped via#[umbral(...)]markers and needDefault. - Input preservation on validation failure: the failure branch's
FormErrorscarries the raw submitted pairs (the extractor attaches them viaFormErrors::with_raw).render/render_withbind those pairs to theformtemplate key, so re-rendering keeps every value the user typed - noaxum::Form<T>side-parse orDefault::default()fallback needed.
Relation fields on a form
When the #[derive(Form)] model carries relation fields, the derive classifies them automatically: a ForeignKey<T> (or forward OneToOne<T>) becomes a <select> whose submitted id is existence-checked against the target table before insert, an M2M<T> becomes a multi-<select> whose selected ids are written to the junction atomically after the parent insert (a single bad id fails validation before any row is written), and a #[umbral(choices)] enum becomes a <select> of the enum's variants. Reverse relations (ReverseSet<C>, reverse OneToOne) are back-pointers and are auto-skipped, with no #[umbral(noform)] needed.
Because FK/M2M existence checks and <select> option fetches hit the database, FormValidate::validate and render_html are async, and Form<T>::from_request awaits them for you, so a handler taking Form<T> needs no extra wiring. The full classification table and worked examples live on Forms learn relations.
See also
- Forms learn relations -
ForeignKey/OneToOne→ModelChoice,M2M→ModelMultiChoice, choices →Select, and the FK existence check + M2M atomicity. - Relationships - the
ForeignKey,OneToOne, andM2Mfield types the form derive reads. examples/shop/plugins/content/src/models.rs-ContactMessageis both the Model and the Form (the canonical reuse example).examples/shop/src/views/public.rs-submit_contactis the handler that drives the above.umbral::orm::write::WriteError- the canonical error type every form surface lifts to.- Admin per-field error rendering - same
WriteError, different surface.