This version is in beta. Some features may change before release.

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

Code
rust
use serde::{Deserialize, Serialize};
use umbral::forms::Form;
 
#[derive(Debug, Default, Deserialize, Serialize, umbral::forms::Form)]
#[form(normalize_strings)] // auto-trim every String field
pub 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:

AttributeEffect
requiredReject empty values. Default for non-Option<T> types; required is the explicit/declarative form.
optionalAccept empty values (skip Required check). Forced on for Option<T> fields.
emailValidate as a syntactic email (@ + dot in domain, non-empty local part). Renders <input type="email">.
urlValidate as a URL (scheme + host). Renders <input type="url">.
phoneValidate 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.
passwordRender as <input type="password">. Same validation rules as text.
min_length = NMinimum character count.
max_length = NMaximum character count.
length(min = N, max = M)Combined shorthand. Either bound optional.

Validators compose in declaration order (requiredmin_length/max_lengthregex), and an empty/missing optional value skips the later checks. Example combined field:

Code
rust
#[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):

AttributeEffect
#[form(normalize_strings)]Auto-trim every String field before validation runs. Eliminates the per-field form.x = form.x.trim().to_string() boilerplate.

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 markerSkipped from form?How it gets filled
field named idDB 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 elsevalidated 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.

Code
rust
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:

Code
html
{% 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:

Code
rust
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

Code
txt
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 renderer

The 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-urlencoded only. multipart/form-data (file uploads) is a follow-up.
  • Body size: capped at 16 MiB by default, configurable via UMBRAL_MAX_FORM_BODY_BYTES (or Settings::max_form_body_bytes in code / umbral.toml). Set it to 0 to disable the cap entirely - handy in dev. Over-limit requests return 413.
  • Field types: String, i8..i64 / u8..u64 / isize / usize, f32 / f64, bool, and Option<T> of those. Server-managed Model fields (DateTime, choice enums) get skipped via #[umbral(...)] markers and need Default.
  • Input preservation on validation failure: the failure branch's FormErrors carries the raw submitted pairs (the extractor attaches them via FormErrors::with_raw). render / render_with bind those pairs to the form template key, so re-rendering keeps every value the user typed - no axum::Form<T> side-parse or Default::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

webformsvalidation