Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.