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 or 422 responses; HTMX requests receive
an HTML error fragment and other clients receive JSON.
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,
}
}
The 72-byte upper bound matches the current password hashing contract. 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 generated by cargo rullst auth:
use axum::{http::StatusCode, response::Html};
use rullst::{html, ValidatedForm};
use rullst_auth::hash_password_async;
pub async fn store(
ValidatedForm(form): ValidatedForm<CreateUserForm>,
) -> Result<Html<String>, StatusCode> {
let password_hash = hash_password_async(form.password)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut user = crate::models::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: String::new(),
updated_at: String::new(),
};
user.save()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Html(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 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.
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.