Practical Examples

CRUD with forms

Registration form

Manual form (without model)

// src/forms.rs
use runique::prelude::*;

pub struct RegisterForm {
    pub form: Forms,
}

#[async_trait]
impl RuniqueForm for RegisterForm {
    impl_form_access!();

    fn register_fields(form: &mut Forms) {
        form.field(
            &TextField::text("username")
                .label("Username")
                .required()
                .min_length(3, "Minimum 3 characters")
                .max_length(50, "Maximum 50 characters")
        );
        form.field(
            &TextField::email("email")
                .label("Email")
                .required()
        );
        form.field(
            &TextField::password("password")
                .label("Password")
                .required()
                .min_length(8, "Minimum 8 characters")
        );
    }

    // Business validation — called automatically by is_valid()
    async fn clean(&mut self) -> Result<(), StrMap> {
        let mut errors = StrMap::new();
        if !self.cleaned_string("email").unwrap_or_default().contains('@') {
            errors.insert("email".to_string(), "Invalid email".to_string());
        }
        if errors.is_empty() { Ok(()) } else { Err(errors) }
    }
}

Model-based form

#[form(...)] generates the struct and impl ModelForm. The developer writes impl RuniqueForm with impl_form_access!(model):

use runique::prelude::*;

#[form(schema = users_schema, fields = [username, email, password])]
pub struct RegisterForm;

#[async_trait]
impl RuniqueForm for RegisterForm {
    impl_form_access!(model);

    async fn clean(&mut self) -> Result<(), StrMap> {
        let mut errors = StrMap::new();
        if self.cleaned_string("username").unwrap_or_default().len() < 3 {
            errors.insert("username".to_string(), "Minimum 3 characters".to_string());
        }
        if !self.cleaned_string("email").unwrap_or_default().contains('@') {
            errors.insert("email".to_string(), "Invalid email".to_string());
        }
        if self.cleaned_string("password").unwrap_or_default().len() < 10 {
            errors.insert("password".to_string(), "Minimum 10 characters".to_string());
        }
        if errors.is_empty() { Ok(()) } else { Err(errors) }
    }
}

#[async_trait] is required only when overriding clean or clean_field. Without async override, impl RuniqueForm { impl_form_access!(model); } is enough.


Registration handler

pub async fn signup(mut request: Request) -> AppResult<Response> {
    let form: RegisterForm = request.form();
    let template = "signup_form.html";

    let mut validated = match ValidationForm::try_new(form, &request).await {
        Ok(validated) => validated,
        Err(form) => {
            context_update!(request => {
                "title" => "Validation Error",
                "signup_form" => &form,
                "messages" => flash_now!(error => "Please fix the errors"),
            });
            return request.render(template);
        }
    };

    let user = validated.save(&request.engine.db).await.map_err(|err| {
        validated.database_error(&err);
        AppError::from(err)
    })?;

    success!(request.notices => format!("Welcome {}!", user.username));
    Ok(Redirect::to("/").into_response())
}

Registration template

{% extends "base.html" %}

{% block content %}
    <h1>{{ title }}</h1>
    {% messages %}

    <form method="post" action='{% link "signup" %}'>
        {% form.signup_form %}
        <button type="submit">Sign up</button>
    </form>
{% endblock %}

Search and display an entity

Search form

pub struct UsernameForm {
    pub form: Forms,
}

impl RuniqueForm for UsernameForm {
    fn register_fields(form: &mut Forms) {
        form.field(
            &TextField::text("username")
                .label("Username")
                .required()
                .placeholder("Search a user")
        );
    }
    impl_form_access!();

    // This form only serves the GET search — never validate on POST.
    fn allow_post(&self, _request: &Request) -> bool {
        false
    }

    // Explicit opt-in: a read-only search form is the one case that should
    // auto-validate on GET (`allow_get` defaults to `false`).
    fn allow_get(&self, _request: &Request) -> bool {
        self.is_submitted()
    }
}

Search handler

pub async fn info_user(mut request: Request) -> AppResult<Response> {
    let form: UsernameForm = request.form();
    let template = "profile/view_user.html";

    match ValidationForm::try_new(form, &request).await {
        Ok(validated) => {
            let form = validated.into_form();
            let username = form.cleaned_string("username").unwrap_or_default();
            let db = request.engine.db.clone();

            let user_opt = UserEntity::find()
                .filter(user::Column::Username.eq(&username))
                .one(&*db)
                .await
                .unwrap_or(None);

            match user_opt {
                Some(user) => {
                    context_update!(request => {
                        "title" => "User view",
                        "found_user" => &user,  // ⚠️ DO NOT name it "user" → collision with the form
                        "user" => &form,
                        "messages" => flash_now!(success => "User found!"),
                    });
                }
                None => {
                    context_update!(request => {
                        "title" => "User view",
                        "user" => &form,
                        "messages" => flash_now!(warning => "User not found"),
                    });
                }
            }

            request.render(template)
        }
        Err(form) => {
            context_update!(request => { "title" => "Search a user", "user" => &form });
            request.render(template)
        }
    }
}