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, 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

ResponseInspect
CSRF token cookie missingThe 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 tokenThe form’s _token value and URL-encoded content type. Confirm the input belongs to the submitted form and matches the current cookie.
Invalid CSRF tokenThe 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.