A form submission is the most common way a user changes data, and it is the one place where "the happy path" is the rare path. Someone will submit a blank title, an email with a trailing space, a date that will not parse, or the same form twice. Autumn's answer has three layers, and knowing which layer owns which problem is most of the work:
| Layer | Owns | Primary type |
|---|---|---|
| The form | re-rendering the page with the user's input still in the fields | ChangesetForm<T> |
| Validation | deciding whether a value is acceptable, and saying why | #[validate(...)], Valid<T> |
| Normalization | making an acceptable value canonical before it is stored | #[normalize(...)] |
Those three layers run in two distinct places, and conflating them is the single easiest way to write a validator that rejects input you meant to accept:
- The form layer validates first, on raw input.
ChangesetFormextraction callsIntoChangeset, which runsvalidator::Validateon the value exactly as it was decoded from the body — before any handler code, and before any repository sees it. - The repository layer then normalizes, validates, and saves, in that order.
This is the pipeline
#[normalize(...)]belongs to.
So normalize, then validate, then save is true of the model write path,
not of the form. A form-level #[validate(email)] sees " [email protected] "
with its spaces intact and rejects it, even though the model carries
#[normalize(trim)] and would have canonicalized it a moment later.
Design around it: keep form-level validators tolerant of what a human actually types (shape and length, not exact canonical form), and put the rules that assume a canonical value on the model, where normalization has already run.
The shape of a form round-trip
Server-rendered forms have one hard requirement that API endpoints do not: on failure you must render the form again, with the values the user typed and the errors next to the right fields. Everything else follows from that.
A Changeset<T> is the value that makes it possible — the submitted data
plus the per-field errors, together. ChangesetForm<T> is the extractor that
produces one:
use autumn_web::prelude::*;
use autumn_web::form::ChangesetForm;
use serde::{Deserialize, Serialize};
use validator::Validate;
// `Default` is here for the blank-form constructor further down
// (`ChangesetForm::blank(TodoForm::default(), ..)`): a form struct that a GET
// route renders empty needs it, and every field type already has one.
#[derive(Deserialize, Serialize, Validate, Clone, Default)]
pub struct TodoForm {
#[validate(length(min = 1, max = 255, message = "Title must be 1–255 characters"))]
title: String,
}
#[get("/todos/new")]
async fn new_todo(csrf: CsrfToken) -> Markup {
// Correct as written under the default CSRF field name. If your app sets
// `security.csrf.form_field`, this needs one more parameter — see
// "CSRF is not optional, and not your job" below.
let blank = ChangesetForm::blank(TodoForm { title: String::new() }, csrf.token());
layout(render_form(&blank))
}
#[post("/todos")]
async fn create(mut db: Db, form: ChangesetForm<TodoForm>) -> AutumnResult<impl IntoResponse> {
match form.into_valid() {
Ok(valid) => {
insert(&mut db, valid).await?;
Ok(Redirect::to("/todos").into_response())
}
Err(form) => {
// `form` still holds the submitted values *and* the errors.
Ok((StatusCode::UNPROCESSABLE_ENTITY, render_form(&form)).into_response())
}
}
}
into_valid is the whole control flow: Ok(T) on success, Err(Self) on
failure — and the Err arm gives the form back, ready to re-render. There is no
flash-carrying, no session round-trip, and no second deserialization.
examples/todo-app/src/routes/todos.rs is this pattern end to end, including
the list page it re-renders on failure.
Rendering the fields
ChangesetForm renders each control with its current value, its errors, and the
ARIA wiring that ties the two together:
fn render_form(form: &ChangesetForm<TodoForm>) -> Markup {
form.form_tag("/todos", "post", html! {
(form.text_input("title", "Title"))
(form.submit_button("Add"))
})
}
form_tag emits the hidden _csrf input automatically — see
CSRF below. For fields the built-in
helpers do not cover, read the state yourself:
| Method | Returns |
|---|---|
form.field_value("title") | the submitted value, for the value= attribute |
form.errors_for("title") | this field's error messages |
form.is_valid() | whether the changeset has any errors at all |
The free functions in autumn_web::form cover the rest of the control types:
password_input, textarea_input, checkbox_input, number_input,
date_input, datetime_input, and select_input. The native inputs below
add the switch, the slider, the groups and more types.
Each control has a required_* variant. It adds required,
aria-required="true" and a visual * marker in the label. The marker has
aria-hidden="true", so a screen reader says "required" one time only. Tell
sighted users what * means, for example with "* required" above the form.
required_* writes only the client-side signal. The model rule
#[validate(presence)] rejects a blank submission.
Hints, attributes and required state: FieldOptions
Each helper also has a *_with variant that takes &FieldOptions. The label
stays a positional argument, so a field with no label does not compile.
use autumn_web::form::{FieldOptions, text_input_with};
text_input_with(&changeset, "email", "Email", &FieldOptions::new()
.hint("We never share your address.")
.required()
.autocomplete("email")
.placeholder("[email protected]")
.data("test-id", "signup-email")
.hx("indicator", "#spinner"))
hintrenders<p id="email-hint" class="autumn-field__hint">between the label and the control (after the label for a checkbox).aria-describedbynames the hint first, then the error.- The typed attributes (
placeholder,autocomplete,inputmode,pattern,minlength,maxlength,min,max,disabled,readonly,multiple) render only on controls that use them. dataandhxadddata-*andhx-*attributes. There is no setter forid,name,classoraria-*: the helper owns them. The htmx helpers also keep their ownhx-post,hx-paramsand the otherhx-*names they set. If you set one name two times, the last value applies.- The browser does not submit the value of a
disabledcontrol.
| Helper | With options |
|---|---|
text_input | text_input_with |
text_input_htmx_with_token_field | text_input_htmx_with |
password_input | password_input_with |
textarea_input | textarea_input_with |
checkbox_input | checkbox_input_with |
number_input | number_input_with |
date_input | date_input_with |
datetime_input | datetime_input_with |
select_input | select_input_with |
grouped_select_input | grouped_select_input_with |
rich_text_area_with_labels | rich_text_area_with |
rich_text_area_htmx_with_token_field_with_labels | rich_text_area_htmx_with |
switch_input, range_input, radio_group, checkbox_group, segmented_control | *_with |
email_input, tel_input, url_input, time_input, month_input, color_input | *_with |
hidden_input | hidden_input_with |
otp_input | otp_input_with |
The *_with htmx helpers take the submit-token field name. Pass
"_submit_token" for the default. A nested-form RowScope has
text_input_with, number_input_with and textarea_input_with.
The Autumn stylesheet marks a field invalid with :user-invalid, so the
browser's own check shows only after the user changes the field or submits.
Server errors use the autumn-field__input--invalid class.
Native inputs
These helpers use native HTML controls, so they work with JavaScript off. Each one has the wrapper, the hint, the errors and the passthrough above.
| Helper | Control | Bind to | required_* |
|---|---|---|---|
switch_input | <input type="checkbox" role="switch"> | #[serde(default)] bool | yes |
range_input(…, step) | <input type="range"> | a number | no |
radio_group(…, choices) | radios in a <fieldset> | a String or an enum | yes |
checkbox_group(…, choices) | boxes in a <fieldset> | #[serde(default)] Vec<String> | no |
segmented_control(…, choices) | radios that look like buttons | a String or an enum | yes |
email_input, tel_input, url_input | type="email", "tel", "url" | String | yes |
time_input | type="time" | chrono::NaiveTime | yes |
month_input | type="month" | String (YYYY-MM) | yes |
color_input | type="color" | String (#rrggbb) | no |
hidden_input(changeset, field) | type="hidden" | any | no |
use autumn_web::form::{FieldOptions, checkbox_group_with, radio_group, switch_input};
(switch_input(&changeset, "alerts", "Email alerts"))
(radio_group(&changeset, "plan", "Plan", &[("free", "Free"), ("pro", "Pro")]))
(checkbox_group_with(&changeset, "topics", "Topics",
&[("rust", "Rust"), ("web", "Web")],
&FieldOptions::new().hint("Choose one or more.")))
- A group is a
<fieldset>with a<legend>. The hint and error links go on the<fieldset>. The first input hasid="{field}", so an error summary link moves focus to the group. The other inputs haveid="{field}-{index}". - A checkbox group posts one
topics=…pair for each checked box, and no key when no box is checked.Changeset::field_valueschecks the boxes again after a 422. requiredon a box means "check this box". So a required checkbox group shows the marker only. Validate "one or more" on the server, for example with#[validate(length(min = 1))].- A radio has no
aria-required, because ARIA does not allow it.requiredon each radio makes the group required. - A slider, a color and a hidden input always have a value. They ignore
required. - The time value shows as
HH:MM, or with seconds andstep="1"when the seconds are not zero. The month value shows asYYYY-MM, also from a date. The color value shows as a lowercase#rrggbb. Any other color is not shown. - A hidden input has no label, no wrapper and no error block. Show its errors with the error summary.
form_for takes the same controls: FieldControl::Switch, Range { step },
Radio { options }, CheckboxGroup { options }, Segmented { options },
Email, Tel, Url, Time, Month, Color and Hidden. A #[model]
NaiveTime column gets Time. A non-null column does not make a switch, a
slider, a color, a hidden input or a checkbox group required.
The "Native inputs" and "Choice controls" stories show each control.
minlength, maxlength, min, max, disabled, readonly, multiple)
render only on controls that use them.
Passwords and one-time codes
A password field and a one-time code (OTP) field need more than a plain input. Both are in core, and the auth generator uses both.
use autumn_web::form::{FieldOptions, OtpFormat, otp_input_with, password_input_with,
required_otp_input};
(password_input_with(&changeset, "password", "Password", &FieldOptions::new()
.required()
.new_password()
.minlength(12)
.reveal()
.strength_hint()))
(required_otp_input(&changeset, "code", "Authenticator code"))
(otp_input_with(&changeset, "backup", "Backup code",
OtpFormat::digits(8).group(4), &FieldOptions::new()))
Password options (other controls ignore them):
current_password()andnew_password()setautocomplete. Use the first on a sign-in form. Use the second on a signup or a change form, so a password manager can suggest a password.reveal()adds a "Show password" button witharia-pressed. The button hashiddenuntilautumn-widgets.jsshows it. A submit, a page hide and an htmx history snapshot hide the password again.reveal_label(text)sets the button text.strength_hint()adds<p id="{field}-strength" aria-live="polite">and puts its id inaria-describedby. The script writes "Strength: weak", "fair", "good" or "strong" from the length and the character kinds. The text changes only when the level changes.strength_labels([..; 4])sets the four labels. Name the subject in each label: a screen reader reads the label alone. It is a hint, not a policy: it does not know common passwords. Check the password on the server.- A revealable password has
spellcheck="false"andautocapitalize="off", so a cloud spellcheck does not read the revealed text.
The OTP field is one <input type="text" autocomplete="one-time-code" inputmode="numeric">. CSS draws a box for each digit behind it. One real
input keeps SMS autofill and paste working, and a screen reader finds one
field.
OtpFormat::digits(n)sets the length, 4 to 10. The default is 6.pattern="[0-9]{n}"checks the code on submit, also without JavaScript.maxlength="{n}"keeps a page with no JavaScript from showing more digits than boxes.- The script removes that
maxlength. It keeps only the digits of a paste, an autofill or an IME entry, up to the length. A paste of123-456gives123456. Full-width digits (123) become ASCII digits. .group(g)shows1234 5678.gis 2 to 5 and less thann. The group needs the script. It changespattern, shows the separator boxes, and removes the spaces from the form data and from an htmx request, so the server gets the digits only. Strip whitespace on the server too. Apatternoption turns the group off, so your pattern checks the code.- The value is never rendered: a code is used one time.
- An
autocomplete,inputmode,patternormaxlengthoption replaces the default.placeholderandminlengthpass through. - On a narrow screen the boxes get smaller, so the page does not scroll sideways.
- Do not use it for a recovery code with letters. The generated
/login/verifypage keeps a text input for that reason.
form_for takes FieldControl::Otp(OtpFormat). The "Sign-in inputs" story
shows each option.
Select: groups, multiple and style
select_input renders a native <select>. The browser
supplies the keyboard behaviour and the accessibility tree. There is no
JavaScript.
| Item | Use |
|---|---|
grouped_select_input(…, choices, groups) | Top-level choices, then one <optgroup> for each (label, options) group |
FieldOptions::multiple() | <select multiple>. Bind to a #[serde(default)] Vec<T> |
FieldOptions::styled_select() | The autumn-select class: appearance: base-select where the browser supports it |
use autumn_web::form::{FieldOptions, grouped_select_input_with, select_input_with};
(grouped_select_input_with(&changeset, "city", "City",
&[("", "Choose a city")],
&[
("France", &[("paris", "Paris"), ("lyon", "Lyon")]),
("Japan", &[("tokyo", "Tokyo")]),
],
&FieldOptions::new().required().styled_select()))
(select_input_with(&changeset, "tags", "Tags",
&[("rust", "Rust"), ("web", "Web")],
&FieldOptions::new().multiple()))
- A single select marks the first option whose value is the field value. It marks one option only, also when two options have that value.
- A multiple select reads
Changeset::field_values. It marks each option whose value is in the list. Thus the chosen options show again after a 422. The browser posts onetags=…pair for each chosen option. It posts no key when no option is chosen. requiredon a multiple select means "choose one or more". Also validate on the server, for example with#[validate(length(min = 1))].- A multiple select on a
Stringfield gets a 422 when the user chooses two options. Bind it to aVec<T>. - Autumn also writes
multipleon a file input. Read the files withChangesetForm::files(field). Other controls ignoremultiple. styled_selectis opt-in. Chrome 135+ and Safari 27 supportappearance: base-select. They draw the select and its picker from the design tokens, in the light and the dark scheme. Other browsers keep the native control with the field style. A multiple select keeps the native list box in all browsers.- In a styled select, the arrow keys, Enter and Space open the picker. The arrow keys move in the picker. Enter chooses an option. Escape closes the picker.
form_for takes FieldControl::GroupedSelect { options, groups }. For a
multiple select:
- Use
.override_field(name, FieldControl::Select { options }), orGroupedSelect. - Set
.field_options(name, FieldOptions::new().multiple()).
The model required flag does not make a multiple select required, because
a Vec can be empty.
The "Select" story shows the styled select, the native fallback, a grouped select and a multiple select.
The error summary
autumn_web::widgets::error_summary(&changeset) lists every error at the top of
the form. Each entry links to #field, the id of the control. The summary has
tabindex="-1" and autofocus, so focus moves to it when a submit fails, with
no JavaScript. A form-level error (key __all__) has no link. Put one summary
on a page, with no other autofocus element. Do not return it from an
inline-validation response: it takes focus while the user types.
Rendering a whole form in one call
A #[model] implements FormModel, so form_for can derive one control per
editable column:
use autumn_web::form::{FieldOptions, form_for};
form_for(&changeset, "/posts", "post")
.csrf(csrf.token())
.exclude("author_id")
.override_label("body", "Post text")
.field_options("title", FieldOptions::new().hint("Keep it short."))
.render()
Use .field_options(field, FieldOptions) to give one field a hint, attributes
or the required state. A checkbox and a password become required only from
FieldOptions: a non-null bool column does not mean "must be checked", and
the browser never fills a password field again on an edit form.
This is what the scaffold generator emits. Reach for the individual helpers when
the form's layout matters; reach for form_for when it does not.
Controls from a widget pack
A widget pack (for example a date picker) gives a FieldRenderer. Bind it
to one field in two ways:
use autumn_web::form::{FieldControl, FieldOptions, custom_input_with};
// With a changeset:
custom_input_with(&changeset, "due", "Due", &DatePicker, &FieldOptions::new())
// With form_for:
form_for(&changeset, "/tasks", "post")
.override_field("due", FieldControl::custom(DatePicker))
.render()
The control gets the same wrapper, label, hint, errors and passthrough as a core control. See Widget Packs.
Two validation paths, and how to choose
Autumn validates with the validator crate:
#[validate(...)] attributes on the struct, #[derive(Validate)] to run them.
Where the result goes is what differs.
ChangesetForm<T> — for pages a human is looking at
Invalid input is data, not an error: the extractor does not fail on a validation error, the handler decides what to render, and the user gets their input back. This is the right choice for every server-rendered HTML form.
It can still reject a body it cannot decode. decode_form_body returns 415
for the wrong Content-Type, 400 when a field will not deserialize into its
declared type, and 422 when a single-value field arrives more than once (see
multi-value fields) — before the handler runs, so there
is nothing to re-render with. The round-trip covers invalid values, not
unparseable ones.
That distinction has a practical consequence: type a form field as the string
the browser actually submits, and convert after validation. A field typed
i64 turns "abc" — or an empty select — into a hard 400 that discards the whole
submission, title and body included. A String field with a validator that
parses it turns the same input into an inline message next to the offending
field:
#[derive(Deserialize, Serialize, Validate, Clone, Default)]
struct SubmitPostForm {
// Not `i64`: a form field's job is to round-trip whatever the browser
// sent, so the page can be re-rendered with it. The conversion happens
// after validation proves it parses.
#[validate(custom(function = "validate_subreddit_choice"))]
subreddit_id: String,
#[validate(length(min = 1, max = 300))]
title: String,
}
fn validate_subreddit_choice(value: &str) -> Result<(), ValidationError> {
match value.trim().parse::<i64>() {
Ok(id) if id > 0 => Ok(()),
_ => Err(ValidationError::new("subreddit_id")
.with_message("Choose a community".into())),
}
}
Reserve non-string field types for values a browser cannot get wrong, or for
endpoints where a 400 is the right answer. examples/reddit-clone's submit form
is built this way, for exactly this reason.
One custom validator is worth spelling out, because it is easy to write as dead
code. To reject a title that carries no text — "***", "🎉🔥💯" — ask
autumn_web::contains_letter_or_number, not slugify(value).is_empty():
slugify never returns an empty string (input with nothing to slugify gets a
stable hash fallback), so the is_empty() form is always false and rejects
nothing. See Generators
for the full contract.
fn validate_sluggable_title(value: &str) -> Result<(), ValidationError> {
if autumn_web::contains_letter_or_number(value) {
return Ok(());
}
Err(ValidationError::new("title")
.with_message("Title must contain at least one letter or number".into()))
}
A route-local validator only runs on the route. If the same model is also written through generated API routes or a repository, put the rule in a mutation hook as well — the hooks are the choke point those paths share.
Valid<T> — for endpoints a program is calling
Invalid input is an error. Valid wraps another extractor, and a validation
failure short-circuits into a 422 with a field-level error map before the
handler body runs:
use autumn_web::Valid;
#[post("/api/posts")]
async fn create(Valid(Json(post)): Valid<Json<NewPost>>) -> impl IntoResponse {
// `post` is guaranteed valid; there is no invalid branch to write.
}
It wraps Json, Form, or Query alike.
Validated<T> and .validate() — for values that did not arrive in a request
Sometimes the value comes from a job payload, a CSV row, or another service.
ValidateExt::validate runs the same rules and returns
AutumnResult<Validated<T>>, so ? works:
use autumn_web::ValidateExt;
let row: Validated<ImportRow> = raw_row.validate()?;
Validated<T> is proof that validation ran: it cannot be constructed outside
the framework, it derefs to T for reading, and it deliberately does not
implement DerefMut, so it cannot be mutated back into an invalid state.
Reading the failure back
Off the HTTP path nothing renders the 422 for you, so read the field map off
the error instead of re-running validator yourself:
for (index, raw_row) in rows.into_iter().enumerate() {
match raw_row.validate() {
Ok(row) => import(row),
// "Validation failed: email: Must be a valid email address"
Err(error) => tracing::warn!(row = index, error = %error, "skipped invalid row"),
}
}
Three accessors, on any AutumnError:
details()returns the field map for a validation failure,Noneotherwise. It is aHashMap, so sort the keys before you render them.code()returns the stable code theproblem+jsonbody carries —autumn.validation_failed,autumn.not_found, and so on.message()returns the wrapped error's message alone. It is not redacted, andstatus()does not tell you whether the response redacted it — the renderer reclassifies a cancelled database statement to a503, so even a4xxhere can be a redacted response. Render the error when you need a client-safe string.
Display appends the failing fields to message(), sorted by field name.
Keep untrusted text out of your #[validate(message = "...")] strings: this
output reaches your logs.
The HTTP response does not change. Its detail still reads Validation failed, with the fields in errors.
Which one?
| The caller is… | Use | Failure looks like |
|---|---|---|
| a browser rendering HTML | ChangesetForm<T> | the same page, 422, errors inline (a body that will not decode is still 400/415) |
| an API client, an MCP tool, htmx expecting JSON | Valid<T> | 422 Problem Details with a field map |
| not a request at all | .validate() → Validated<T> | an AutumnError you handle |
Model-level validation
Putting #[validate(...)] on the #[model] itself moves the rule to where the
data lives, so every write path is covered — a form, an API endpoint, a
seed, a job, a CSV import:
#[model]
pub struct User {
#[validate(email(message = "Must be a valid email address"))]
pub email: String,
#[validate(length(min = 2, max = 60))]
pub display_name: String,
}
The generated repository runs these rules on insert, after #[normalize]
canonicalizes the payload and before the before_create hook. Every generated
insert path applies them — save, save_many, save_many_skip_invalid, and
the create half of find_or_create_by_* — so a GraphQL resolver, a #[task]
or an admin action reaching the repository gets the same answer a generated
--api handler does.
What "every write path" means, precisely: every write through the generated repository. Three things in a normal project are not that, and are not covered:
| Not covered | Why |
|---|---|
Model::factory().create(), and so autumn seed | The factory inserts with Diesel directly, bypassing the repository — it builds fixtures, and a fixture that had to satisfy every rule would be useless for testing the rules |
A hand-written repository, or a handler using diesel::insert_into for cross-table atomicity | No generated code runs |
upsert_many | It takes whole models, and Postgres decides per row whether the statement inserts or updates — there is no insert to hang the rule on. Validate before you call it |
A database CHECK constraint is what covers all three at once.
Each path keeps its own failure shape:
| Insert path | An invalid row |
|---|---|
save | 422 with the field map; nothing is written |
save_many | 422 on the first offender, before any row is written |
save_many_skip_invalid | reported as (index, error) against the caller's own index; the valid rows still land |
find_or_create_by_* | 422 only when the row is about to be created — a row that already exists is returned unchanged |
A payload type with no #[validate] columns costs nothing: the check compiles
away.
A generated --api handler still checks the decoded payload itself, before it
reaches the repository, so it can answer 422 without a database round trip. The
rules therefore run twice on that path — worth knowing for a
#[validate(custom = ...)] that is expensive or has side effects. The two see
different values: the handler's check sees the payload as submitted, the
repository's sees it normalized. A value only normalization would make valid —
" ab " against #[normalize(trim)] #[validate(length(max = 3))] — is
therefore refused at the endpoint, though repo.save would accept it.
The rules run before before_create, which matters if a hook fills a field
in. A before_create that derives slug from title runs after the model
has already judged slug — so a #[validate(length(min = 1))] on a
hook-populated column rejects every insert. Validate only what the caller
supplies, or derive the value before you build the New*.
On update, merged-model validation is opt-in. A repository with hooks, or
one declared #[repository(Post, validate_on_update = fetch)], loads the
existing row, applies the patch, and validates the merged model — so a
must_match or a #[validate(custom = ...)] comparing two columns sees real
values on both sides, and a partial update that would break the invariant is
rejected with the same 422 field-error map an insert produces.
A plain generated repository with no hooks and no knob takes the blind
update path: it emits no merged-model check at all, deliberately, to avoid an
unconditional extra SELECT on every update.
On that path the repository validates nothing at all — not cross-field
rules, and not the field-level rules on the columns being written. The blind
branch goes straight from changes.__to_changeset() to the UPDATE. So a
direct repo.update(id, &UpdateUser { email: Patch::Set("nope".into()), .. })
persists a value your #[validate(email)] would have rejected.
Validation on an update comes from the caller, not the repository: a generated
--api handler validates the patch before calling it, and a ChangesetForm
handler validates the submitted form struct. If you call the repository directly
and want the model's rules enforced, give it hooks, turn on
validate_on_update = fetch, or validate before building the patch — do not
assume it.
Turning it on changes what is validated, not what is stored: only the hooked path persists normalized values. See Updates are not the same as inserts for the three paths side by side.
Even with the knob, that check is point-in-time against a non-locked snapshot. It reliably rejects a single request that is invalid once merged, but two concurrent partial writes to different fields can still interleave into an invalid row. A cross-field invariant you cannot afford to lose wants a database
CHECKconstraint underneath it.
Model rules and form rules are not redundant. Put the invariant on the
model, and put anything the form alone knows about on the form struct:
confirm_password, an "I agree" checkbox, a field that exists only in the UI.
The form struct is also where you put rules that need a different message for
end users than for API clients.
Normalization: #[normalize(...)]
Validation decides whether to accept a value. Normalization decides what
exactly gets stored. They are different problems, and conflating them produces
the classic bug: " [email protected] " passes an email validator, is stored
verbatim, and then never matches a lookup for [email protected].
#[model]
pub struct User {
#[normalize(trim, downcase)]
#[validate(email)]
pub email: String,
#[normalize(squish)]
pub display_name: String,
}
Built-in normalizers, applied left to right in the order you write them:
| Normalizer | Effect |
|---|---|
trim | strip leading and trailing whitespace |
downcase | lowercase |
upcase | uppercase |
squish | trim, and collapse every internal run of whitespace to one space |
strip_nul | remove every NUL (U+0000) — see Unstorable bytes |
with = path::to::fn | your own fn(&str) -> String |
All the built-ins are idempotent — normalizing an already-normal value changes nothing — which is what lets the same function run on the write path and on lookups and still agree.
Where it runs
Normalization happens at the head of the repository insert flow: save,
save_many, save_many_skip_invalid and find_or_create_by_* normalize the
New* insert struct, before validation and before the before_create
hook, so:
submitted value
→ #[normalize] chain " [email protected] " → "[email protected]"
→ #[validate] rules validators see the canonical value
→ before_create hook your code sees the canonical value
→ INSERT the database stores the canonical value
A validator that would have rejected the raw value for its whitespace never
sees the whitespace. That is the intent: #[normalize(trim)] on a
#[validate(length(min = 1))] field means "a title of only spaces is blank",
which is almost always what you wanted.
Updates are not the same as inserts
The insert path above stores the canonical value. Two of the three update paths do not. They differ in what validation sees and in what actually gets written, and those are not the same question:
| Repository | Merged model built? | Validation sees | Persisted |
|---|---|---|---|
| No hooks, no knob (blind) | no — no from_patch at all | nothing — the repository runs no validation | raw patch |
validate_on_update = fetch | yes, and it is normalized | the normalized merged model | raw patch |
| Has hooks | yes | the normalized draft | normalized draft |
So on either of the first two, this stores the untrimmed, uncased string even on
a #[normalize(trim, downcase)] column:
repo.update(id, &UpdateUser { email: Patch::Set(" [email protected] ".into()), ..Default::default() }).await?;
// stored: " [email protected] " — not "[email protected]"
The middle row is the surprising one, and it is deliberate: from_patch
normalizes the merged model in order to validate it, but that draft is
side-effect-only — the generated code keeps its 422 and throws the value away,
persisting changes.__to_changeset() unchanged. A value that passes validation
only because normalization cleaned it up is therefore stored dirty.
autumn-macros/src/repository.rs calls this the "normalize-vs-persist
asymmetry" at the site that causes it.
It is a trap worth knowing about, because the stored value then no longer
matches the normalized finders below: find_by_email("[email protected]") will not find
that row, and a uniqueness assumption built on the canonical form is broken. If
you need updates to store canonical values, the hooked path is the one that
does it.
The hooked update path persists the normalized draft and does not have this
problem. So: if a normalized column is ever written through update, give the
repository hooks (or normalize the value yourself before building the patch)
rather than assuming the attribute covers it. A CITEXT column or a functional
unique index is the durable backstop.
Lookups normalize too
The derived #[repository] finders canonicalize their argument the same way, so
find_by_email(" [email protected] ") matches the stored [email protected] row. You do not
have to remember to normalize at every call site — which is the failure mode
this attribute exists to remove.
Constraints
Stringonly.#[normalize]onOption<String>or any non-Stringfield is a compile error.- At least one normalizer. A bare
#[normalize]or an empty#[normalize()]is a compile error rather than a silent no-op. - Not compatible with
#[translatable]. Normalizers rewrite one string; they cannot see inside a per-locale column set. - It does not rewrite history. Adding
#[normalize]to an existing column canonicalizes future writes only. Backfill existing rows with a migration.
Unstorable bytes: NUL
A Postgres TEXT or VARCHAR column cannot hold a NUL byte (0x00). This is
not a length or a format rule that #[validate(...)] could express — the value
is a perfectly good Rust String, and every validator you can write on it
passes. It fails much later, when the driver hands the byte to the server, which
answers invalid byte sequence for encoding "UTF8": 0x00.
A real user can produce one without trying: a paste from a binary source, a misbehaving input method, a clipboard glitch. So this is a robustness question, not only an adversarial one.
The framework handles it at the form boundary. The two extractors built for
form round-trips — ChangesetForm and NestedChangesetForm — sweep every
submitted text value before it is deserialized, and a field that carried a
NUL gets an ordinary error:
Cannot contain the NUL character (0x00)
The message is available as autumn_web::form::NUL_CHARACTER_FIELD_ERROR.
Nothing in your handler changes — it is a field error like any other, so the
form re-renders inline with your existing errors_for(...) markup and whatever
4xx you already return for a rejected changeset. The retained value is the
author's text with the byte removed, so the re-rendered form keeps their work
(and never echoes a raw 0x00 back into the HTML), and their next submission
succeeds.
Framework plumbing fields are deliberately exempt — the CSRF token, the submit
token, and the _method override, under both their default names and whatever
name you configured. None is a form field any template renders, so an error
keyed under one would leave the form invalid with nothing on screen to explain
why, and none is ever written to a column. Nothing about their own handling
changes either: each is read from the raw body by its middleware, before any
extractor runs, so a NUL-mangled _csrf is still a 403 and a NUL-mangled
_method is still a 400. (A mangled _submit_token is not rejected — it simply
matches no stored token and loses its at-most-once protection, exactly as it
would have before.)
_destroy on a nested child row is exempt for a different reason: it is read
only for truthiness, and cleaning it would change it — 1\0 is falsy today
and would become truthy — so the sweep leaves it alone entirely.
An unknown key is not exempt. A submitted name that is not a field of your
form type is normally ignored, but if it carries a NUL it is still reported —
under that name. Since no template renders that name, the submission is
rejected with no message next to any input; error_summary will show the
message, but without saying which input it belongs to. In practice that takes a
hand-crafted request: the values your own templates emit for non-form fields
are server-generated, not pasted.
File parts of a multipart/form-data body are untouched — binary uploads are
supposed to contain arbitrary bytes.
The paths no form extractor sees
A JSON API body, a hand-written query, a background job argument, an
autumn_web::extract::Form or Valid<T> extraction: these reach the database
without passing one of the two round-trip extractors, so the byte still gets
there. It is classified rather than blanket-500'd — the resulting AutumnError
carries 422 Unprocessable Entity, matching the status a validator would have
produced, and autumn_web::error::is_nul_byte_violation recognizes it if you
want to fold it back into a form:
if let Err(err) = repo.save(&new_post).await
&& is_nul_byte_violation(&err)
{
changeset.add_error("body", NUL_CHARACTER_FIELD_ERROR);
return Ok(render_rejected(changeset));
}
This is a backstop. The form boundary is the main defense. It gives the author a message they can use.
The generated #[repository(api = ...)] create and update handlers name the
field. The 422 has an errors[] entry for each string that holds a NUL byte.
Nested names use dots and brackets: address.street, items[1].sku. The
Postgres does not say which column held the byte. The handler looks for it in
the payload. A payload type without Serialize keeps an empty errors[].
In your own handler, call autumn_web::error::nul_byte_json_fields on the
JSON body.
Postgres rejects a NUL in a JSONB string with a different message
(unsupported Unicode escape sequence, SQLSTATE 22P05). The classifier
matches that message and its detail exactly. It returns the same 422.
Classification uses the server message. If the server translates the message,
the error stays a 500. This is safe.
When you would rather clean than reject
On a column fed by paste-prone input, where dropping the byte silently is better than refusing the write, use the normalizer:
#[model]
pub struct Profile {
#[normalize(strip_nul)]
pub bio: String,
}
Prefer rejecting wherever the author can see and fix the value — they get told
what happened instead of having their input quietly altered. strip_nul removes
only NUL; every other control character is storable and is left exactly as
written.
It covers inserts and hooked updates only. Per Updates are not the same as
inserts, a repository with no hooks, or
one using validate_on_update = fetch, persists the raw patch and throws the
normalized draft away — so an update through either of those still sends the
NUL to Postgres and still fails the write, now as a 422 rather than a 500.
Localize the message
With the i18n feature, the form extractors read the Arc<Bundle> from the
request extensions. They resolve the request locale and look up
common.error.nul_character (autumn_web::form::NUL_CHARACTER_MESSAGE_KEY).
Add the key to each catalog:
common.error.nul_character = No puede contener el carácter NUL (0x00)
If there is no bundle, or the key is missing, the message is
NUL_CHARACTER_FIELD_ERROR in English. A missing key does not record a miss.
autumn generate scaffold --i18n adds the English entry to en.ftl. autumn i18n check
does not report the key as unused.
The API 422 message stays in English. No locale is available there.
CSRF is not optional, and not your job
ChangesetForm captures the CSRF token from the request extensions during
extraction, and form_tag emits the hidden input. In a POST handler that is the
whole story — no parameter, no wiring. It picks up the configured field name
from the extensions too, so a customized security.csrf.form_field is handled
for you.
GET handlers that render a blank form get neither, because there is no request body to have captured them from. The token is the obvious half:
#[get("/todos/new")]
async fn new_todo(csrf: CsrfToken) -> Markup {
let blank = ChangesetForm::blank(TodoForm::default(), csrf.token());
render_form(&blank)
}
That is correct only while you use the default field name. blank is a
plain constructor with no access to configuration, so it hardcodes _csrf,
while CsrfLayer scans an incoming body for the configured name only. Set
security.csrf.form_field to anything else and this form renders happily and
403s on its first submission — a failure that appears at the far end of the
flow, on an app that "just changed a config value".
If your app configures the field name, extract it and pass it through. It costs one parameter and is inert under the default:
// `CsrfFormField` is in the prelude, so this import is only for clarity.
use autumn_web::security::CsrfFormField;
#[get("/todos/new")]
async fn new_todo(csrf: CsrfToken, csrf_field: CsrfFormField) -> Markup {
let blank = ChangesetForm::blank(TodoForm::default(), csrf.token())
.with_csrf_field(csrf_field.0);
render_form(&blank)
}
NestedChangesetForm::blank has the same constraint and the same
with_csrf_field remedy. So does any hand-written <input type="hidden">: name
it from CsrfFormField, not from the literal _csrf.
ChangesetForm::without_csrf exists for tests and for apps that have disabled
the CSRF layer. If CSRF is enabled and you use it in a real handler, the form
will render and every submission will be rejected.
Inline validation with htmx, and the no-JavaScript path
text_input_htmx renders a field that re-validates itself as the user leaves
it. The field POSTs to a validation endpoint and htmx swaps the returned wrapper
in place with outerHTML:
fn title_field(form: &ChangesetForm<TodoForm>) -> Markup {
form.text_input_htmx("title", "Title", "/todos/validate/title")
}
#[post("/todos/validate/title")]
async fn validate_title(form: ChangesetForm<TodoForm>) -> Markup {
title_field(&form) // the same partial, now carrying errors
}
The endpoint is three lines because it re-renders the same partial the full page render uses. That is the property to preserve when you hand-roll a field wrapper: one function, called from both places.
The no-JavaScript fallback is automatic, and it is not a second code path.
With htmx absent, the hx-* attributes are inert, the browser performs an
ordinary full-form POST, and the #[post("/todos")] handler returns 422 with
the same wrapper rendered inline. The user sees the same errors in the same
places. Nothing extra is written to make that work — it works because the
failure branch of the main handler re-renders the form, which is the pattern
this whole guide is built on.
Two rules keep it that way:
- Never make the validation endpoint the only way to see an error. The full POST handler must render errors too.
- Keep the CSRF token in the form. With JavaScript on, the framework's
autumn-htmx-csrf.jsshim can send the token as a header; with JavaScript off, the hidden_csrfinput is the only thing that gets a plain form POST through.form_tagemits it, so this costs nothing — but a hand-rolled<form>that omits it breaks for exactly the visitors least able to work around it.
examples/reddit-clone builds its submit and edit forms this way. Neither
carries an hx-* attribute, so the no-JavaScript path is the only path there,
and unit tests in src/routes/posts.rs assert it: no htmx attributes, a
method="post" form, and the hidden _csrf input that gets a plain POST past
the CSRF layer.
Accessible fields by construction
The form helpers emit labels and error wiring for you. When you build a field by
hand, use the typed primitives in autumn_web::a11y rather than raw markup:
TextField, TextArea, Select, Checkbox, FileField and RadioGroup
do not implement Render until a label is attached, so an unlabeled field
is not merely discouraged — it does not compile.
use autumn_web::a11y::TextField;
TextField::new("title")
.label("Title") // required before it can render
.value(form.field_value("title").unwrap_or_default())
.required() // emits the HTML5 constraint
.aria_invalid(!form.errors_for("title").is_empty())
.described_by("title-error") // ties the field to its message
Pair described_by with an element carrying that id and role="alert", and a
screen-reader user hears the error when focus lands on the field. The
accessibility guide covers the full set, and
autumn a11y verify fails a build that regresses.
Multi-value fields
A checkbox group, a <select multiple> and a tag input send one key more than
once: tags=rust&tags=web. Give the field a sequence type. Each value becomes
one element:
#[derive(Deserialize, Serialize, Validate)]
struct PostForm {
title: String,
#[serde(default)] // no box checked → empty list
tags: Vec<String>,
#[serde(default)]
reviewer_ids: HashSet<i64>,
labels: Option<Vec<String>>, // absent → None
}
ChangesetForm<T>, NestedChangesetForm and the multipart path all use these
rules. So does autumn_web::form::from_urlencoded, for a handler that reads
Bytes:
| Body | Field | Result |
|---|---|---|
tags=a&tags=b | Vec<String> | ["a", "b"] |
tags=a | Vec<String> | ["a"] |
(no tags key) | #[serde(default)] Vec<String> | [] |
(no labels key) | Option<Vec<String>> | None |
q=a%20b&q=c+d | Vec<String> | ["a b", "c d"] |
ids=1&ids=&ids=3 | Vec<i64> | [1, 3] — a blank element of a typed list is dropped |
q=&q=x | Vec<String> | ["", "x"] — a blank string stays |
ids=1&ids=x | Vec<i64> | 400 |
name=a&name=b | String | 422 |
A repeated key on a single-value field is a 422. The decoder does not
select the first value or the last value. Two layers can select different
values (a proxy log and the handler, for example). That causes
parameter-pollution bugs. The message names the field, so a field that must be
a Vec is easy to find. Single-value fields that arrive once decode as before.
Notes:
- Brackets are part of the key.
ids[]=1does not fillids. Use#[serde(alias = "ids[]")]to accept both spellings. Do not send both in one body. boolalso acceptson, the value a checkbox with novalueattribute sends.- A blank numeric or
boolOption(page=) isNone. A#[serde(default = "...")]on that field does not apply to a blank value. - A fixed-size array (
[i64; 2]) and a newtype around aVectake one value. Use a plainVec. Changeset::field_values("tags")returns every element as a string, to re-render the checked boxes or the selected options. In a nested form row,row.values("tags")returns every submitted value of that subfield.row.value("tags")returns only the last one.field_from_urlencodedreads one raw value for a preview. It is lenient and returns the first value.Form<T>still usesserde_urlencoded. It rejects a repeated key on a struct field.
Multi-row and multi-step forms
- Repeated rows — a
has_manyedited inline, with add/remove — are their own binding problem. See nested forms. - Multi-step flows that must survive a refresh belong in a wizard.
- At-most-once submission (payments, anything with an external side effect) needs a submit token, not a disabled button.
Uploads
Multipart applies the app's security.upload policy — MIME allowlist and
per-file size caps — before your handler sees a byte. See
storage for where the bytes then go.
ChangesetForm keeps file parts, so one form can take a file upload and
its text fields together. On a multipart/form-data body (the
multipart feature), the text fields decode into T as usual. Each file part
goes to the form, not to T:
#[post("/profile")]
async fn update(mut form: ChangesetForm<ProfileForm>) -> impl IntoResponse {
if form.file("avatar").is_none() {
form.changeset.add_error("avatar", "Choose a picture");
}
let files = form.take_files(); // take them before `into_valid` consumes the form
match form.into_valid() {
Ok(profile) => { /* store `files`, save `profile` */ }
Err(form) => (StatusCode::UNPROCESSABLE_ENTITY, render(&form)).into_response(),
}
}
| Method | Returns |
|---|---|
form.file("avatar") | the first file part named avatar |
form.files("photos") | every file part named photos (<input type="file" multiple>) |
form.take_files() | all file parts, moved out of the form |
Each FormFile has field(), file_name(), content_type() (from the
client), sniffed_content_type() (from the bytes, when a MIME policy is set)
and bytes().
Rules:
- The
security.uploadpolicy applies: the MIME allowlist (400), the per-file cap (413), and the request cap. The per-file cap also applies to each text part. A route that sets a smallerDefaultBodyLimitkeeps it. - The form holds each file in memory. The per-file cap and the request cap
limit that memory. For a file larger than you want in memory, use
Multipartand stream it to storage on its own route. - An empty optional file input (no file chosen) is not a file.
file(..)returnsNone. - A browser cannot fill a file input again. When you re-render after an error, the user must choose the file again.
Testing forms
TestResponse has HTML assertions, so a form test reads like the form:
let res = app.post("/todos").form("title=").send().await;
res.assert_status(422)
.assert_selector("input[name=title][aria-invalid=true]")
.assert_text_contains("[role=alert]", "Title must be 1–255 characters");
Test the failure path first. The success path is one insert; the failure path is the one with the round-trip, the ARIA wiring, and the CSRF token in it.
Building a ChangesetForm by hand in a unit test has one trap:
ChangesetForm::without_csrf and ChangesetForm::blank wrap the data in a
fresh, error-free changeset — they never call validate. Only
IntoChangeset::into_changeset runs the rules, so a test that wants to exercise
them has to go through it:
// Renders, but proves nothing about the rules — `into_valid()` always succeeds.
let form = ChangesetForm::without_csrf(SubmitPostForm { .. });
// Runs the rules.
let form = ChangesetForm::from_changeset(SubmitPostForm { .. }.into_changeset());
For normalization, assert on what was stored, not on what was accepted:
// " [email protected] " — `+` is a space in a form-urlencoded body.
app.post("/users").form("email=++Alice%40Example.COM+").send().await;
let user = users.find_by_email("[email protected]").await?.unwrap();
assert_eq!(user.email, "[email protected]");
See the testing guide.
See also
- Extractors — where
ChangesetFormandValidsit among the rest - Nested forms —
has_manyrows in one submission - Accessibility — the typed primitives, and
autumn a11y verify - Rich text — accepting formatted text safely
- Generators — what
autumn generate scaffoldemits, and its no-JS fallbacks - Repositories — where model validation and normalization run
- Submit tokens — at-most-once form submission
- Wizards — multi-step flows