modo-macros 0.3.0

Procedural macros for the modo web framework
Documentation

modo-macros

Procedural macros for the modo web framework. Provides attribute macros for route registration and application bootstrap, plus derive macros for input validation and sanitization.

All macros are re-exported from modo — import them as modo::handler, modo::main, etc. Do not depend on modo-macros directly in application code.

Features

Feature What it enables
static-embed #[main(static_assets = "...")] static file embedding via rust-embed

Template and i18n macros (#[view], #[template_function], #[template_filter], t!) are only active when the corresponding templates or i18n feature is enabled on the modo crate.

Usage

Application entry point

#[modo::main]
async fn main(
    app: modo::app::AppBuilder,
    config: modo::AppConfig,
) -> Result<(), Box<dyn std::error::Error>> {
    app.config(config).run().await
}

The function must be named main, be async, and accept exactly two parameters: an AppBuilder and a config type that implements serde::de::DeserializeOwned + Default. The macro replaces the function with a sync fn main() that bootstraps a multi-threaded Tokio runtime, configures tracing_subscriber (using RUST_LOG or falling back to "info,sqlx::query=warn"), loads config via modo::config::load_or_default, and exits with code 1 on error.

The return type annotation on the async fn main is not enforced by the macro; write it for readability but the body is wrapped internally.

Embedding static files

#[modo::main(static_assets = "static/")]
async fn main(
    app: modo::app::AppBuilder,
    config: modo::AppConfig,
) -> Result<(), Box<dyn std::error::Error>> {
    app.config(config).run().await
}

Requires the static-embed feature on modo-macros.

HTTP handlers

#[modo::handler(GET, "/todos")]
async fn list_todos() -> modo::JsonResult<Vec<Todo>> {
    Ok(modo::Json(vec![]))
}

#[modo::handler(DELETE, "/todos/{id}")]
async fn delete_todo(id: String) -> modo::JsonResult<serde_json::Value> {
    // `id` is extracted from the path automatically
    Ok(modo::Json(serde_json::json!({"deleted": id})))
}

Supported methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.

Path parameters written as {name} are extracted automatically. Declare a function parameter with the matching name and the macro injects axum::extract::Path extraction. Undeclared path params are captured but ignored (partial extraction).

Handler-level middleware

#[modo::handler(GET, "/admin")]
#[middleware(require_auth)]
async fn admin_page() -> &'static str {
    "secret"
}

// Factory middleware (called with arguments)
#[modo::handler(GET, "/dashboard")]
#[middleware(require_role("admin"))]
async fn dashboard() -> &'static str {
    "dashboard"
}

Bare middleware paths are wrapped with axum::middleware::from_fn. Paths followed by (args) are called as layer factories. Multiple middleware entries are applied in the order listed.

Route modules

#[modo::module(prefix = "/api/v1")]
mod api {
    #[modo::handler(GET, "/users")]
    async fn list_users() -> &'static str { "users" }
}

// With module-level middleware
#[modo::module(prefix = "/admin", middleware = [require_auth])]
mod admin {
    #[modo::handler(GET, "/dashboard")]
    async fn dashboard() -> &'static str { "admin" }
}

All #[handler] attributes inside the module are automatically associated with the module's prefix and middleware at compile time via inventory.

Bare mod foo; declarations inside the module body are allowed. Inline nested mod foo { ... } blocks are not supported and produce a compile error, because their handlers would not receive the outer prefix.

Custom error handler

#[modo::error_handler]
fn my_error_handler(
    err: modo::Error,
    ctx: &modo::ErrorContext,
) -> axum::response::Response {
    if ctx.accepts_html() {
        // render an HTML error page
    }
    err.default_response()
}

The function must be sync and accept exactly (modo::Error, &modo::ErrorContext). It is registered via inventory and invoked for every unhandled modo::Error. Only one error handler may be registered per binary.

Input sanitization

#[derive(serde::Deserialize, modo::Sanitize)]
struct SignupForm {
    #[clean(trim, normalize_email)]
    email: String,

    #[clean(trim, strip_html_tags, truncate = 500)]
    bio: String,
}

Available #[clean(...)] rules: trim, lowercase, uppercase, strip_html_tags, collapse_whitespace, truncate = N, normalize_email, custom = "path::to::fn".

Sanitization runs automatically inside JsonReq and FormReq extractors. Generic structs are not supported.

Input validation

#[derive(serde::Deserialize, modo::Validate)]
struct CreateTodo {
    #[validate(
        required(message = "title is required"),
        min_length = 3,
        max_length = 500
    )]
    title: String,

    #[validate(min = 0, max = 100)]
    priority: u8,
}

// In a handler:
use modo::extractor::JsonReq;
async fn create(input: JsonReq<CreateTodo>) -> modo::JsonResult<()> {
    input.validate()?;
    Ok(modo::Json(()))
}

Available #[validate(...)] rules: required, min_length = N, max_length = N, email, min = V, max = V, custom = "path::to::fn". Each rule accepts an optional (message = "...") override. A field-level message = "..." key is used as a fallback for all rules on that field.

Templates (requires templates feature on modo)

#[modo::view("pages/home.html")]
struct HomePage {
    title: String,
}

// With a separate HTMX partial
#[modo::view("pages/home.html", htmx = "partials/home.html")]
struct HomePageHtmx {
    title: String,
}

#[modo::template_function]
fn greeting(hour: u32) -> String {
    if hour < 12 { "Good morning".into() } else { "Hello".into() }
}

#[modo::template_filter(name = "shout")]
fn shout_filter(s: String) -> String {
    s.to_uppercase()
}

Localisation (requires i18n feature on modo)

// In a handler with an I18n extractor:
let msg = modo::t!(i18n, "welcome.message", name = username);
let items = modo::t!(i18n, "cart.items", count = cart_count);

t! calls .t_plural on the i18n context when a count variable is present, selecting the correct plural form.

Key Macros

Macro Kind Purpose
#[handler] attribute Register an async fn as an HTTP route
#[main] attribute Application entry point and runtime bootstrap
#[module] attribute Group routes under a shared URL prefix
#[error_handler] attribute Register a custom error handler
Sanitize derive Generate Sanitize::sanitize from #[clean] fields
Validate derive Generate Validate::validate from #[validate] fields
t! function-like Localisation key lookup with variable substitution
#[view] attribute Link a struct to a MiniJinja template
#[template_function] attribute Register a MiniJinja global function
#[template_filter] attribute Register a MiniJinja filter