Tutorial 07: Forms & DTO Validation 📝
Rullst provides ValidatedForm<T> and ValidatedJson<T> extractors. They parse
the request and run validator constraints before the handler is called.
Invalid payloads become bounded 400, 413, 415 or 422 JSON responses.
HTMX requests (HX-Request: true) instead receive an HTML error fragment with
200 OK, because HTMX 1.x and 2.x swap only successful responses by default;
the X-Rullst-Validation-Status header carries the REST status. htmx
swaps the fragment into the form’s hx-target with its hx-swap, so point
hx-target at an error or result container rather than at the form itself.
A body that cannot be parsed gets a fixed message: 413 “Request body is too
large.” for a body over the extractor limit, 415 “Unsupported request content
type.” for a wrong content type, and 400 “The submitted data could not be
read or has an invalid format.” for a malformed body or an unknown enum
variant (422 stays reserved for failed validator constraints). The
deserializer’s own text can echo request input, so it is logged at debug
level on the rullst::validation target and not returned. Every message and
field name in the HTMX fragment is HTML-escaped, including custom validator
messages. Errors inside #[validate(nested)] structs and lists are reported
under dotted and indexed paths such as address.zip and items[0].name.
ValidationError’s Display and Debug list only those paths and validator
codes, never the rejected values, so logging the error cannot leak a password.
Step 1: Define a validated DTO
#![allow(unused)]
fn main() {
use serde::Deserialize;
use rullst::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct CreateUserForm {
#[validate(length(min = 3, max = 100))]
pub name: String,
#[validate(email)]
pub email: String,
#[validate(length(min = 12, max = 72))]
pub password: String,
}
}
#[derive(Validate)] expands to ::validator::… paths, so the application
needs a direct validator dependency. Projects generated by the v13 CLI
already declare it; in an older project add the version Rullst uses:
validator = { version = "0.21", features = ["derive"] }
length counts characters, while password hashing refuses more than 72
bytes: a 72-character password with accented or non-Latin characters passes
this validator and then fails to hash. Check form.password.len() <= 72 (bytes)
in the handler as well, as the controller generated by cargo rullst auth does.
Add a request-body limit at the router/proxy boundary as validation happens after
body extraction.
Step 2: Validate and hash before persistence
The following handler assumes the User model (src/models/user.rs) that
cargo rullst auth generates in Tutorial 11:
use rullst::{html, server::StatusCode, Markup, ValidatedForm};
use rullst::auth::hash_password_async;
use rullst::db::RowSnapshot;
use rullst::orm::chrono::Utc;
pub async fn store(
ValidatedForm(form): ValidatedForm<CreateUserForm>,
) -> Result<Markup, StatusCode> {
// `length` counted characters; hashing refuses more than 72 bytes.
if form.password.len() > 72 {
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}
let password_hash = hash_password_async(form.password)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// The ORM inserts every field, so the model carries the `timestamps()`
// values instead of relying on the column defaults.
let now = Utc::now();
let mut user = crate::models::user::User {
id: 0,
name: form.name.trim().to_owned(),
email: form.email.trim().to_ascii_lowercase(),
password_hash: Some(password_hash),
oauth_provider: None,
oauth_id: None,
created_at: now,
updated_at: now,
// `#[orm(db_default)]` columns: inserts leave them to the database.
is_admin: false,
session_epoch: 0,
snapshot: RowSnapshot::default(),
};
user.save()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(html! {
<div class="p-4 bg-emerald-900/50 text-emerald-300 rounded border border-emerald-500">
<p>"User created successfully"</p>
</div>
})
}
This is a continuation snippet because the generated model lives in the
application crate. The controller generated by cargo rullst auth sets the
timestamps the same way with its utc_timestamp helper and also rejects a
duplicate email before inserting. The checked extractor implementation itself
is covered by rullst-core tests.
For a JSON endpoint, replace ValidatedForm with ValidatedJson; the DTO and
handler body remain the same.
Step 3: Send the CSRF token with browser forms
Production/staging Server requests use the security baseline, including
double-submit CSRF validation. A POST needs both the rullst_csrf cookie and
its matching value in a form _token field or X-CSRF-Token header.
Extract the request-scoped token on the page’s GET handler. On a visitor’s first request, this is the same token that the middleware will set in the response cookie; reading the incoming cookie alone would miss it.
#![allow(unused)]
fn main() {
use rullst::{html, security::CsrfToken, server::Extension, Markup};
pub async fn message_form(
Extension(csrf): Extension<CsrfToken>,
) -> Markup {
html! {
<form id="message-form" method="post" action="/messages"
hx-post="/messages" hx-target="#message-result" hx-swap="innerHTML">
<input type="hidden" name="_token" value={csrf.as_str()} />
<label for="message">"Message"</label>
<input id="message" name="message" required maxlength="600" />
<button type="submit">"Send"</button>
</form>
<div id="message-result" role="status" aria-live="polite"></div>
}
}
}
Mount GET and POST handlers in the protected application and serve the pinned
HTMX asset from the same origin. This is a form-rendering snippet, not a full
messaging service: /messages still needs a bounded validated DTO, application
authorization and the intended domain operation. The hidden field works with
both a normal URL-encoded form submission and HTMX; no global cookie-forwarding
script is required. JSON requests instead need the matching header, while the
browser still sends the cookie. Do not forward tokens to third-party URLs.
Serve form pages from ordinary paths. A GET for a path that looks like a
static file never creates the token or sets the cookie: /robots.txt,
/sitemap.xml, /favicon.ico, anything under /static/, and any path ending
in .txt, .xml, .ico, .json, .css, .js, .png, .jpg, .jpeg,
.svg, .webp or .wasm. Since 13.0.0-alpha.2 such a page still receives
CsrfToken when the browser already has a valid rullst_csrf cookie from
another page, but on a first visit there is none, and the
Extension<CsrfToken> extractor fails with 500.
The development server does not automatically apply the production CSRF/WAF
stack; its WAF only logs the requests production would refuse. For this
extractor in development, explicitly compose
rullst::server::from_fn(rullst::security::csrf_middleware) on the application
router, as the generated form-enabled starters do. Nested CSRF composition with
the production server is request-idempotent. Do not make the extractor optional
and silently render an empty token. Exercise the production baseline over HTTPS
before deployment; do not remove Secure to accommodate a production HTTP URL.
Show failures instead of a silent spinner
For the pinned HTMX 1.9.12 used by these examples, ordinary HTTP errors do not
automatically replace the target content (ValidatedForm failures are the
exception described above). Put the following in an
application-owned, same-origin external script loaded with defer. This avoids
requiring inline event handlers or weakening Content Security Policy:
const form = document.getElementById('message-form');
const status = document.getElementById('message-result');
if (form && status) {
const showFailure = (event) => {
if (event.detail.elt !== form) return;
status.textContent = 'Message not sent. Reload the page and try again.';
};
for (const name of ['htmx:responseError', 'htmx:sendError', 'htmx:timeout']) {
form.addEventListener(name, showFailure);
}
form.addEventListener('htmx:afterRequest', (event) => {
// Validation fragments are swapped with 200 and this header.
const rejected = event.detail.xhr.getResponseHeader('X-Rullst-Validation-Status');
if (event.detail.elt === form && event.detail.successful && !rejected) {
const input = form.elements.namedItem('message');
if (input instanceof HTMLInputElement) input.value = '';
}
});
}
Keep the message on failure so the visitor can retry. Never insert an arbitrary
server error body using innerHTML. Resetting only after success is also clearer
than relying on event-order assumptions. In HTMX 1.9.12, form values have already
been collected before htmx:beforeRequest; clearing an input there alone does
not establish that an empty message was transmitted.
Diagnose a 403 before changing security policy
| Response | Inspect |
|---|---|
CSRF token cookie missing | The POST’s Cookie header: the cookie may be absent, duplicated or malformed. Check that the preceding GET set it, that the browser accepted it, and that scheme/host/path and credential settings match. A hidden field cannot replace the cookie. |
Invalid or missing CSRF token | The form’s _token value and URL-encoded content type. Confirm the input belongs to the submitted form and matches the current cookie. |
Invalid CSRF token | The explicit header, which takes precedence over the form. Remove a stale custom header rather than expecting the valid form field to override it. |
Missing CSRF token: a multipart/form-data form must send its _token field before any file field … | A file-upload form whose hidden _token input comes after a file input (or after 64 KiB of other fields). Move _token to the top of the form or send X-CSRF-Token; see form uploads. |
Do not log token values or session cookies. HX-Request: true is not a security
exemption. Public browser chat endpoints retain CSRF and need separate abuse,
rate and cost limits. The existing csrf_signed_webhook_paths configuration is
only for exact POST paths authenticated by mandatory signed-webhook validation;
it is not an AI-chat allowlist and does not verify signatures by itself.
Core’s htmx_csrf_contract regression tests exercise first-visit cookie/form
agreement, ordinary and HTMX submissions, preservation of form data and
rejection before handler execution. These in-process HTTP tests do not certify
a reverse proxy, browser cookie policy, a live AI provider or application auth.
Key takeaways
- Validation is not sanitization and not authorization; enforce all three at their respective boundaries.
- Never store, log, or echo a plaintext password. Use the asynchronous Argon2id helper in request handlers.
- Enforce a unique database index on normalized email rather than relying only on a pre-insert lookup.
- Keep internal validation/database details out of production client errors.