Ferro Framework

Ferro is a Rust web framework optimized for AI-assisted authoring. It is built for developers whose primary authoring tool is a coding agent (Claude Code, Cursor, and similar), and exposes every subsystem through an in-process MCP (Model Context Protocol) server. An agent connected to your project reads routes, models, handlers, validations, and generation context as tool calls — not by parsing source.

The defining feature is service projections: declare a service and intent, get a working UI. The ferro-projections crate (shipped in v9.0) maps typed model pipelines to rendered views, so an agent can scaffold an end-to-end CRUD or workflow surface from a single declaration. At v1.0 the output is visual (HTML via JSON-UI, shipped in v10.0); the underlying model is designed to support additional rendering modalities over time.

There is no bundled agent UI. ferro-mcp is the introspection layer your agent talks to.

What's included

  • Service projections — typed model→UI pipelines via ferro-projections
  • JSON-UI — server-rendered, server-driven UIs with 30+ components and a plugin system
  • MCP introspection — a full suite of tools via ferro-mcp for agent-assisted development
  • Routing and middleware — macro-based routes, typed extractors, middleware pipeline
  • Database — SeaORM-based models, migrations, and query builder
  • Validation — declarative rule sets with structured errors
  • Authentication — session-based auth with guards and policies
  • Inertia.js — full-stack React/TypeScript with compile-time component validation
  • Events, queues, notifications, broadcasting — async workflows out of the box
  • Storage and caching — pluggable backends (local/S3, in-memory/Redis)
  • Localization, theming, Stripe, AI, WhatsApp — first-party crates for common needs
  • Deploy and CI scaffoldingferro do:init, ferro ci:init, ferro doctor

Quick Example

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Router, AuthMiddleware, Inertia};

#[handler]
pub async fn index(req: Request) -> Response {
    let users = User::find().all(&db).await?;

    Inertia::render(&req, "Users/Index", UsersProps { users })
}

pub fn routes() -> Router {
    Router::new()
        .get("/users", index)
        .middleware(AuthMiddleware)
}
}

Agents can generate this automatically. Connect ferro-mcp to your AI agent and use code_templates to scaffold handlers, list_routes to explore your API, and get_handler to read implementation details of any existing handler.

Philosophy

Agent-first authoring — Ferro is designed for the case where a human directs an AI agent and the agent writes the code. Every subsystem exposes typed introspection so the agent can reason about the application without guessing.

Projection over hand-assembly — UIs are derived from typed services and intents, not hand-wired component trees. The framework prefers declarations the agent can generate and refine.

Convention over configuration — Predictable file layout and naming so introspection produces useful answers and generated code lands in the right place.

Type safety — Routes, Inertia component paths, JSON-UI views, and database queries are validated at compile time. Mistakes surface as build errors the agent can read and fix.

Async-first — Built on Tokio.

Status

Ferro is pre-1.0. Breaking changes are allowed between minor versions until 1.0.

Getting Started

To start building, see the Installation guide. To wire an AI agent to your project via ferro-mcp, see Working with Agents.

Installation

Requirements

The Ferro CLI installs toolchain-free via Homebrew (below). The following are only needed to build and run a scaffolded app:

  • Rust 1.88+ (with Cargo) — to build the app (no OpenSSL needed; the scaffold uses rustls)
  • Node.js 18+ (for the frontend dev server)
  • PostgreSQL, SQLite, or MySQL — SQLite is the default, no setup required

Installing the CLI

No Rust toolchain required:

brew install albertogferrario/ferro/ferro

curl installer (macOS and Linux)

curl -fsSL https://raw.githubusercontent.com/albertogferrario/ferro/main/scripts/install.sh | sh

Cargo (requires Rust)

cargo install ferro-cli

Or build from source:

git clone https://github.com/albertogferrario/ferro.git
cd ferro
cargo install --path ferro-cli

Creating a New Project

ferro new my-app

This will:

  1. Create a new directory my-app
  2. Initialize a Rust workspace
  3. Set up the frontend with React and TypeScript
  4. Configure the database
  5. Initialize git repository

Options

# Skip interactive prompts
ferro new my-app --no-interaction

# Skip git initialization
ferro new my-app --no-git

Starting Development

cd my-app
ferro serve

This starts both the backend (port 8080) and frontend (port 5173) servers.

Server Options

# Custom ports
ferro serve --port 3000 --frontend-port 3001

# Backend only
ferro serve --backend-only

# Frontend only
ferro serve --frontend-only

# Skip TypeScript generation
ferro serve --skip-types

AI Development Setup

For AI-assisted development with Claude, Cursor, or VS Code:

ferro boost:install

This configures the MCP server and adds project guidelines for your editor.

Next Steps

Quick Start

Build a simple user listing feature in 5 minutes.

1. Create Migration

ferro make:migration create_users_table

Edit src/migrations/m_YYYYMMDD_create_users_table.rs:

#![allow(unused)]
fn main() {
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Users::Table)
                    .col(ColumnDef::new(Users::Id).big_integer().primary_key().auto_increment())
                    .col(ColumnDef::new(Users::Name).string().not_null())
                    .col(ColumnDef::new(Users::Email).string().not_null().unique_key())
                    .col(ColumnDef::new(Users::CreatedAt).timestamp().not_null())
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager.drop_table(Table::drop().table(Users::Table).to_owned()).await
    }
}

#[derive(Iden)]
enum Users {
    Table,
    Id,
    Name,
    Email,
    CreatedAt,
}
}

Run the migration:

ferro db:migrate

2. Sync Database to Models

ferro db:sync

This generates src/models/users.rs with SeaORM entity definitions.

3. Create Controller

ferro make:controller users

Edit src/controllers/users_controller.rs:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Inertia, InertiaProps};
use crate::models::users::Entity as User;

#[handler]
pub async fn index(req: Request) -> Response {
    let db = req.db();
    let users = User::find().all(db).await?;

    Inertia::render(&req, "Users/Index", UsersIndexProps { users })
}

#[derive(InertiaProps)]
pub struct UsersIndexProps {
    pub users: Vec<crate::models::users::Model>,
}
}

4. Create Inertia Page

ferro make:inertia Users/Index

Edit frontend/src/pages/Users/Index.tsx:

import { InertiaProps } from '@/types/inertia-props';

interface User {
  id: number;
  name: string;
  email: string;
}

interface Props {
  users: User[];
}

export default function UsersIndex({ users }: Props) {
  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Users</h1>
      <ul className="space-y-2">
        {users.map((user) => (
          <li key={user.id} className="p-2 border rounded">
            <strong>{user.name}</strong> - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
}

5. Add Route

Edit src/routes.rs:

#![allow(unused)]
fn main() {
use ferro::Router;
use crate::controllers::users_controller;

pub fn routes() -> Router {
    Router::new()
        .get("/users", users_controller::index)
}
}

6. Generate TypeScript Types

ferro generate-types

This creates frontend/src/types/inertia-props.ts from your Rust props.

7. Run the Server

ferro serve

Visit http://localhost:5173/users to see your user listing.

What's Next?

Working with Agents

Ferro is built agent-first: agents get the same structural understanding of your application that developers do, via MCP (Model Context Protocol). Routes, models, handlers, validations, services, and code generation hints are all accessible as tool calls — no file-reading required.

Setting Up ferro-mcp

Ferro's CLI binary doubles as an MCP server. The same ferro binary you use for migrations and scaffolding exposes a full suite of introspection tools when invoked with the mcp subcommand. There is no separate binary to install — build your project and the MCP server is ready.

cargo build
# ferro binary is now at target/debug/ferro (development) or target/release/ferro (production)

MCP Configuration

Claude Desktop

Add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):

{
  "mcpServers": {
    "ferro": {
      "command": "/path/to/your/app/target/debug/ferro",
      "args": ["mcp"],
      "type": "stdio"
    }
  }
}

Restart Claude Desktop after saving. Use target/release/ferro for production builds.

Claude Code (.claude.json)

Place at project root as .claude.json (project-scope, applies to this repo only) or ~/.claude.json (user-scope, applies to all projects):

{
  "mcpServers": {
    "ferro": {
      "command": "/path/to/your/app/target/debug/ferro",
      "args": ["mcp"],
      "type": "stdio"
    }
  }
}

Generic stdio (any MCP-compatible host)

Any MCP host that supports stdio transport (Cursor, Windsurf, etc.) uses this pattern:

{
  "mcpServers": {
    "ferro": {
      "command": "/path/to/ferro",
      "args": ["mcp"]
    }
  }
}

The type: "stdio" field is optional — stdio is the default transport for most hosts.

The Discovery Loop

The core agent workflow follows three steps:

  1. Orient — Call application_info to understand the project: installed crates, configured features, database details, and overall application structure.

  2. Explore — Use feature-specific tools to drill into the area of interest. Exploring routes: list_routes then explain_route. Understanding data: list_models then explain_model then model_usages. Diagnosing a problem: last_error or diagnose_error.

  3. Generate — Call code_templates or generation_context to get scaffolding hints, then execute the appropriate CLI command to produce the actual code.

This loop repeats at each level of depth. An agent scaffolding a feature might call application_info once, list_routes to check for conflicts, explain_model to understand related data, and code_templates to identify the right CLI command — all before writing a single line of code.

Common Workflows

Exploring a new codebase

An agent joining an unfamiliar Ferro project starts with application_info to get the full picture: what crates are installed, what features are active, what database is configured. It follows with list_routes to map the API surface and list_models to understand the data layer. Within minutes the agent has the structural knowledge that a developer would need days to accumulate by reading source files.

Diagnosing a build or runtime error

When a build fails or a request errors, the agent calls last_error to retrieve the most recent error Ferro captured, then diagnose_error with the error text to get a structured root-cause analysis with suggested fixes. The agent does not need to read log files or trace through stack frames manually.

Understanding models and their relationships

For features that involve data, the agent calls list_models to get all model names, explain_model on the relevant models to understand their fields and constraints, and model_usages to see where a model is referenced across the codebase. relation_map gives a full graph of model relationships. This is enough context to generate correct queries, validations, and forms without reading individual source files.

Generating code from templates

When an agent needs to scaffold a handler, model, or migration, it calls code_templates to get the list of available templates and their generation hints. Each template specifies the CLI command that produces it. The agent selects the appropriate template, reads the hint, and calls the CLI command. The scaffolded code follows Ferro conventions and integrates with the project's existing structure.

Agent-to-CLI Workflow

The bridge between MCP introspection and code generation is explicit in Ferro: MCP tools return generation_hints that point directly to the CLI command that scaffolds the code.

End-to-end example: scaffolding a new model

  1. The agent calls code_templates and receives a list of templates. The model template entry includes:

    generation_hint: "Use `ferro make:scaffold <ModelName>` to scaffold a new model with migration"
    
  2. The agent reads the hint, identifies the CLI command: ferro make:scaffold.

  3. The agent executes the CLI command:

    ferro make:scaffold Post
    
  4. Ferro generates src/models/post.rs and a timestamped migration file with the correct SeaORM structure.

  5. The agent reads the generated file using get_handler or directly, confirms the structure matches intent, and proceeds to add fields or relationships.

The key insight: the agent never guesses the CLI command syntax. generation_context and code_templates always provide the canonical invocation. The agent's job is to select the right template and supply the right arguments — the framework handles the rest.

Other generation hints flow the same way:

  • code_templatesferro make:action — scaffold a request handler
  • code_templatesferro make:migration — scaffold a database migration
  • code_templatesferro make:job — scaffold a background job
  • generation_context for the current route → ferro make:controller with the matching name

Troubleshooting

"command not found" when starting the MCP server

The binary path in the MCP config must point to the ferro binary, not ferro-mcp. The binary is at target/debug/ferro (development) or target/release/ferro (production). There is no standalone ferro-mcp binary.

"No tools available" after connecting

Check the JSON syntax in your config file — a trailing comma or missing bracket will silently prevent the server from loading. Validate with a JSON linter. Also confirm the binary path is absolute, not relative.

Tools return stale data after code changes

The ferro MCP server reads your source code at startup. After changing models, routes, or handlers, rebuild the project (cargo build) and restart your MCP host so the server reloads with the updated source.

application_info shows missing features

Features are detected from Cargo.toml dependencies. If a crate is not in your workspace's Cargo.toml, it will not appear in application_info. Add the dependency and rebuild.

See Also

  • MCP Bridgeferro-api-mcp, the consumer-facing API MCP server that exposes your application's REST API to external agents (separate from the framework introspection covered here)

Directory Structure

A Ferro project follows a convention-based structure inspired by Laravel.

my-app/
├── Cargo.toml              # Rust dependencies
├── .env                    # Environment configuration
├── src/
│   ├── main.rs             # Application entry point
│   ├── routes.rs           # Route definitions
│   ├── bootstrap.rs        # Global middleware registration
│   ├── actions/            # Business logic handlers
│   ├── controllers/        # HTTP controllers
│   ├── middleware/         # Custom middleware
│   ├── models/             # Database entities (SeaORM)
│   ├── migrations/         # Database migrations
│   ├── services/           # Service implementations
│   ├── requests/           # Form request validation
│   ├── events/             # Event definitions
│   ├── listeners/          # Event listeners
│   ├── jobs/               # Background jobs
│   ├── notifications/      # Notification classes
│   └── tasks/              # Scheduled tasks
├── frontend/
│   ├── src/
│   │   ├── pages/          # Inertia.js React components
│   │   ├── components/     # Reusable UI components
│   │   ├── types/          # TypeScript type definitions
│   │   └── main.tsx        # Frontend entry point
│   ├── package.json        # Node dependencies
│   └── vite.config.ts      # Vite configuration
├── storage/
│   ├── app/                # Application files
│   └── logs/               # Log files
└── public/
    └── storage/            # Symlink to storage/app/public

Key Directories

src/actions/

Business logic that doesn't fit neatly into controllers. Actions are invocable classes for complex operations.

#![allow(unused)]
fn main() {
// src/actions/create_user.rs
#[derive(Action)]
pub struct CreateUser {
    user_service: Arc<dyn UserService>,
}

impl CreateUser {
    pub async fn execute(&self, data: CreateUserData) -> Result<User> {
        // Business logic here
    }
}
}

src/controllers/

HTTP handlers grouped by resource. Generated with ferro make:controller.

#![allow(unused)]
fn main() {
// src/controllers/users_controller.rs
#[handler]
pub async fn index(req: Request) -> Response { ... }

#[handler]
pub async fn store(req: Request, form: CreateUserForm) -> Response { ... }
}

src/models/

SeaORM entity definitions. Generated with ferro db:sync.

src/middleware/

Custom middleware for request/response processing.

src/events/ and src/listeners/

Event-driven architecture. Events dispatch to multiple listeners.

src/jobs/

Background job definitions for queue processing.

frontend/src/pages/

Inertia.js page components. Path determines the route component.

pages/Users/Index.tsx  →  Inertia::render("Users/Index", ...)
pages/Dashboard.tsx    →  Inertia::render("Dashboard", ...)

Configuration Files

.env

Environment-specific configuration:

APP_ENV=local
APP_DEBUG=true
DATABASE_URL=sqlite:database.db
REDIS_URL=redis://localhost:6379

Cargo.toml

Rust dependencies. Ferro crates are added here.

frontend/package.json

Node.js dependencies for the React frontend.

Generated Directories

These directories are created automatically:

  • target/ - Rust build artifacts
  • node_modules/ - Node.js dependencies
  • frontend/dist/ - Built frontend assets

Storage

The storage/ directory holds application files:

# Create public storage symlink
ferro storage:link

This links public/storagestorage/app/public for publicly accessible files.

Routing

Ferro provides an expressive routing API similar to Laravel.

Basic Routes

Define routes in src/routes.rs:

#![allow(unused)]
fn main() {
use ferro::Router;

pub fn routes() -> Router {
    Router::new()
        .get("/", home)
        .get("/users", users::index)
        .post("/users", users::store)
        .put("/users/:id", users::update)
        .delete("/users/:id", users::destroy)
}
}

Route Parameters

Parameters are extracted from the URL path:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show(req: Request, id: i64) -> Response {
    // id is extracted from /users/:id
    let user = User::find_by_id(id).one(&db).await?;
    Ok(json!(user))
}
}

Optional Parameters

Use Option<T> for optional parameters:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request, page: Option<i64>) -> Response {
    let page = page.unwrap_or(1);
    // ...
}
}

Route Groups

Group routes with shared middleware or prefixes:

#![allow(unused)]
fn main() {
pub fn routes() -> Router {
    Router::new()
        .group("/api", |group| {
            group
                .get("/users", api::users::index)
                .post("/users", api::users::store)
                .middleware(ApiAuthMiddleware)
        })
        .group("/admin", |group| {
            group
                .get("/dashboard", admin::dashboard)
                .middleware(AdminMiddleware)
        })
}
}

Root routes inside a group

A "/" route inside a non-root group matches both the bare prefix and the prefix with a trailing slash. Other paths concatenate normally.

#![allow(unused)]
fn main() {
Router::new()
    .group("/s/{slug}", |r| {
        r.get("/", show_item)        // matches /s/foo AND /s/foo/
         .get("/edit", edit_item)    // matches /s/foo/edit
    })
}

A trailing slash on the group prefix is stripped before concatenation, so group("/api/", ...) with a child /x route produces /api/x, not /api//x (double-slash). Route introspection via get_registered_routes() and ferro-mcp list_routes reports one entry per logical handler — the canonical path without trailing slash.

Named Routes

#![allow(unused)]
fn main() {
Router::new()
    .get("/users/:id", users::show).name("users.show")
}

Generate URLs:

#![allow(unused)]
fn main() {
let url = route("users.show", [("id", "1")]);
// => "/users/1"
}

Resource Routes

Generate CRUD routes for a resource:

#![allow(unused)]
fn main() {
Router::new()
    .resource("/users", users_controller)
}

This creates:

MethodURIHandler
GET/usersindex
GET/users/createcreate
POST/usersstore
GET/users/:idshow
GET/users/:id/editedit
PUT/users/:idupdate
DELETE/users/:iddestroy

Route Middleware

Apply middleware to specific routes:

#![allow(unused)]
fn main() {
Router::new()
    .get("/dashboard", dashboard)
    .middleware(AuthMiddleware)
}

Or to groups:

#![allow(unused)]
fn main() {
Router::new()
    .group("/admin", |group| {
        group
            .get("/users", admin::users)
            .middleware(AdminMiddleware)
    })
}

Fallback Routes

Handle 404s:

#![allow(unused)]
fn main() {
Router::new()
    .get("/", home)
    .fallback(not_found)
}

Route Constraints

Validate route parameters:

#![allow(unused)]
fn main() {
Router::new()
    .get("/users/:id", users::show)
    .where_("id", r"\d+")  // Must be numeric
}

API Routes

For JSON APIs, typically group under /api:

#![allow(unused)]
fn main() {
Router::new()
    .group("/api/v1", |api| {
        api
            .get("/users", api::users::index)
            .post("/users", api::users::store)
            .middleware(ApiAuthMiddleware)
    })
}

View Routes

For simple views without controller logic:

#![allow(unused)]
fn main() {
Router::new()
    .view("/about", "About", AboutProps::default())
}

Redirect Routes

#![allow(unused)]
fn main() {
Router::new()
    .redirect("/old-path", "/new-path")
    .redirect_permanent("/legacy", "/modern")
}

Route Caching

In production, routes are compiled at build time for optimal performance.

Middleware

Ferro provides a powerful middleware system for intercepting and processing HTTP requests before they reach your route handlers. Middleware can inspect, modify, or short-circuit requests, and also post-process responses.

Generating Middleware

The fastest way to create a new middleware is using the Ferro CLI:

ferro make:middleware Auth

This command will:

  1. Create src/middleware/auth.rs with a middleware stub
  2. Update src/middleware/mod.rs to export the new middleware

Examples:

# Creates AuthMiddleware in src/middleware/auth.rs
ferro make:middleware Auth

# Creates RateLimitMiddleware in src/middleware/rate_limit.rs
ferro make:middleware RateLimit

# You can also include "Middleware" suffix (same result)
ferro make:middleware CorsMiddleware

Generated file:

#![allow(unused)]
fn main() {
//! Auth middleware

use ferro::{async_trait, Middleware, Next, Request, Response};

/// Auth middleware
pub struct AuthMiddleware;

#[async_trait]
impl Middleware for AuthMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        // TODO: Implement middleware logic
        next(request).await
    }
}
}

Overview

Middleware sits between the incoming request and your route handlers, allowing you to:

  • Authenticate and authorize requests
  • Log requests and responses
  • Add CORS headers
  • Rate limit requests
  • Transform request/response data
  • And much more

Creating Middleware

To create middleware, define a struct and implement the Middleware trait:

#![allow(unused)]
fn main() {
use ferro::{async_trait, HttpResponse, Middleware, Next, Request, Response};

pub struct LoggingMiddleware;

#[async_trait]
impl Middleware for LoggingMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        // Pre-processing: runs before the route handler
        println!("--> {} {}", request.method(), request.path());

        // Call the next middleware or route handler
        let response = next(request).await;

        // Post-processing: runs after the route handler
        println!("<-- Request complete");

        response
    }
}
}

The handle Method

The handle method receives:

  • request: The incoming HTTP request
  • next: A function to call the next middleware in the chain (or the route handler)

You can:

  • Continue the chain: Call next(request).await to pass control to the next middleware
  • Short-circuit: Return a response early without calling next()
  • Modify the request: Transform the request before calling next()
  • Modify the response: Transform the response after calling next()

Short-Circuiting Requests

Return early to block a request from reaching the route handler:

#![allow(unused)]
fn main() {
use ferro::{async_trait, HttpResponse, Middleware, Next, Request, Response};

pub struct AuthMiddleware;

#[async_trait]
impl Middleware for AuthMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        // Check for Authorization header
        if request.header("Authorization").is_none() {
            // Short-circuit: return 401 without calling the route handler
            return Err(HttpResponse::text("Unauthorized").status(401));
        }

        // Continue to the route handler
        next(request).await
    }
}
}

Registering Middleware

Ferro supports three levels of middleware:

1. Global Middleware

Global middleware runs on every request. Register it in bootstrap.rs using the global_middleware! macro:

#![allow(unused)]
fn main() {
// src/bootstrap.rs
use ferro::{global_middleware, DB};
use crate::middleware;

pub async fn register() {
    // Initialize database
    DB::init().await.expect("Failed to connect to database");

    // Global middleware runs on every request (in registration order)
    global_middleware!(middleware::LoggingMiddleware);
    global_middleware!(middleware::CorsMiddleware);
}
}

2. Route Middleware

Apply middleware to individual routes using the .middleware() method:

#![allow(unused)]
fn main() {
// src/routes.rs
use ferro::{routes, get, post};
use crate::controllers;
use crate::middleware::AuthMiddleware;

routes! {
    get("/", controllers::home::index).name("home"),
    get("/public", controllers::home::public),

    // Protected route - requires AuthMiddleware
    get("/protected", controllers::dashboard::index).middleware(AuthMiddleware),
    get("/admin", controllers::admin::index).middleware(AuthMiddleware),
}
}

3. Route Group Middleware

Apply middleware to a group of routes that share a common prefix:

#![allow(unused)]
fn main() {
use ferro::Router;
use crate::middleware::{AuthMiddleware, ApiMiddleware};

Router::new()
    // Public routes (no middleware)
    .get("/", home_handler)
    .get("/login", login_handler)

    // API routes with shared middleware
    .group("/api", |r| {
        r.get("/users", list_users)
         .post("/users", create_user)
         .get("/users/{id}", show_user)
    })
    .middleware(ApiMiddleware)

    // Admin routes with auth middleware
    .group("/admin", |r| {
        r.get("/dashboard", admin_dashboard)
         .get("/settings", admin_settings)
    })
    .middleware(AuthMiddleware)
}

Middleware attached to a group applies uniformly to root-path routes inside the group, covering both /prefix and /prefix/ variants.

Middleware Execution Order

Middleware executes in the following order:

  1. Global middleware (in registration order)
  2. Route group middleware
  3. Route-level middleware
  4. Route handler

For responses, the order is reversed (post-processing happens in reverse order).

Request → Global MW → Group MW → Route MW → Handler
                                              ↓
Response ← Global MW ← Group MW ← Route MW ← Handler

Practical Examples

CORS Middleware

#![allow(unused)]
fn main() {
use ferro::{async_trait, Middleware, Next, Request, Response, HttpResponse};

pub struct CorsMiddleware;

#[async_trait]
impl Middleware for CorsMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let response = next(request).await;

        // Add CORS headers to the response
        match response {
            Ok(mut res) => {
                res = res
                    .header("Access-Control-Allow-Origin", "*")
                    .header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
                    .header("Access-Control-Allow-Headers", "Content-Type, Authorization");
                Ok(res)
            }
            Err(mut res) => {
                res = res
                    .header("Access-Control-Allow-Origin", "*");
                Err(res)
            }
        }
    }
}
}

Rate Limiting Middleware

#![allow(unused)]
fn main() {
use ferro::{async_trait, Middleware, Next, Request, Response, HttpResponse};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

pub struct RateLimitMiddleware {
    requests: Arc<AtomicUsize>,
    max_requests: usize,
}

impl RateLimitMiddleware {
    pub fn new(max_requests: usize) -> Self {
        Self {
            requests: Arc::new(AtomicUsize::new(0)),
            max_requests,
        }
    }
}

#[async_trait]
impl Middleware for RateLimitMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let count = self.requests.fetch_add(1, Ordering::SeqCst);

        if count >= self.max_requests {
            return Err(HttpResponse::text("Too Many Requests").status(429));
        }

        next(request).await
    }
}
}

Request Timing Middleware

#![allow(unused)]
fn main() {
use ferro::{async_trait, Middleware, Next, Request, Response};
use std::time::Instant;

pub struct TimingMiddleware;

#[async_trait]
impl Middleware for TimingMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let start = Instant::now();
        let path = request.path().to_string();

        let response = next(request).await;

        let duration = start.elapsed();
        println!("{} completed in {:?}", path, duration);

        response
    }
}
}

Security Headers

The SecurityHeaders middleware adds OWASP-recommended HTTP security headers to all responses. It is registered by default in new projects generated with ferro new.

Default Headers

HeaderDefault ValuePurpose
X-Content-Type-OptionsnosniffPrevents MIME-type sniffing
X-Frame-OptionsDENYPrevents clickjacking
Content-Security-Policydefault-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'Controls resource loading
Referrer-Policystrict-origin-when-cross-originControls referer header
Permissions-Policygeolocation=(), camera=(), microphone=()Restricts browser features
Cross-Origin-Opener-Policysame-originIsolates browsing context
X-XSS-Protection0Disables legacy XSS auditor (per OWASP)
Strict-Transport-Security(not set)Enable with .with_hsts()

Customization

#![allow(unused)]
fn main() {
use ferro::{global_middleware, SecurityHeaders};

// Default (recommended for most apps)
global_middleware!(SecurityHeaders::new());

// Production with HTTPS
global_middleware!(SecurityHeaders::new().with_hsts());

// Custom frame options (allow same-origin iframes)
global_middleware!(SecurityHeaders::new()
    .x_frame_options("SAMEORIGIN"));

// Custom CSP for specific needs
global_middleware!(SecurityHeaders::new()
    .content_security_policy("default-src 'self'; script-src 'self'"));

// Disable a specific header
global_middleware!(SecurityHeaders::new()
    .without("X-Frame-Options"));
}

HSTS

HSTS (HTTP Strict Transport Security) is off by default because it breaks localhost development over HTTP. Enable it in production:

#![allow(unused)]
fn main() {
// Standard HSTS (max-age=1year, includeSubDomains)
global_middleware!(SecurityHeaders::new().with_hsts());

// HSTS with preload directive
global_middleware!(SecurityHeaders::new().with_hsts_preload());
}

with_hsts_preload() adds the preload directive. Only enable this if you have submitted your domain to the HSTS preload list. Preload is permanent -- removing a domain takes months.

Notes

  • The default CSP includes 'unsafe-inline' and 'unsafe-eval' for compatibility with Inertia.js and Vite. Tighten these directives for production if your setup allows it.
  • Security headers apply to both success and error responses.
  • Headers are applied during response post-processing (after the route handler runs).
  • Static files served directly by Ferro bypass middleware. Use a reverse proxy (e.g., nginx) for static file headers in production.

File Organization

The recommended file structure for middleware:

src/
├── middleware/
│   ├── mod.rs          # Re-export all middleware
│   ├── auth.rs         # Authentication middleware
│   ├── logging.rs      # Logging middleware
│   └── cors.rs         # CORS middleware
├── bootstrap.rs        # Register global middleware
├── routes.rs           # Apply route-level middleware
└── main.rs

src/middleware/mod.rs:

#![allow(unused)]
fn main() {
mod auth;
mod logging;
mod cors;

pub use auth::AuthMiddleware;
pub use logging::LoggingMiddleware;
pub use cors::CorsMiddleware;
}

Summary

FeatureUsage
Create middlewareImplement Middleware trait
Global middlewareglobal_middleware!(MyMiddleware) in bootstrap.rs
Route middleware.middleware(MyMiddleware) on route definition
Group middleware.middleware(MyMiddleware) on route group
Short-circuitReturn Err(HttpResponse::...) without calling next()
Continue chainCall next(request).await

Controllers

Controllers group related request handling logic.

Creating Controllers

ferro make:controller Users

This creates src/controllers/users_controller.rs:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, json};

#[handler]
pub async fn index(req: Request) -> Response {
    // List users
    Ok(json!({"users": []}))
}

#[handler]
pub async fn show(req: Request, id: i64) -> Response {
    // Show single user
    Ok(json!({"id": id}))
}

#[handler]
pub async fn store(req: Request) -> Response {
    // Create user
    Ok(json!({"created": true}))
}

#[handler]
pub async fn update(req: Request, id: i64) -> Response {
    // Update user
    Ok(json!({"updated": id}))
}

#[handler]
pub async fn destroy(req: Request, id: i64) -> Response {
    // Delete user
    Ok(json!({"deleted": id}))
}
}

The Handler Macro

The #[handler] macro provides:

  1. Automatic parameter extraction from path, query, body
  2. Dependency injection for services
  3. Error handling conversion
#![allow(unused)]
fn main() {
#[handler]
pub async fn show(
    req: Request,           // Always available
    id: i64,                // From path parameter
    user_service: Arc<dyn UserService>,  // Injected service
) -> Response {
    let user = user_service.find(id).await?;
    Ok(json!(user))
}
}

Route Registration

Register controller methods in src/routes.rs:

#![allow(unused)]
fn main() {
use crate::controllers::users_controller;

pub fn routes() -> Router {
    Router::new()
        .get("/users", users_controller::index)
        .get("/users/:id", users_controller::show)
        .post("/users", users_controller::store)
        .put("/users/:id", users_controller::update)
        .delete("/users/:id", users_controller::destroy)
}
}

Inertia Controllers

For Inertia.js responses:

#![allow(unused)]
fn main() {
use ferro::{Inertia, InertiaProps, Request, Response};
use crate::models::users::Entity as User;

#[handler]
pub async fn index(req: Request) -> Response {
    let db = req.db();
    let users = User::find().all(db).await?;

    Inertia::render(&req, "Users/Index", UsersIndexProps { users })
}

#[derive(InertiaProps)]
pub struct UsersIndexProps {
    pub users: Vec<crate::models::users::Model>,
}
}

Form Handling with SavedInertiaContext

When handling forms, you need to call req.input() which consumes the request. To render validation errors with Inertia, save the context first:

#![allow(unused)]
fn main() {
use ferro::{Inertia, InertiaProps, Request, Response, SavedInertiaContext, Validate, serde_json};

#[derive(InertiaProps)]
pub struct LoginProps {
    pub errors: Option<serde_json::Value>,
}

#[derive(Deserialize, Validate)]
pub struct LoginRequest {
    #[validate(email(message = "Please enter a valid email"))]
    pub email: String,
    #[validate(length(min = 1, message = "Password is required"))]
    pub password: String,
}

#[handler]
pub async fn login(req: Request) -> Response {
    // Save Inertia context BEFORE consuming request
    let ctx = SavedInertiaContext::from(&req);

    // This consumes the request
    let form: LoginRequest = req.input().await?;

    // Validate - use saved context for error responses
    if let Err(errors) = form.validate() {
        return Inertia::render_ctx(
            &ctx,
            "auth/Login",
            LoginProps { errors: Some(serde_json::json!(errors)) },
        ).map(|r| r.status(422));
    }

    // Process login...
    redirect!("/dashboard").into()
}
}

Key points:

  • SavedInertiaContext::from(&req) captures path and Inertia headers
  • Inertia::render_ctx(&ctx, ...) renders using saved context
  • Use this pattern when you need to both read the body AND render Inertia responses

Form Validation

Use form requests for validation:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response};

#[derive(FormRequest)]
pub struct CreateUserRequest {
    #[validate(required, email)]
    pub email: String,

    #[validate(required, min(8))]
    pub password: String,
}

#[handler]
pub async fn store(req: Request, form: CreateUserRequest) -> Response {
    // form is already validated
    let user = User::create(form.email, form.password).await?;
    Ok(Redirect::to("/users"))
}
}

Service Injection

Inject services via the #[service] system:

#![allow(unused)]
fn main() {
#[handler]
pub async fn store(
    req: Request,
    form: CreateUserRequest,
    user_service: Arc<dyn UserService>,
    mailer: Arc<dyn Mailer>,
) -> Response {
    let user = user_service.create(form).await?;
    mailer.send_welcome(user.email).await?;

    Ok(Redirect::to("/users"))
}
}

Actions

For complex operations, use Actions:

ferro make:action CreateUser
#![allow(unused)]
fn main() {
// src/actions/create_user.rs
use ferro::handler;
use std::sync::Arc;

#[derive(Action)]
pub struct CreateUser {
    user_service: Arc<dyn UserService>,
    mailer: Arc<dyn Mailer>,
}

impl CreateUser {
    pub async fn execute(&self, data: CreateUserData) -> Result<User> {
        let user = self.user_service.create(data).await?;
        self.mailer.send_welcome(&user).await?;
        Ok(user)
    }
}
}

Use in controller:

#![allow(unused)]
fn main() {
#[handler]
pub async fn store(req: Request, form: CreateUserRequest, action: CreateUser) -> Response {
    let user = action.execute(form.into()).await?;
    Ok(Redirect::to(format!("/users/{}", user.id)))
}
}

Resource Controllers

A resource controller handles all CRUD operations:

MethodHandlerDescription
GET /resourcesindexList all
GET /resources/createcreateShow create form
POST /resourcesstoreCreate new
GET /resources/:idshowShow single
GET /resources/:id/editeditShow edit form
PUT /resources/:idupdateUpdate
DELETE /resources/:iddestroyDelete

API Controllers

For JSON APIs:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request) -> Response {
    let users = User::find().all(&req.db()).await?;

    Ok(json!({
        "data": users,
        "meta": {
            "total": users.len()
        }
    }))
}
}

With pagination:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request, page: Option<i64>, per_page: Option<i64>) -> Response {
    let page = page.unwrap_or(1);
    let per_page = per_page.unwrap_or(15);

    let paginator = User::find()
        .paginate(&req.db(), per_page as u64);

    let users = paginator.fetch_page(page as u64 - 1).await?;
    let total = paginator.num_items().await?;

    Ok(json!({
        "data": users,
        "meta": {
            "current_page": page,
            "per_page": per_page,
            "total": total
        }
    }))
}
}

See also

For POST handlers that mutate state and redirect on every code path, use #[action]. It preserves Ok(()) and bare ? ergonomics while handling the 303 redirect, session flash, and back-compat query string automatically.

Action Handlers

#[action] is the attribute macro for POST-style handlers that mutate state and then redirect. It is a sibling of #[handler] — choose #[action] when the handler's natural completion is a 303 redirect (success or error); choose #[handler] for HTMX partials, JSON endpoints, GET pages, or anything that does not redirect on every code path.

When to use #[action]

Use #[action] when the handler:

  • Receives a POST (or PUT / PATCH / DELETE) form submission.
  • Mutates state (creates, updates, deletes a record).
  • Redirects to a list or detail page on success.
  • Should redirect (often back to the same page) on error, surfacing the failure as a flash message.

Keep #[handler] for handlers that render a response: GET pages, JSON APIs, HTMX partials, file downloads.

Quick example

#![allow(unused)]
fn main() {
use ferro::{action, ActionError, ActionResult, Request};

#[action(redirect_to = "/dashboard/pages")]
pub async fn publish_by_id(req: Request, id: i64) -> ActionResult {
    let page = Page::find_by_id(id).await?
        .ok_or(ActionError::not_found("Page not found"))?;
    page.publish().await?;
    Ok(())
}
}

On success the user is redirected to /dashboard/pages with a 303. On any Err, the user is redirected to the same URL with ?error=<kind>&msg=<text> appended and a session flash entry written under the _action slot.

The macro shape

#![allow(unused)]
fn main() {
#[action(redirect_to = "/path", method = "POST")]
}
  • redirect_torequired. The success-path redirect target. Must be a same-origin path (starting with /).
  • method — optional. Default "POST". Currently informational; it documents the intended method but does not affect routing. Wire the route in your routes.rs as usual.

The user-written signature uses req: Request (not &mut Request). The macro generates the mutable binding internally — Request passes by value in the function signature, and the macro rebinds it as &mut Request before handing it to the user body.

Return type — ActionResult

#![allow(unused)]
fn main() {
pub type ActionResult = Result<(), ActionError>;
}

The success expression is Ok(()) — there is no helper type to construct. The error side is ActionError (described below).

? ergonomics

Inside an #[action] body, ? works directly on:

  • Result<_, String>
  • Result<_, &'static str>
  • Result<_, FrameworkError>
  • Result<_, sea_orm::DbErr>

The macro wraps the body in an async move { ... }.await block typed as ActionResult, so ? propagates to ActionResult, not to the outer Response-returning function. This is what makes bare ? work on the error types above.

For any other error type implementing std::fmt::Display, use the .action_err() extension method:

#![allow(unused)]
fn main() {
use ferro::ActionResultExt;

let value = some_external_call()
    .action_err()?;
}

.action_err() converts any Display error into an ActionError::msg(e.to_string()).

ActionError — constructors and builders

#![allow(unused)]
fn main() {
ActionError::msg("Something went wrong");           // kind = Generic
ActionError::not_found("Page not found");           // kind = NotFound
ActionError::forbidden("You don't own this page");  // kind = Forbidden
ActionError::unauthorized("Please sign in");        // kind = Unauthorized
}

Builder methods (consuming mut self -> Self):

#![allow(unused)]
fn main() {
ActionError::msg("Saved as draft")
    .with_flash(FlashVariant::Warning)         // override the banner variant
    .redirect_to("/dashboard/pages/123/edit"); // override the redirect target
}

redirect_to(...) on ActionError is the error-side redirect override. The most common use is unauthorized() — ferro does not know your application's sign-in path, so configure it explicitly:

#![allow(unused)]
fn main() {
if !user.is_authenticated() {
    return Err(ActionError::unauthorized("Please sign in")
        .redirect_to("/your-login-path"));
}
}

Ferro intentionally does not ship a default authentication redirect. Consumer applications supply the path. This keeps ferro-* crates project-agnostic (no hardcoded sign-in routes).

Success-side overrides

Request exposes two setter methods for overriding the success path:

#![allow(unused)]
fn main() {
#[action(redirect_to = "/dashboard/pages")]
pub async fn create(req: Request) -> ActionResult {
    let new_page = Page::create(...).await?;
    req.redirect_to(format!("/dashboard/pages/{}", new_page.id));
    req.flash("created");
    Ok(())
}
}
  • req.redirect_to(url) — overrides the configured redirect_to on the success path. Validated as same-origin; external URLs are silently ignored (see Security below).
  • req.flash(key) — records a success flash key. On the next page render, the consumer template reads the _action flash slot to surface the success message.

Both setters take effect only on the success path. On Err, they are ignored; ActionError's own redirect_override and flash_variant apply instead.

The override surface is split by code path: success-side overrides live on Request (where the success handler has access); error-side overrides live on ActionError (where the error is constructed). This makes each override discoverable at its natural call site.

Flash transport

#[action] writes a JSON payload to the session flash slot _action:

{
  "variant": "error",
  "message": "Page not found"
}

variant is one of error, warning, info, or success. Templates use this to choose the banner's CSS class.

Consumer templates read the flash via:

#![allow(unused)]
fn main() {
let action_flash = session.get_flash::<serde_json::Value>("_action");
// Pass to the view.
}

get_flash ages the value — the next render after the redirect sees it; subsequent renders do not.

Back-compat query string

For templates not yet wired to read session flash, #[action] also appends a query string to the redirect URL:

  • Error path: ?error=<kind>&msg=<percent-encoded-message>
  • Success path: ?success=<flash-key-or-1>

where <kind> is the snake_case ActionKind value: generic, not_found, forbidden, unauthorized. This fallback exists for incremental migration and may be removed in a future ferro release once session flash reading is universal in consumer templates.

Security

The action runtime applies three mitigations. Consumers should be aware of each.

Flash message escaping (T-180-01)

ActionError::message is treated as untrusted user-facing display text. It may originate from a database error reflecting a query parameter, a parsed form field, or any other input the handler touches.

Consumer templates MUST HTML-escape the flash message before rendering. Ferro stores the message verbatim in the JSON flash payload; escaping is the template's responsibility.

Safe rendering example:

{% if action_flash %}
  <div class="flash flash--{{ action_flash.variant }}">
    {{ action_flash.message | escape }}
  </div>
{% endif %}

Open-redirect mitigation (T-180-02)

Both the error-side ActionError::redirect_to(...) and the success-side req.redirect_to(...) are validated as same-origin at the moment the redirect is built. URLs not starting with /, or starting with // (scheme-relative), are silently rejected and the request falls back to the configured #[action(redirect_to = "...")] target. A tracing::warn! records the rejection.

To redirect to an external URL, use a dedicated #[handler] that constructs the redirect explicitly.

Log-injection mitigation (T-180-03)

tracing::error! is called on the error path with the handler name and error message. Control characters in the message (\n, \r, \t, NUL, etc.) are stripped before the call so structured log sinks see a single-line value. This is defense-in-depth — JSON log formatters escape control characters anyway, but plain-text sinks benefit.

The query-string fallback (?msg=...) uses standard percent-encoding so control characters in the URL are encoded, not interpreted.

Before / after

A typical action handler before #[action]:

Before

#![allow(unused)]
fn main() {
#[handler]
pub async fn publish_by_id(req: Request, id: i64) -> Response {
    let page = Page::find_by_id(id).await
        .map_err(|e| error_response(500, &format!("DB error: {e}")))?
        .ok_or_else(|| error_response(404, "Page not found"))?;
    if page.tenant_id != current_tenant_id {
        return Err(error_response(403, "Not authorized"));
    }
    match page.publish().await {
        Ok(()) => Redirect::to("/dashboard/pages?success=published").into(),
        Err(err_msg) => {
            let encoded: String = pct_encode(&err_msg);
            Redirect::to(format!("/dashboard/pages?error=publish&msg={}", encoded)).into()
        }
    }
}
}

On any failure the browser is stranded at the POST URL displaying a 500 page.

After

#![allow(unused)]
fn main() {
#[action(redirect_to = "/dashboard/pages")]
pub async fn publish_by_id(req: Request, id: i64) -> ActionResult {
    let page = Page::find_by_id(id).await?
        .ok_or(ActionError::not_found("Page not found"))?;
    if page.tenant_id != current_tenant_id {
        return Err(ActionError::forbidden(""));
    }
    page.publish().await?;
    Ok(())
}
}

Every failure is a 303 redirect to /dashboard/pages with a flash message and a back-compat query string. The browser does not strand at the POST URL.

error_response! macro

For handlers that return Response (not ActionResult), the error_response! macro produces a bare HttpResponse suitable for use inside .map_err(...) and .ok_or_else(...) error arms:

use ferro::error_response;

let record = Entity::find_by_id(id)
    .one(db)
    .await
    .map_err(|e| ferro::error_response!(500, e.to_string()))?
    .ok_or_else(|| ferro::error_response!(404, "Not found"))?;

The macro returns an HttpResponse with a JSON body {"message": "..."} and the given status code. It does not wrap the value in Ok(...) — the ? operator in the error arm does the unwrapping.

See also

Request & Response

The Request Object

Every handler receives a Request object:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show(req: Request) -> Response {
    // Use request data
}
}

Accessing Request Data

#![allow(unused)]
fn main() {
// Path: /users/123
let id = req.param::<i64>("id")?;

// Query: /users?page=2&sort=name
let page = req.query::<i64>("page").unwrap_or(1);
let sort = req.query::<String>("sort");

// Headers
let auth = req.header("Authorization");
let content_type = req.content_type();

// Method and path
let method = req.method();
let path = req.path();
let full_url = req.url();
}

Request Body

#![allow(unused)]
fn main() {
// JSON body
let data: CreateUserData = req.json().await?;

// Form data
let form: HashMap<String, String> = req.form().await?;

// Raw body
let bytes = req.body_bytes().await?;
let text = req.body_string().await?;
}

File Uploads

#![allow(unused)]
fn main() {
let file = req.file("avatar").await?;

// File properties
let filename = file.filename();
let content_type = file.content_type();
let size = file.size();

// Save file
file.store("avatars").await?;
// or
file.store_as("avatars", "custom-name.jpg").await?;
}

Authentication

#![allow(unused)]
fn main() {
// Check if authenticated
if req.is_authenticated() {
    let user = req.user()?;
    println!("Hello, {}", user.name);
}

// Get optional user
if let Some(user) = req.user_optional() {
    // ...
}
}

Session Data

#![allow(unused)]
fn main() {
// Read session
let user_id = req.session().get::<i64>("user_id");

// Write session (needs mutable access)
req.session_mut().set("flash", "Welcome back!");

// Flash messages
let flash = req.session().flash("message");
}

Cookies

#![allow(unused)]
fn main() {
// Read cookie
let token = req.cookie("remember_token");

// Cookies are typically set on responses
}

Database Access

#![allow(unused)]
fn main() {
let db = req.db();
let users = User::find().all(db).await?;
}

Responses

Handlers return Response which is Result<HttpResponse, HttpResponse>:

#![allow(unused)]
fn main() {
pub type Response = Result<HttpResponse, HttpResponse>;
}

JSON Responses

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request) -> Response {
    Ok(json!({
        "users": users,
        "total": 100
    }))
}

// Or using HttpResponse directly
Ok(HttpResponse::json(data))
}

HTML Responses

#![allow(unused)]
fn main() {
Ok(HttpResponse::html("<h1>Hello</h1>"))
}

Text Responses

#![allow(unused)]
fn main() {
Ok(HttpResponse::text("Hello, World!"))
}

Inertia Responses

#![allow(unused)]
fn main() {
// Basic Inertia response
Inertia::render(&req, "Users/Index", props)

// With saved context (for form handlers)
let ctx = SavedInertiaContext::from(&req);
let form = req.input().await?;  // Consumes request
Inertia::render_ctx(&ctx, "Users/Form", props)
}

See Controllers - Form Handling for complete examples.

Redirects

#![allow(unused)]
fn main() {
// Simple redirect
Ok(Redirect::to("/dashboard"))

// Redirect back
Ok(Redirect::back(&req))

// Redirect with flash message
Ok(Redirect::to("/users").with("success", "User created!"))

// Named route redirect
Ok(Redirect::route("users.show", [("id", "1")]))
}

Status Codes

#![allow(unused)]
fn main() {
// Success codes
Ok(HttpResponse::ok(body))
Ok(HttpResponse::created(body))
Ok(HttpResponse::no_content())

// Error codes
Err(HttpResponse::bad_request("Invalid input"))
Err(HttpResponse::unauthorized("Please login"))
Err(HttpResponse::forbidden("Access denied"))
Err(HttpResponse::not_found("User not found"))
Err(HttpResponse::server_error("Something went wrong"))
}

Custom Status

#![allow(unused)]
fn main() {
Ok(HttpResponse::json(data).status(202))
}

Response Headers

#![allow(unused)]
fn main() {
Ok(HttpResponse::json(data)
    .header("X-Custom", "value")
    .header("Cache-Control", "no-cache"))
}

Cookies

#![allow(unused)]
fn main() {
Ok(HttpResponse::json(data)
    .cookie(Cookie::new("token", "abc123"))
    .cookie(Cookie::new("remember", "true")
        .http_only(true)
        .secure(true)
        .max_age(Duration::days(30))))
}

Binary Responses

Serve raw binary data (images, PDFs, generated files):

#![allow(unused)]
fn main() {
// Serve binary with explicit content type
Ok(HttpResponse::bytes(png_bytes)
    .header("Content-Type", "image/png"))

// File download with Content-Disposition
Ok(HttpResponse::download(pdf_bytes, "report.pdf"))
}

download() auto-detects Content-Type from the filename extension and sets the Content-Disposition: attachment header.

For static files served from public/, use the built-in static file serving (no handler needed).

Error Handling

Return errors as HttpResponse:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show(req: Request, id: i64) -> Response {
    let user = User::find_by_id(id)
        .one(&req.db())
        .await?
        .ok_or_else(|| HttpResponse::not_found("User not found"))?;

    Ok(json!(user))
}
}

Domain Errors

Use #[domain_error] for typed errors:

#![allow(unused)]
fn main() {
#[domain_error]
pub enum UserError {
    #[error("User not found")]
    #[status(404)]
    NotFound,

    #[error("Email already taken")]
    #[status(409)]
    EmailTaken,
}

#[handler]
pub async fn store(req: Request) -> Response {
    let result = create_user().await?; // ? converts UserError to HttpResponse
    Ok(json!(result))
}
}

Form Requests

For automatic validation:

#![allow(unused)]
fn main() {
#[derive(FormRequest)]
pub struct CreateUserRequest {
    #[validate(required, email)]
    pub email: String,

    #[validate(required, min(8))]
    pub password: String,

    #[validate(same("password"))]
    pub password_confirmation: String,
}

#[handler]
pub async fn store(req: Request, form: CreateUserRequest) -> Response {
    // form is validated, safe to use
    Ok(json!({"email": form.email}))
}
}

If validation fails, Ferro automatically returns a 422 response with errors.

InlineBudget & RequestTelemetry

Two request-scoped primitives for HTML inline-vs-preload decisioning and in-process sampled telemetry.

When to use InlineBudget

InlineBudget decides at request time whether a chunk of bytes should be inlined into the HTML response or preloaded via <link rel=preload>. Use it when:

  • The response renders payloads of varying size depending on tenant or context.
  • Inlining everything would risk crossing a payload-size cliff (HTML parse cost on the client).
  • A pre-built static fallback URL exists for the same byte payload.

RequestTelemetry records sampled time-series telemetry into an in-process ring buffer keyed by (key, scope). Use it when:

  • Operator dashboards need recent samples of a metric without round-tripping through an external telemetry system.
  • Per-tenant or per-route slicing is useful for diagnostics.
  • The "lost on process restart" semantic is acceptable.

Quick example

use ferro_rs::{Decision, Request, RequestTelemetry, Sample};
use serde_json::json;

async fn render_page(req: &mut Request, products: Vec<Product>) -> String {
    let products_json = serde_json::to_string(&products).unwrap_or_default();
    let payload_bytes = products_json.len();

    let body = "<div>...</div>";

    let html = match req.inline_budget(
        "products_payload",
        payload_bytes,
        "/_/bootstrap/products.json",
    ) {
        Decision::Inline => format!(
            "<script id='__products' type='application/json'>{products_json}</script>{body}",
        ),
        Decision::Preload(url) => format!(
            r#"<link rel="preload" as="fetch" href="{url}" crossorigin>{body}"#,
        ),
    };

    // Telemetry: record the payload byte count scoped to the tenant.
    req.telemetry_record_scoped(
        "products_payload_size",
        Some("tenant:42"),
        Sample::now(json!({ "bytes": payload_bytes })),
    );

    html
}

// Operator dashboard handler:
async fn ops_payload_distribution() -> Vec<Sample> {
    RequestTelemetry::snapshot("products_payload_size", Some("tenant:42"))
}

The Decision enum

pub enum Decision {
    Inline,
    Preload(String),
}

Match on Decision::Inline to emit the bytes inline; match on Decision::Preload(url) to emit a <link rel=preload href=url> and serve the same bytes from url separately. The URL is whatever the caller passed as fallback_url — there is no rewriting.

Threshold configuration

The threshold is read from AppConfig::inline_budget_threshold_bytes. Default: 102_400 (100 KiB).

Override via env var:

INLINE_BUDGET_BYTES=204800  # 200 KiB

Or programmatically:

use ferro_rs::{AppConfigBuilder, Config};

let cfg = AppConfigBuilder::default()
    .inline_budget_threshold_bytes(204_800)
    .build();

// Register the built config with the container so `req.inline_budget(...)`
// will pick up the override. Building the config without registering it has
// no effect — `decide()` reads from the container, not from a local binding.
Config::register(cfg);

If you skip the Config::register(cfg) step, inline_budget silently falls back to the INLINE_BUDGET_BYTES env var or to [ferro_rs::DEFAULT_INLINE_BUDGET_THRESHOLD_BYTES] (currently 100 KiB).

The threshold is global — there is no per-key override in v1. If your application needs heterogeneous budgets per key, file an issue requesting req.inline_budget_with_limit(key, bytes, fallback_url, limit).

Warning channel

The first time cumulative bytes for a given key crosses the threshold within a single request, InlineBudget emits a structured tracing::warn! with the following fields:

FieldTypeSource
key&strthe key argument
cumulative_bytesusizerunning total for key within the request
threshold_bytesusizethe configured threshold
fallback_url&strthe fallback_url argument
route_patternStringreq.route_pattern() or "" if not yet matched

The warning fires exactly once per (key, request). Subsequent calls past the threshold for the same key in the same request return Decision::Preload silently (no warning spam).

The fallback_url field is logged with Display formatting. Do NOT pass user-controlled input as fallback_url; the field is intended for compile-time-shaped strings designed by the application developer.

When to use RequestTelemetry

RequestTelemetry is a per-key in-process ring buffer for sampled time-series telemetry. Use it for operator-visible metrics that:

  • Have low cardinality on the (key, scope) axis.
  • Benefit from per-tenant or per-route slicing.
  • Do not require cross-process aggregation (use Prometheus or OpenTelemetry for that).

Each (key, scope) bucket holds at most 128 samples — oldest dropped on overflow. The store is process-global and lost on process restart.

Sample shape

pub struct Sample {
    pub recorded_at: std::time::SystemTime,
    pub value: serde_json::Value,
}

impl Sample {
    pub fn now(value: serde_json::Value) -> Self;
    pub fn at(when: std::time::SystemTime, value: serde_json::Value) -> Self;
}

recorded_at uses SystemTime (wall-clock) so samples remain comparable across process restarts. value is a serde_json::Value so heterogeneous payloads from different writers can share a snapshot reader.

Sample payload size is unbounded and caller-controlled. Keep payloads small — a few hundred bytes is typical for an operator-dashboard sample (a couple of numeric fields, a tenant id, maybe a route pattern). The ring buffer holds [ferro_rs::RING_BUFFER_CAPACITY] samples per (key, scope), so the per-bucket memory ceiling is payload_size × RING_BUFFER_CAPACITY. The framework does not enforce a per-sample size cap; that is caller discipline, the same framing as the (key, scope) cardinality note above.

Writer methods

impl Request {
    pub fn telemetry_record(&mut self, key: &str, sample: Sample);
    pub fn telemetry_record_scoped(&mut self, key: &str, scope: Option<&str>, sample: Sample);
}

telemetry_record(key, sample) is equivalent to telemetry_record_scoped(key, None, sample). Both methods take &mut self for API uniformity with inline_budget; neither mutates the request itself.

Reader — snapshot

impl RequestTelemetry {
    pub fn snapshot(key: &str, scope: Option<&str>) -> Vec<Sample>;
    pub fn keys() -> Vec<(String, Option<String>)>;
    pub fn clear();
}

snapshot returns a clone of every sample currently in the (key, scope) bucket in FIFO order. Empty if no samples have been recorded.

keys lists every (key, scope) pair that has at least one recorded sample. Useful for operator dashboards that need to enumerate available metrics.

clear drops every sample in every bucket — intended for operator-driven resets (e.g. post-deploy).

The reader API is internal Rust. If exposed on an HTTP endpoint, the consumer is responsible for authorization — RequestTelemetry itself enforces no access control.

Scope conventions

scope is Option<&str>; callers pick the convention. Recommended formats:

ConventionExampleWhen to use
tenant:N"tenant:42"per-tenant metrics in a multi-tenant app
route:/path"route:/api/products"per-route latency / size metrics
region:X"region:eu-west-1"per-region metrics in a multi-region deploy
(none)Noneglobal metric not sliced by anything

These are conventions, not constraints — the storage layer treats scope as an opaque Option<&str>.

Lost-on-restart semantic

The ring buffer is in-process memory. Process restart drops every sample. This is by design — RequestTelemetry is for recent operator-visible signal, not long-term metrics retention. For cross-process aggregation, export selected metrics to Prometheus, OpenTelemetry, or a custom DB sink.

Key cardinality

The (key, scope) axis has no enforced upper bound on cardinality. Callers MUST NOT derive key or scope strings from user input — a unique key per user creates unbounded growth of the global store. Use a fixed vocabulary controlled by application code.

End-to-end example

The "Quick example" section above is the canonical end-to-end example. It exercises both primitives in one handler: inline_budget decides inline-vs-preload for the products payload, then telemetry_record_scoped records the byte count under a per-tenant scope. The operator handler uses RequestTelemetry::snapshot to read recent samples for that tenant's bucket.

Events & Listeners

Ferro provides a Laravel-inspired event system for decoupling your application components. Events represent something that happened, while listeners react to those events.

Creating Events

Using the CLI

Generate a new event:

ferro make:event OrderPlaced

This creates src/events/order_placed.rs:

#![allow(unused)]
fn main() {
use ferro::Event;

#[derive(Clone)]
pub struct OrderPlaced {
    pub order_id: i64,
    pub user_id: i64,
    pub total: f64,
}

impl Event for OrderPlaced {
    fn name(&self) -> &'static str {
        "OrderPlaced"
    }
}
}

Event Requirements

Events must implement:

  • Clone - Events may be sent to multiple listeners
  • Send + Sync + 'static - For async safety
  • Event trait - Provides the event name

Creating Listeners

Using the CLI

Generate a new listener:

ferro make:listener SendOrderConfirmation

This creates src/listeners/send_order_confirmation.rs:

#![allow(unused)]
fn main() {
use ferro::{Listener, Error, async_trait};
use crate::events::OrderPlaced;

pub struct SendOrderConfirmation;

#[async_trait]
impl Listener<OrderPlaced> for SendOrderConfirmation {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        tracing::info!("Sending confirmation for order {}", event.order_id);
        // Send email logic...
        Ok(())
    }
}
}

Listener Trait Methods

MethodDescriptionDefault
handle(&self, event)Process the eventRequired
name(&self)Listener identifierType name
should_stop_propagation(&self)Stop other listenersfalse

Registering Listeners

Register listeners in src/bootstrap.rs:

#![allow(unused)]
fn main() {
use ferro::{App, EventDispatcher};
use crate::events::OrderPlaced;
use crate::listeners::{SendOrderConfirmation, UpdateInventory, NotifyWarehouse};

pub async fn register() {
    // ... other setup ...

    let dispatcher = App::event_dispatcher();

    // Register listeners for OrderPlaced event
    dispatcher.listen::<OrderPlaced, _>(SendOrderConfirmation);
    dispatcher.listen::<OrderPlaced, _>(UpdateInventory);
    dispatcher.listen::<OrderPlaced, _>(NotifyWarehouse);
}
}

Closure Listeners

For simple cases, use closures:

#![allow(unused)]
fn main() {
dispatcher.on::<OrderPlaced, _, _>(|event| async move {
    tracing::info!("Order {} placed!", event.order_id);
    Ok(())
});
}

Dispatching Events

Call .dispatch() directly on events:

#![allow(unused)]
fn main() {
use crate::events::OrderPlaced;

// In a controller or service
OrderPlaced {
    order_id: 123,
    user_id: 456,
    total: 99.99,
}
.dispatch()
.await?;
}

Fire and Forget

Dispatch without waiting for listeners:

#![allow(unused)]
fn main() {
OrderPlaced {
    order_id: 123,
    user_id: 456,
    total: 99.99,
}
.dispatch_sync();  // Returns immediately
}

Using the Dispatcher Directly

#![allow(unused)]
fn main() {
use ferro::dispatch;

dispatch(OrderPlaced {
    order_id: 123,
    user_id: 456,
    total: 99.99,
}).await?;
}

Queued Listeners

For long-running tasks, queue listeners for background processing:

#![allow(unused)]
fn main() {
use ferro::{Listener, ShouldQueue, Error, async_trait};
use crate::events::OrderPlaced;

pub struct GenerateInvoicePDF;

// Mark as queued
impl ShouldQueue for GenerateInvoicePDF {
    fn queue(&self) -> &'static str {
        "invoices"  // Send to specific queue
    }

    fn delay(&self) -> Option<u64> {
        Some(30)  // Wait 30 seconds before processing
    }

    fn max_retries(&self) -> u32 {
        5
    }
}

#[async_trait]
impl Listener<OrderPlaced> for GenerateInvoicePDF {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        // This runs in a background worker
        tracing::info!("Generating PDF for order {}", event.order_id);
        Ok(())
    }
}
}

Stopping Propagation

Stop subsequent listeners from running:

#![allow(unused)]
fn main() {
impl Listener<OrderPlaced> for FraudChecker {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        if self.is_fraudulent(event) {
            return Err(Error::msg("Fraudulent order detected"));
        }
        Ok(())
    }

    fn should_stop_propagation(&self) -> bool {
        true  // Other listeners won't run if this fails
    }
}
}

Example: Order Processing

#![allow(unused)]
fn main() {
// events/order_placed.rs
#[derive(Clone)]
pub struct OrderPlaced {
    pub order_id: i64,
    pub user_id: i64,
    pub items: Vec<OrderItem>,
    pub total: f64,
}

impl Event for OrderPlaced {
    fn name(&self) -> &'static str { "OrderPlaced" }
}

// listeners/send_order_confirmation.rs
pub struct SendOrderConfirmation;

#[async_trait]
impl Listener<OrderPlaced> for SendOrderConfirmation {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        let user = User::find(event.user_id).await?;
        Mail::to(&user.email)
            .subject("Order Confirmation")
            .template("emails/order-confirmation", event)
            .send()
            .await?;
        Ok(())
    }
}

// listeners/update_inventory.rs
pub struct UpdateInventory;

#[async_trait]
impl Listener<OrderPlaced> for UpdateInventory {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        for item in &event.items {
            Product::decrement_stock(item.product_id, item.quantity).await?;
        }
        Ok(())
    }
}

// bootstrap.rs
dispatcher.listen::<OrderPlaced, _>(SendOrderConfirmation);
dispatcher.listen::<OrderPlaced, _>(UpdateInventory);
}

Best Practices

  1. Keep events immutable - Events are data, not behavior
  2. Use descriptive names - Past tense for things that happened (OrderPlaced, UserRegistered)
  3. Include all needed data - Listeners shouldn't need to fetch additional data
  4. Queue heavy operations - Use ShouldQueue for emails, PDFs, external APIs
  5. Handle failures gracefully - Listeners should not break on individual failures
  6. Test listeners in isolation - Unit test each listener independently

MCP Tools

Use list_events to discover all registered events and listeners in the project.

list_events

Returns all Event implementations found in src/events/, each with the event name, fields, and which listeners are registered to handle it. Use this to understand the event graph before adding new listeners or debugging dispatch issues.

Queues & Background Jobs

Ferro provides a database-backed queue for processing jobs asynchronously. The queue uses the application's existing DatabaseConnection — no separate external queue server is needed. The WorkerLoop runs in-process inside Application::run and is started automatically when at least one job type is registered.

Atomic claim is dual-backend:

  • PostgresSELECT … FOR UPDATE SKIP LOCKED inside a transaction
  • SQLite — raw BEGIN IMMEDIATE + UPDATE … RETURNING

Both paths claim exactly one job per cycle; two workers on the same table cannot double-claim a row.

Setup

Migration

Register CreateJobsTable in your application's Migrator alongside your own migrations:

#![allow(unused)]
fn main() {
use ferro_queue::CreateJobsTable;
use sea_orm_migration::prelude::*;

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(CreateJobsTable),
            // ... your own migrations
        ]
    }
}
}

Registration

Register job types in your bootstrap before the server starts. The framework's server boot path (inside Application::run) detects registered job types and spawns a WorkerLoop automatically — no separate process or CLI command required.

#![allow(unused)]
fn main() {
// src/bootstrap.rs
use ferro::queue::Queue;
use crate::jobs::{ProcessPayment, SendEmail, GenerateReport};

pub async fn register() {
    // Register job types — the framework auto-starts the WorkerLoop.
    Queue::register::<ProcessPayment>();
    Queue::register::<SendEmail>();
    Queue::register::<GenerateReport>();
}
}

Environment Variables

# Queue driver: "sync" for development (jobs run inline), any other value for background.
# IMPORTANT: when QUEUE_CONNECTION is UNSET it defaults to "sync" — background
# processing is OFF unless you set this to a non-sync value (e.g. "db").
QUEUE_CONNECTION=db

# Default queue name
QUEUE_DEFAULT=default

# Maximum concurrent jobs per worker instance
QUEUE_MAX_CONCURRENT=10

QUEUE_CONNECTION defaults to sync when unset. In sync mode jobs run inline during the HTTP request — no background worker, no database polling — and .delay() / .on_queue() are ignored. Set any other value (e.g. db) to enable background processing. If jobs are registered while the queue is in sync mode, the server logs a startup warning, since this combination is usually unintended in production.

Creating Jobs

Using the CLI

ferro make:job ProcessPayment

This creates src/jobs/process_payment.rs:

#![allow(unused)]
fn main() {
use ferro::queue::{Job, Error, async_trait};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessPayment {
    pub order_id: i64,
    pub amount: f64,
}

#[async_trait]
impl Job for ProcessPayment {
    async fn handle(&self) -> Result<(), Error> {
        tracing::info!("Processing payment for order {}", self.order_id);
        // Payment processing logic...
        Ok(())
    }

    fn max_retries(&self) -> u32 {
        3
    }
}
}

Job Trait Methods

MethodDescriptionDefault
handle()Job execution logicRequired
name()Job identifier for loggingType name
max_retries()Retry attempts on failure3
retry_delay(attempt)Delay before retryFull-jitter exponential (see below)
timeout()Maximum execution time60 seconds
failed(error)Called when all retries exhaustedLogs error
idempotency_key()Deduplication key on enqueueNone

Retry Delay Default

The default retry_delay uses full-jitter exponential backoff: rand(0..=min(cap, base × 2^attempt)) where base = 5 s and cap = 15 min. Override it on individual job types:

#![allow(unused)]
fn main() {
fn retry_delay(&self, attempt: u32) -> std::time::Duration {
    // Fixed 30-second delay regardless of attempt count.
    std::time::Duration::from_secs(30)
}
}

Idempotency Keys

Provide idempotency_key() to prevent duplicate jobs when the same event fires more than once. Enqueue skips insertion when a pending or claimed row with the same (job_type, idempotency_key) already exists:

#![allow(unused)]
fn main() {
impl Job for SendInvoice {
    fn idempotency_key(&self) -> Option<String> {
        Some(format!("send-invoice-{}", self.invoice_id))
    }

    async fn handle(&self) -> Result<(), Error> {
        // Will only run once per invoice_id even if dispatched multiple times.
        Ok(())
    }
}
}

Dispatching Jobs

Basic Dispatch

#![allow(unused)]
fn main() {
use crate::jobs::ProcessPayment;

ProcessPayment {
    order_id: 123,
    amount: 99.99,
}
.dispatch()
.await?;
}

With Delay

#![allow(unused)]
fn main() {
use std::time::Duration;

ProcessPayment { order_id: 123, amount: 99.99 }
    .delay(Duration::from_secs(300))  // Run after 5 minutes
    .dispatch()
    .await?;
}

To Specific Queue

#![allow(unused)]
fn main() {
ProcessPayment { order_id: 123, amount: 99.99 }
    .on_queue("high-priority")
    .dispatch()
    .await?;
}

Combining Options

#![allow(unused)]
fn main() {
ProcessPayment { order_id: 123, amount: 99.99 }
    .delay(Duration::from_secs(60))
    .on_queue("payments")
    .dispatch()
    .await?;
}

WorkerLoop Configuration

The framework creates a WorkerLoop with WorkerConfig::default() when job types are registered. Override the configuration by calling WorkerLoop::new(config) directly if you need custom settings.

#![allow(unused)]
fn main() {
use ferro::queue::{WorkerConfig, WorkerLoop};
use std::time::Duration;

let config = WorkerConfig {
    queues: vec!["high-priority".into(), "default".into()],
    max_jobs: 20,
    sleep_duration: Duration::from_millis(500),
    visibility_timeout: Duration::from_secs(300), // 5 min default
    ..Default::default()
};
}
FieldDescriptionDefault
queuesQueue names to process, in priority order["default"]
max_jobsMaximum concurrent in-flight jobs10
sleep_durationIdle poll interval when queue is empty1s
visibility_timeoutTime before a claimed job is reclaimed by the reaper300s

CPU-Heavy Jobs

The WorkerLoop runs on the async executor. Jobs that do CPU-bound work (PDF rendering, image processing, compression) must wrap that work in tokio::task::spawn_blocking to avoid starving the executor of threads and blocking other jobs from running:

#![allow(unused)]
fn main() {
use ferro::queue::{Job, Error, async_trait};

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RenderDocumentPdfJob {
    pub document_id: i64,
}

#[async_trait]
impl Job for RenderDocumentPdfJob {
    async fn handle(&self) -> Result<(), Error> {
        let document_id = self.document_id;

        // spawn_blocking moves CPU work off the async executor thread pool.
        tokio::task::spawn_blocking(move || {
            // CPU-intensive PDF rendering here...
            render_pdf(document_id)
        })
        .await
        .map_err(|e| Error::custom(format!("spawn_blocking join: {e}")))?
        .map_err(|e| Error::custom(format!("render_pdf: {e}")))
    }
}

fn render_pdf(_document_id: i64) -> Result<(), String> {
    // synchronous rendering work
    Ok(())
}
}

This applies to any job doing significant CPU work: document rendering, image resizing, compression, or large in-memory data transformations.

Error Handling

Automatic Retries

Failed jobs are automatically retried based on max_retries() and retry_delay(). After all retries are exhausted, the job is parked as failed with the error message recorded:

#![allow(unused)]
fn main() {
impl Job for ProcessPayment {
    fn max_retries(&self) -> u32 {
        5
    }

    async fn failed(&self, error: &Error) {
        tracing::error!(
            order_id = self.order_id,
            error = %error,
            "Payment processing permanently failed"
        );
        // Notify, update order status, etc.
    }
}
}

Stuck Job Reaper

The worker runs a reaper before each claim cycle. Jobs that have been claimed for longer than the visibility_timeout (default 5 min) are:

  • Reset to pending with attempts + 1 if they have retries remaining
  • Parked as failed if they have exhausted max_retries

This recovers from worker crashes without any manual intervention.

Graceful Shutdown

On SIGTERM or Ctrl-C the worker stops claiming new jobs, waits for in-flight jobs to finish, and resets any claimed rows it held back to pending — those jobs will be claimed by the next worker instance.

Failed Job Inspection

Failed jobs are stored in the jobs table with status = 'failed'. Inspect them via the debug endpoint or ferro-mcp:

# Debug endpoint (requires APP_ENV=local or DEBUG_MODE=true)
curl http://localhost:3000/_ferro/queue/stats
curl http://localhost:3000/_ferro/queue/jobs

Migration Guide

The following table maps the previous external-broker API to the current DB-backed API.

Old APINew (DB)Notes
Queue::init(QueueConfig::new(broker_url))Queue::register::<J>() in bootstrap; framework auto-initsConnection injected at bootstrap from the app DB
Separate worker process / cargo run --bin workerWorkerLoop auto-started inside Application::runSingle binary, work-stealing
External broker env vars (HOST, PORT, PASSWORD)None requiredQueue uses the app's DATABASE_URL
failed_jobs tablejobs WHERE status='failed'Single table, error recorded inline
2^attempt fixed backoffFull-jitter exponential defaultOverride via Job::retry_delay
No deduplication hookJob::idempotency_key()Dedup on enqueue when Some
QueueConnection typeRemovedQueue::connection() returns &DatabaseConnection

Gestiscilo Consumer Migration (Phase 188)

The following job types migrate in gestiscilo Phase 188. Each keeps its Job implementation unchanged; only the registration and migration registration change:

JobOld registrationNew registration
RenderDocumentPdfJobworker.register::<RenderDocumentPdfJob>() in worker binaryQueue::register::<RenderDocumentPdfJob>() in bootstrap
SendBookingReminderJobworker.register::<SendBookingReminderJob>() in worker binaryQueue::register::<SendBookingReminderJob>() in bootstrap
DeliverNotificationJobworker.register::<DeliverNotificationJob>() in worker binaryQueue::register::<DeliverNotificationJob>() in bootstrap
screenshot_workerseparate process binaryQueue::register::<ScreenshotJob>() in bootstrap

Add Box::new(ferro_queue::CreateJobsTable) to your Migrator::migrations() list (one-time migration). The failed_jobs table (if present) can be dropped after migration — failed job history is now in jobs WHERE status='failed'.

MCP Tools

Use these tools to monitor and debug queue state during development and in running applications.

list_jobs

Returns all Job implementations found in src/jobs/, including the job struct name, max retries, and timeout configuration. Use this to audit what jobs exist before dispatching or debugging failures.

job_history

Returns recent failed job history from jobs WHERE status='failed': job name, error message, attempt count, and timestamp. Use this to diagnose jobs that are permanently failing.

queue_status

Returns current queue depth and pending job counts per queue name. Use this to check whether a queue is backed up.

Notifications

Ferro provides a Laravel-inspired multi-channel notification system. Send notifications via mail, database, Slack, and more through a unified API.

Configuration

Environment Variables

Configure notifications in your .env file:

# Mail Driver: smtp (default) or resend
MAIL_DRIVER=smtp

# SMTP Configuration (when MAIL_DRIVER=smtp)
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=your-username
MAIL_PASSWORD=your-password
MAIL_ENCRYPTION=tls

# Resend Configuration (when MAIL_DRIVER=resend)
RESEND_API_KEY=re_xxxxxxxxxxxxx

# Shared (all drivers)
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="My App"

# Slack
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz

Bootstrap Setup

In src/bootstrap.rs, initialize notifications:

#![allow(unused)]
fn main() {
use ferro::{NotificationConfig, NotificationDispatcher};

pub async fn register() {
    // ... other setup ...

    // Configure notifications from environment
    let config = NotificationConfig::from_env();
    NotificationDispatcher::configure(config);
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::{NotificationConfig, MailConfig, NotificationDispatcher};

// SMTP (default)
let config = NotificationConfig::new()
    .mail(
        MailConfig::new("smtp.example.com", 587, "noreply@example.com")
            .credentials("user", "pass")
            .from_name("My App")
    )
    .slack_webhook("https://hooks.slack.com/services/...");

NotificationDispatcher::configure(config);

// Resend
let config = NotificationConfig::new()
    .mail(
        MailConfig::resend("re_xxxxxxxxxxxxx", "noreply@example.com")
            .from_name("My App")
    );

NotificationDispatcher::configure(config);
}

Creating Notifications

Using the CLI

Generate a new notification:

ferro make:notification OrderShipped

This creates src/notifications/order_shipped.rs:

#![allow(unused)]
fn main() {
use ferro::{Notification, Channel, MailMessage};

pub struct OrderShipped {
    pub order_id: i64,
    pub tracking_number: String,
}

impl Notification for OrderShipped {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail]
    }

    fn to_mail(&self) -> Option<MailMessage> {
        Some(MailMessage::new()
            .subject("Your order has shipped!")
            .body(format!("Tracking: {}", self.tracking_number)))
    }
}
}

Notification Trait Methods

MethodDescriptionDefault
via()Channels to send throughRequired
to_mail()Mail message contentNone
to_database()Database message contentNone
to_slack()Slack message contentNone
notification_type()Type name for loggingType name

Making Entities Notifiable

Implement Notifiable on your User model:

#![allow(unused)]
fn main() {
use ferro::{Notifiable, Channel, async_trait};

pub struct User {
    pub id: i64,
    pub email: String,
    pub slack_webhook: Option<String>,
}

impl Notifiable for User {
    fn route_notification_for(&self, channel: Channel) -> Option<String> {
        match channel {
            Channel::Mail => Some(self.email.clone()),
            Channel::Database => Some(self.id.to_string()),
            Channel::Slack => self.slack_webhook.clone(),
            _ => None,
        }
    }

    fn notifiable_id(&self) -> String {
        self.id.to_string()
    }
}
}

Notifiable Trait Methods

MethodDescriptionDefault
route_notification_for(channel)Get routing info per channelRequired
notifiable_id()Unique identifier"unknown"
notifiable_type()Type nameType name
notify(notification)Send a notificationProvided

Sending Notifications

Basic Usage

#![allow(unused)]
fn main() {
use crate::notifications::OrderShipped;

// In a controller or service
let user = User::find(user_id).await?;
user.notify(OrderShipped {
    order_id: 123,
    tracking_number: "ABC123".into(),
}).await?;
}

Available Channels

Mail Channel

Send emails via SMTP or Resend:

#![allow(unused)]
fn main() {
impl Notification for WelcomeEmail {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail]
    }

    fn to_mail(&self) -> Option<MailMessage> {
        Some(MailMessage::new()
            .subject("Welcome to Our Platform")
            .body("Thanks for signing up!")
            .html("<h1>Welcome!</h1><p>Thanks for signing up!</p>")
            .cc("admin@example.com")
            .bcc("archive@example.com")
            .reply_to("support@example.com"))
    }
}
}

MailMessage Methods

MethodDescription
subject(text)Set email subject
body(text)Set plain text body
html(content)Set HTML body
from(address)Override from address
reply_to(address)Set reply-to address
cc(address)Add CC recipient
bcc(address)Add BCC recipient
header(name, value)Add custom header

Database Channel

Store notifications for in-app display:

#![allow(unused)]
fn main() {
use ferro::{Notification, Channel, DatabaseMessage};

impl Notification for OrderStatusChanged {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Database]
    }

    fn to_database(&self) -> Option<DatabaseMessage> {
        Some(DatabaseMessage::new("order_status_changed")
            .data("order_id", self.order_id)
            .data("status", &self.status)
            .data("message", format!("Order #{} is now {}", self.order_id, self.status)))
    }
}
}

DatabaseMessage Methods

MethodDescription
new(type)Create with notification type
data(key, value)Add data field
with_data(map)Add multiple fields
get(key)Get field value
to_json()Serialize to JSON

Slack Channel

Send Slack webhook notifications:

#![allow(unused)]
fn main() {
use ferro::{Notification, Channel, SlackMessage, SlackAttachment};

impl Notification for DeploymentComplete {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Slack]
    }

    fn to_slack(&self) -> Option<SlackMessage> {
        Some(SlackMessage::new("Deployment completed successfully!")
            .channel("#deployments")
            .username("Deploy Bot")
            .icon_emoji(":rocket:")
            .attachment(
                SlackAttachment::new()
                    .color("good")
                    .title("Deployment Details")
                    .field("Environment", &self.environment, true)
                    .field("Version", &self.version, true)
                    .footer("Deployed by CI/CD")
            ))
    }
}
}

SlackMessage Methods

MethodDescription
new(text)Create with main text
channel(name)Override channel
username(name)Override bot name
icon_emoji(emoji)Set emoji icon
icon_url(url)Set image icon
attachment(att)Add attachment

SlackAttachment Methods

MethodDescription
color(hex)Set sidebar color
title(text)Set attachment title
title_link(url)Make title clickable
text(content)Set attachment text
field(title, value, short)Add field
footer(text)Set footer text
timestamp(unix)Set timestamp

Multi-Channel Notifications

Send to multiple channels at once:

#![allow(unused)]
fn main() {
impl Notification for OrderPlaced {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail, Channel::Database, Channel::Slack]
    }

    fn to_mail(&self) -> Option<MailMessage> {
        Some(MailMessage::new()
            .subject("Order Confirmation")
            .body(format!("Order #{} placed successfully", self.order_id)))
    }

    fn to_database(&self) -> Option<DatabaseMessage> {
        Some(DatabaseMessage::new("order_placed")
            .data("order_id", self.order_id)
            .data("total", self.total))
    }

    fn to_slack(&self) -> Option<SlackMessage> {
        Some(SlackMessage::new(format!("New order #{} for ${:.2}", self.order_id, self.total)))
    }
}
}

Example: Complete Notification

#![allow(unused)]
fn main() {
// notifications/order_shipped.rs
use ferro::{Notification, Channel, MailMessage, DatabaseMessage, SlackMessage, SlackAttachment};

pub struct OrderShipped {
    pub order_id: i64,
    pub tracking_number: String,
    pub carrier: String,
    pub estimated_delivery: String,
}

impl Notification for OrderShipped {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail, Channel::Database, Channel::Slack]
    }

    fn to_mail(&self) -> Option<MailMessage> {
        Some(MailMessage::new()
            .subject(format!("Order #{} has shipped!", self.order_id))
            .html(format!(r#"
                <h1>Your order is on its way!</h1>
                <p>Order #{} has been shipped via {}.</p>
                <p><strong>Tracking:</strong> {}</p>
                <p><strong>Estimated Delivery:</strong> {}</p>
            "#, self.order_id, self.carrier, self.tracking_number, self.estimated_delivery)))
    }

    fn to_database(&self) -> Option<DatabaseMessage> {
        Some(DatabaseMessage::new("order_shipped")
            .data("order_id", self.order_id)
            .data("tracking_number", &self.tracking_number)
            .data("carrier", &self.carrier))
    }

    fn to_slack(&self) -> Option<SlackMessage> {
        Some(SlackMessage::new("Order shipped!")
            .attachment(
                SlackAttachment::new()
                    .color("#36a64f")
                    .title(format!("Order #{}", self.order_id))
                    .field("Carrier", &self.carrier, true)
                    .field("Tracking", &self.tracking_number, true)
                    .field("ETA", &self.estimated_delivery, false)
            ))
    }
}

// Usage in controller
let user = User::find(order.user_id).await?;
user.notify(OrderShipped {
    order_id: order.id,
    tracking_number: "1Z999AA10123456784".into(),
    carrier: "UPS".into(),
    estimated_delivery: "January 15, 2026".into(),
}).await?;
}

Environment Variables Reference

VariableDescriptionDefault
MAIL_DRIVERMail transport driversmtp
SMTP (when MAIL_DRIVER=smtp)
MAIL_HOSTSMTP server hostRequired
MAIL_PORTSMTP server port587
MAIL_USERNAMESMTP username-
MAIL_PASSWORDSMTP password-
MAIL_ENCRYPTION"tls" or "none"tls
Resend (when MAIL_DRIVER=resend)
RESEND_API_KEYResend API keyRequired
Shared
MAIL_FROM_ADDRESSDefault from emailRequired
MAIL_FROM_NAMEDefault from name-
SLACK_WEBHOOK_URLSlack incoming webhook-

Best Practices

  1. Use descriptive notification names - OrderShipped not Notification1
  2. Include all needed data - Pass everything the notification needs
  3. Keep notifications focused - One notification per event
  4. Use database for in-app - Combine with UI notification center
  5. Handle failures gracefully - Log errors, don't crash on send failures
  6. Test notifications - Verify each channel works in development

MCP Tools

Use code_templates with the notifications category to generate starter code for new notification classes without looking up the API.

code_templates

Returns ready-to-use code snippets for common notification patterns. Pass category: "notifications" to get templates for mail, database, and Slack channels, along with the Notifiable trait implementation. Useful when scaffolding a new notification type quickly.

WhatsApp Channel

Send transactional notifications via the WhatsApp Cloud API (Meta). The WhatsApp adapter delegates to ferro-whatsapp, which owns its global state via the static WhatsApp::init facade.

Setup

  1. Initialize ferro-whatsapp once at app startup (typically in bootstrap.rs):

    #![allow(unused)]
    fn main() {
    use ferro_whatsapp::{WhatsApp, WhatsAppConfig};
    
    let wa_config = WhatsAppConfig::from_env(Box::new(|phone| {
        // phone-validation hook — your allowlist or regex check
        phone.starts_with("39")
    })).expect("WHATSAPP_* env vars not set");
    WhatsApp::init(wa_config);
    }
  2. Enable the channel in NotificationConfig:

    #![allow(unused)]
    fn main() {
    use ferro::{NotificationConfig, NotificationDispatcher};
    
    let config = NotificationConfig::from_env()    // reads WHATSAPP_ENABLED
        .with_whatsapp_enabled(true);              // or set programmatically
    NotificationDispatcher::configure(config);
    }

    Default is false. When disabled, the dispatcher emits a structured "channel not configured" log and returns Ok(())ferro-whatsapp is never touched, so WhatsApp::init is not required.

  3. Implement Notifiable to provide the recipient phone:

    #![allow(unused)]
    fn main() {
    use ferro::{Notifiable, NotificationChannel};
    
    impl Notifiable for User {
        fn route_notification_for(&self, channel: NotificationChannel) -> Option<String> {
            match channel {
                NotificationChannel::WhatsApp => self.phone.clone(),
                // ... other channels ...
                _ => None,
            }
        }
    }
    }
  4. Implement Notification::to_whatsapp:

    #![allow(unused)]
    fn main() {
    use ferro::{Notification, NotificationChannel, WhatsAppMessage};
    
    impl Notification for OrderShipped {
        fn via(&self) -> Vec<NotificationChannel> {
            vec![NotificationChannel::WhatsApp]
        }
    
        fn to_whatsapp(&self) -> Option<WhatsAppMessage> {
            Some(WhatsAppMessage::text(format!(
                "Your order #{} has shipped — tracking {}",
                self.order_id, self.tracking
            )))
        }
    }
    }

    For approved Meta templates use WhatsAppMessage::template(name, language, parameters).

In-App (SSE) Channel

Real-time in-app notifications dispatch through two legs in sequence:

  1. Persistence — written via your DatabaseNotificationStore implementation
  2. Real-time fanout — published via ferro-broadcast to channel user.{notifiable_id} with event Notification.{notification_type}

If either leg fails the dispatch returns an error — there is no partial-success silent fallback. The persistence-first ordering ensures the broadcaster can replay missed events from the store on client reconnect.

Setup

#![allow(unused)]
fn main() {
use std::sync::Arc;
use ferro::{Broadcaster, InAppConfig, NotificationConfig, NotificationDispatcher};

let broker = Arc::new(Broadcaster::new());
let store: Arc<dyn ferro::DatabaseNotificationStore> = Arc::new(MyDatabaseStore::new());

let config = NotificationConfig::from_env()
    .with_in_app(InAppConfig {
        broker: broker.clone(),
        store: store.clone(),
    })
    .with_database_store(store);    // shared with Channel::Database — same Arc

NotificationDispatcher::configure(config);
}

Authorization note: The InApp adapter publishes to user.{id} channels. You must configure ferro-broadcast's ChannelAuthorizer to enforce who can subscribe to those channels. See Broadcasting for the auth setup.

Implementing Notifications

#![allow(unused)]
fn main() {
use ferro::{InAppMessage, InAppSeverity, Notification, NotificationChannel};

impl Notification for OrderShipped {
    fn via(&self) -> Vec<NotificationChannel> {
        vec![NotificationChannel::InApp]
    }

    fn to_in_app(&self) -> Option<InAppMessage> {
        Some(
            InAppMessage::new("OrderShipped")
                .data(serde_json::json!({
                    "order_id": self.order_id,
                    "tracking": self.tracking,
                }))
                .severity(InAppSeverity::Success),
        )
    }
}
}

Mail Attachments

MailMessage::attachment(filename, content_type, bytes) adds an inline binary attachment. The builder is fallible — it returns Err(Error::AttachmentTooLarge) when bytes.len() exceeds the 25 MB per-attachment cap. Multiple calls accumulate.

#![allow(unused)]
fn main() {
use ferro::MailMessage;

let pdf_bytes: Vec<u8> = std::fs::read("/tmp/invoice.pdf")?;

let mail = MailMessage::new()
    .subject("Your invoice is attached")
    .body("Hi, please find your invoice attached.")
    .attachment("invoice.pdf", "application/pdf", pdf_bytes)?;
}

Attachments work on both mail drivers:

  • SMTP (lettre): ferro-notifications builds a multipart/mixed email with one SinglePart per attachment when attachments is non-empty. When empty, the existing single-part path is used (zero regression for non-attachment emails).
  • Resend (HTTP API): attachments are base64-encoded and sent as attachments: [{ filename, content }] in the JSON payload. Resend has its own 40 MB total-per-email cap which the framework does NOT enforce — Resend surfaces it via API error.

The 25 MB cap is per-attachment and enforced before any allocation past 25 MB:

#![allow(unused)]
fn main() {
let huge: Vec<u8> = vec![0; 30 * 1024 * 1024];
match MailMessage::new().attachment("big.bin", "application/octet-stream", huge) {
    Err(ferro::NotificationError::AttachmentTooLarge { filename, size, limit }) => {
        eprintln!("Rejected '{filename}': {size} bytes exceeds {limit}-byte limit");
    }
    _ => unreachable!(),
}
}

Broadcasting

Ferro provides a WebSocket broadcasting system for real-time communication. Push events to connected clients through public, private, and presence channels. The server handles WebSocket connections at /_ferro/ws with automatic heartbeat, timeout, and subscription management.

Setup

Registering the Broadcaster

In bootstrap.rs, create a Broadcaster and register it as a singleton. The broadcaster manages all connected clients and channel subscriptions.

#![allow(unused)]
fn main() {
use ferro::{Broadcaster, BroadcastConfig, App};

pub async fn register() {
    let broadcaster = Broadcaster::with_config(BroadcastConfig::from_env());
    App::singleton(broadcaster);
}
}

The framework automatically intercepts WebSocket upgrade requests to /_ferro/ws when a Broadcaster is registered. No additional route configuration is needed for the WebSocket endpoint itself.

Manual Configuration

Instead of reading from environment variables, configure the broadcaster directly:

#![allow(unused)]
fn main() {
use ferro::{Broadcaster, BroadcastConfig};
use std::time::Duration;

let config = BroadcastConfig::new()
    .max_subscribers_per_channel(100)
    .max_channels(50)
    .heartbeat_interval(Duration::from_secs(30))
    .client_timeout(Duration::from_secs(60))
    .allow_client_events(true);

let broadcaster = Broadcaster::with_config(config);
}

Channel Types

Channel type is determined by the channel name prefix:

TypePrefixAuthorizationUse Case
PublicnoneNoNews feeds, global notifications
Privateprivate-YesUser-specific data, order updates
Presencepresence-YesOnline status, who's typing
#![allow(unused)]
fn main() {
// Public - anyone can subscribe
"orders"
"notifications"

// Private - requires authorization
"private-orders.123"
"private-user.456"

// Presence - tracks online members
"presence-chat.1"
"presence-room.gaming"
}

Channel Authorization

Private and presence channels require an authorizer. Implement the ChannelAuthorizer trait and attach it to the broadcaster.

Implementing a Channel Authorizer

#![allow(unused)]
fn main() {
use ferro::{AuthData, ChannelAuthorizer};

pub struct AppChannelAuth;

#[async_trait::async_trait]
impl ChannelAuthorizer for AppChannelAuth {
    async fn authorize(&self, data: &AuthData) -> bool {
        // data.socket_id   - client's WebSocket connection ID
        // data.channel     - channel name being requested
        // data.auth_token  - user ID from session auth (set by broadcasting_auth)

        match data.channel.as_str() {
            c if c.starts_with("private-orders.") => {
                let order_id: i64 = c
                    .strip_prefix("private-orders.")
                    .and_then(|s| s.parse().ok())
                    .unwrap_or(0);
                // Check if user owns this order
                check_order_ownership(data.auth_token.as_deref(), order_id).await
            }
            c if c.starts_with("presence-chat.") => {
                // Allow all authenticated users to join chat
                data.auth_token.is_some()
            }
            _ => false,
        }
    }
}
}

Registering the Authorizer

Chain .with_authorizer() when creating the broadcaster:

#![allow(unused)]
fn main() {
let broadcaster = Broadcaster::with_config(BroadcastConfig::from_env())
    .with_authorizer(AppChannelAuth);
App::singleton(broadcaster);
}

Auth Endpoint

Clients connecting to private or presence channels must authenticate through an HTTP endpoint. Ferro provides broadcasting_auth, a handler that bridges session authentication with channel authorization.

Registering the Auth Route

#![allow(unused)]
fn main() {
use ferro::broadcasting_auth;

Route::post("/broadcasting/auth", broadcasting_auth)
    .middleware(SessionAuthMiddleware);
}

The handler:

  1. Verifies the user is authenticated via session (Auth::id())
  2. Receives channel_name and socket_id from the client
  3. Calls Broadcaster::check_auth() with the user's ID as the auth token
  4. Returns 200 with auth confirmation if authorized, 401 if unauthenticated, 403 if unauthorized
  5. For presence channels, includes channel_data with user_id

Private Channel Auth Flow

The full authorization flow for private and presence channels:

  1. Client connects to ws://host/_ferro/ws and receives a socket_id
  2. Client sends HTTP POST to /broadcasting/auth with channel_name and socket_id
  3. Server validates session auth and calls the registered ChannelAuthorizer
  4. If authorized, client receives auth confirmation
  5. Client sends a subscribe message over WebSocket with the auth token
  6. Server subscribes the client to the channel

Broadcasting from Handlers

Fluent Builder API

The Broadcast builder provides a chainable interface for sending events:

#![allow(unused)]
fn main() {
use ferro::{Broadcast, Broadcaster, App};
use std::sync::Arc;

#[handler]
pub async fn update_order(req: Request, id: Path<i32>) -> Response {
    let db = req.db();
    let order = update_order_in_db(db, *id).await?;

    let broadcaster = App::get::<Broadcaster>().ok_or_else(|| HttpResponse::internal_server_error())?;
    let broadcast = Broadcast::new(Arc::new(broadcaster));

    broadcast
        .channel(&format!("orders.{}", id))
        .event("OrderUpdated")
        .data(&order)
        .send()
        .await
        .ok();

    Ok(json!(order))
}
}

Excluding the Sender

When a client triggers an action, exclude them from the broadcast to avoid echo:

#![allow(unused)]
fn main() {
broadcast
    .channel("chat.1")
    .event("NewMessage")
    .data(&message)
    .except(&socket_id)
    .send()
    .await?;
}

Direct Broadcaster API

For simpler cases, call broadcast() or broadcast_except() directly:

#![allow(unused)]
fn main() {
let broadcaster = App::get::<Broadcaster>().expect("Broadcaster not registered");

// Broadcast to all subscribers
broadcaster.broadcast("orders", "OrderCreated", &order).await?;

// Broadcast excluding a specific client
broadcaster
    .broadcast_except("chat.1", "MessageSent", &msg, &sender_socket_id)
    .await?;
}

Client Connection

Clients connect via standard WebSocket to the /_ferro/ws endpoint. All messages use JSON.

JavaScript Client

const ws = new WebSocket('ws://localhost:8080/_ferro/ws');

ws.onopen = () => {
    console.log('Connected');
};

ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);

    switch (msg.type) {
        case 'connected':
            console.log('Socket ID:', msg.socket_id);
            // Subscribe to public channel
            ws.send(JSON.stringify({
                type: 'subscribe',
                channel: 'orders'
            }));
            break;

        case 'subscribed':
            console.log('Subscribed to:', msg.channel);
            break;

        case 'subscription_error':
            console.error('Failed:', msg.channel, msg.error);
            break;

        case 'event':
            console.log('Event:', msg.event, msg.data);
            break;

        case 'member_added':
            console.log('Joined:', msg.user_id, msg.user_info);
            break;

        case 'member_removed':
            console.log('Left:', msg.user_id);
            break;

        case 'pong':
            // Keepalive response
            break;

        case 'error':
            console.error('Error:', msg.message);
            break;
    }
};

Subscribing to Private Channels

Private channels require an auth token. The client first authenticates via the HTTP endpoint, then includes the token in the subscribe message:

async function subscribePrivate(ws, socketId, channel) {
    // Step 1: Get auth token from server
    const res = await fetch('/broadcasting/auth', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include', // Send session cookie
        body: JSON.stringify({
            channel_name: channel,
            socket_id: socketId
        })
    });

    if (!res.ok) {
        throw new Error('Authorization failed');
    }

    const data = await res.json();

    // Step 2: Subscribe with auth token
    ws.send(JSON.stringify({
        type: 'subscribe',
        channel: channel,
        auth: data.auth
    }));
}

Whisper (Client Events)

Whisper messages are client-to-client events forwarded through the server. They are useful for ephemeral state like typing indicators and cursor positions. The sender is excluded from receiving the message.

// Send a whisper
ws.send(JSON.stringify({
    type: 'whisper',
    channel: 'private-chat.1',
    event: 'typing',
    data: { name: 'Alice' }
}));

Whisper requires allow_client_events: true in configuration (enabled by default) and the sender must be subscribed to the channel.

Message Protocol

Client to Server

All client messages are JSON with a type field:

// Subscribe to a channel
{"type": "subscribe", "channel": "orders"}

// Subscribe to a private channel with auth
{"type": "subscribe", "channel": "private-orders.1", "auth": "token"}

// Unsubscribe
{"type": "unsubscribe", "channel": "orders"}

// Whisper (client event)
{"type": "whisper", "channel": "chat", "event": "typing", "data": {"name": "Alice"}}

// Keepalive ping
{"type": "ping"}

Server to Client

// Connection established
{"type": "connected", "socket_id": "uuid-v4-here"}

// Subscription confirmed
{"type": "subscribed", "channel": "orders"}

// Subscription failed
{"type": "subscription_error", "channel": "private-secret", "error": "Authorization required"}

// Unsubscribed
{"type": "unsubscribed", "channel": "orders"}

// Broadcast event
{"type": "event", "event": "OrderUpdated", "channel": "orders", "data": {"id": 1}}

// Presence member joined
{"type": "member_added", "channel": "presence-chat.1", "user_id": "42", "user_info": {"name": "Alice"}}

// Presence member left
{"type": "member_removed", "channel": "presence-chat.1", "user_id": "42"}

// Keepalive response
{"type": "pong"}

// Error
{"type": "error", "message": "Invalid message format"}

Presence Channels

Presence channels extend private channels with member tracking. When users join or leave, events are automatically broadcast to all channel members.

Subscribing with Member Info

On the server side, presence subscriptions include member metadata:

#![allow(unused)]
fn main() {
use ferro::PresenceMember;

let member = PresenceMember::new(socket_id, user_id)
    .with_info(serde_json::json!({
        "name": user.name,
        "avatar": user.avatar_url,
    }));

broadcaster
    .subscribe(&socket_id, "presence-chat.1", Some(&auth_token), Some(member))
    .await?;
}

Member Events

The server automatically broadcasts when members join or leave:

  • member_added -- includes user_id and user_info
  • member_removed -- includes user_id

Querying Channel Members

#![allow(unused)]
fn main() {
if let Some(channel) = broadcaster.get_channel("presence-chat.1") {
    for member in channel.get_members() {
        println!("User {} is online", member.user_id);
    }
}
}

Configuration

Environment Variables

VariableDescriptionDefault
BROADCAST_MAX_SUBSCRIBERSMax subscribers per channel (0 = unlimited)0
BROADCAST_MAX_CHANNELSMax total channels (0 = unlimited)0
BROADCAST_HEARTBEAT_INTERVALHeartbeat interval in seconds30
BROADCAST_CLIENT_TIMEOUTClient timeout in seconds (disconnect if no activity)60
BROADCAST_ALLOW_CLIENT_EVENTSAllow whisper messages (true/false)true

Connection Management

The WebSocket connection handler runs a tokio::select! loop that manages:

  • Incoming frames -- client messages dispatched to the broadcaster
  • Server messages -- broadcast events forwarded to the client
  • Heartbeat -- periodic ping/pong to detect stale connections

Clients that exceed BROADCAST_CLIENT_TIMEOUT without activity are disconnected. The server sends Close frames on clean shutdown and removes the client from all subscribed channels.

Monitoring

#![allow(unused)]
fn main() {
let broadcaster = App::get::<Broadcaster>().expect("Broadcaster not registered");

// Connection stats
let clients = broadcaster.client_count();
let channels = broadcaster.channel_count();

// Channel details
if let Some(channel) = broadcaster.get_channel("orders") {
    println!("{} subscribers", channel.subscriber_count());
}

# MCP Tools

Use `list_broadcast_channels` to inspect active WebSocket channels at runtime.

## `list_broadcast_channels`

Returns all currently active broadcast channels with subscriber count, channel type (public, private, presence), and connected client IDs. Use this to verify that clients have subscribed to the expected channels or to diagnose subscription failures.
}

Storage

Ferro provides a unified file storage abstraction inspired by Laravel's filesystem. Work with local files, memory storage, and cloud providers through a consistent API.

Configuration

Environment Variables

Configure storage in your .env file:

# Default disk (local, public, or s3)
FILESYSTEM_DISK=local

# Local disk settings
FILESYSTEM_LOCAL_ROOT=./storage
FILESYSTEM_LOCAL_URL=

# Public disk settings (for web-accessible files)
FILESYSTEM_PUBLIC_ROOT=./storage/public
FILESYSTEM_PUBLIC_URL=/storage

# S3 disk settings (requires s3 feature)
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket
AWS_URL=https://your-bucket.s3.amazonaws.com

Bootstrap Setup

In src/bootstrap.rs, configure storage:

#![allow(unused)]
fn main() {
use ferro::{App, Storage, StorageConfig};
use std::sync::Arc;

pub async fn register() {
    // ... other setup ...

    // Create storage with environment config
    let config = StorageConfig::from_env();
    let storage = Arc::new(Storage::with_storage_config(config));

    // Store in app state for handlers to access
    App::set_storage(storage);
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::{Storage, StorageConfig, DiskConfig};

let config = StorageConfig::new("local")
    .disk("local", DiskConfig::local("./storage"))
    .disk("public", DiskConfig::local("./storage/public").with_url("/storage"))
    .disk("uploads", DiskConfig::local("./uploads").with_url("/uploads"));

let storage = Storage::with_storage_config(config);
}

Basic Usage

Storing Files

#![allow(unused)]
fn main() {
use ferro::Storage;

// Store string content
storage.put("documents/report.txt", "Report content").await?;

// Store bytes
storage.put("images/photo.jpg", image_bytes).await?;

// Store with visibility options
use ferro::PutOptions;

storage.put_with_options(
    "private/secret.txt",
    "secret content",
    PutOptions::new().visibility(Visibility::Private),
).await?;
}

Retrieving Files

#![allow(unused)]
fn main() {
// Get as bytes
let contents = storage.get("documents/report.txt").await?;

// Get as string
let text = storage.get_string("documents/report.txt").await?;

// Check if file exists
if storage.exists("documents/report.txt").await? {
    println!("File exists!");
}
}

Deleting Files

#![allow(unused)]
fn main() {
// Delete a single file
storage.delete("temp/cache.txt").await?;

// Delete a directory and all contents
storage.disk("local")?.delete_directory("temp").await?;
}

Copying and Moving

#![allow(unused)]
fn main() {
// Copy a file
storage.copy("original.txt", "backup/original.txt").await?;

// Move/rename a file
storage.rename("old-name.txt", "new-name.txt").await?;
}

Multiple Disks

Switching Disks

#![allow(unused)]
fn main() {
// Use the default disk
storage.put("file.txt", "content").await?;

// Use a specific disk
let public_disk = storage.disk("public")?;
public_disk.put("images/logo.png", logo_bytes).await?;

// Get file from specific disk
let file = storage.disk("uploads")?.get("user-upload.pdf").await?;
}

Disk Configuration

Each disk is configured independently:

#![allow(unused)]
fn main() {
use ferro::{StorageConfig, DiskConfig};

let config = StorageConfig::new("local")
    // Main storage disk
    .disk("local", DiskConfig::local("./storage/app"))
    // Publicly accessible files
    .disk("public", DiskConfig::local("./storage/public").with_url("/storage"))
    // Temporary files
    .disk("temp", DiskConfig::local("/tmp/app"))
    // Memory disk for testing
    .disk("testing", DiskConfig::memory());
}

File URLs

Public URLs

#![allow(unused)]
fn main() {
// Get the public URL for a file
let url = storage.disk("public")?.url("images/logo.png").await?;
// Returns: /storage/images/logo.png

// With a custom URL base
let config = DiskConfig::local("./uploads")
    .with_url("https://cdn.example.com/uploads");
// url() returns: https://cdn.example.com/uploads/images/logo.png
}

Temporary URLs

For files that need time-limited access:

#![allow(unused)]
fn main() {
use std::time::Duration;

// Get a temporary URL (useful for S3 presigned URLs)
let disk = storage.disk("s3")?;
let temp_url = disk.temporary_url(
    "private/document.pdf",
    Duration::from_secs(3600), // 1 hour
).await?;
}

File Information

Metadata

#![allow(unused)]
fn main() {
let disk = storage.disk("local")?;

// Get file size
let size = disk.size("document.pdf").await?;
println!("File size: {} bytes", size);

// Get full metadata
let metadata = disk.metadata("document.pdf").await?;
println!("Path: {}", metadata.path);
println!("Size: {}", metadata.size);
println!("MIME type: {:?}", metadata.mime_type);
println!("Last modified: {:?}", metadata.last_modified);
}

Visibility

#![allow(unused)]
fn main() {
use ferro::{PutOptions, Visibility};

// Store with private visibility
storage.put_with_options(
    "private/data.json",
    json_data,
    PutOptions::new().visibility(Visibility::Private),
).await?;

// Store with public visibility
storage.put_with_options(
    "public/image.jpg",
    image_data,
    PutOptions::new().visibility(Visibility::Public),
).await?;
}

Directory Operations

Listing Files

#![allow(unused)]
fn main() {
let disk = storage.disk("local")?;

// List files in a directory (non-recursive)
let files = disk.files("documents").await?;
for file in files {
    println!("File: {}", file);
}

// List all files recursively
let all_files = disk.all_files("documents").await?;
for file in all_files {
    println!("File: {}", file);
}

// List directories
let dirs = disk.directories("documents").await?;
for dir in dirs {
    println!("Directory: {}", dir);
}
}

Creating Directories

#![allow(unused)]
fn main() {
let disk = storage.disk("local")?;

// Create a directory
disk.make_directory("uploads/2024/01").await?;

// Delete a directory and contents
disk.delete_directory("temp").await?;
}

Available Drivers

Local Driver

Stores files on the local filesystem:

#![allow(unused)]
fn main() {
let config = DiskConfig::local("./storage")
    .with_url("https://example.com/storage");
}

Memory Driver

Stores files in memory (useful for testing):

#![allow(unused)]
fn main() {
let config = DiskConfig::memory()
    .with_url("https://cdn.example.com");
}

S3 Driver

Enable the s3 feature:

[dependencies]
ferro = { version = "0.1", features = ["s3"] }

Example: File Upload Handler

#![allow(unused)]
fn main() {
use ferro::{Request, Response, Storage};
use std::sync::Arc;

async fn upload_file(
    request: Request,
    storage: Arc<Storage>,
) -> Response {
    // Get uploaded file from multipart form
    let file = request.file("document")?;

    // Generate unique filename
    let filename = format!(
        "uploads/{}/{}",
        chrono::Utc::now().format("%Y/%m/%d"),
        file.name()
    );

    // Store the file
    storage.disk("public")?
        .put(&filename, file.bytes())
        .await?;

    // Get the public URL
    let url = storage.disk("public")?
        .url(&filename)
        .await?;

    Response::json(&serde_json::json!({
        "success": true,
        "url": url,
    }))
}
}

Example: Avatar Upload with Validation

#![allow(unused)]
fn main() {
use ferro::{Request, Response, Storage, PutOptions, Visibility};
use std::sync::Arc;

async fn upload_avatar(
    request: Request,
    storage: Arc<Storage>,
    user_id: i64,
) -> Response {
    let file = request.file("avatar")?;

    // Validate file type
    let allowed_types = ["image/jpeg", "image/png", "image/webp"];
    if !allowed_types.contains(&file.content_type()) {
        return Response::bad_request("Invalid file type");
    }

    // Validate file size (max 5MB)
    if file.size() > 5 * 1024 * 1024 {
        return Response::bad_request("File too large");
    }

    // Delete old avatar if exists
    let old_path = format!("avatars/{}.jpg", user_id);
    if storage.exists(&old_path).await? {
        storage.delete(&old_path).await?;
    }

    // Store new avatar
    let path = format!("avatars/{}.{}", user_id, file.extension());
    storage.disk("public")?
        .put_with_options(
            &path,
            file.bytes(),
            PutOptions::new().visibility(Visibility::Public),
        )
        .await?;

    let url = storage.disk("public")?.url(&path).await?;

    Response::json(&serde_json::json!({
        "avatar_url": url,
    }))
}
}

CDN

CDN Edge URLs

Configure a CDN base URL for a disk to serve stored files from an edge node rather than the storage origin. The primary configuration surface is the provider-agnostic quartet:

# Provider-agnostic CDN quartet (preferred)
# CDN_PROVIDER: none | digitalocean | bunny | cloudflare
CDN_URL=https://cdn.example.com
CDN_PROVIDER=digitalocean
CDN_PURGE_TOKEN=your-api-token
CDN_PURGE_ZONE=your-cdn-endpoint-id

CDN_URL drives Disk::cdn_url(). CDN_PROVIDER selects the purge adapter; CDN_PROVIDER=none makes purge() a logged no-op. CDN_URL (display) and CDN_PROVIDER (purge) are independent — a deployment can serve assets through a CDN URL with no purge provider configured.

Deprecated fallbacks (one release window): the following legacy env vars are still read as fallbacks and emit a tracing::warn! deprecation notice on use. Migrate to the quartet.

Deprecated varReplacement
AWS_CDN_URLCDN_URL
BUNNY_CDN_URLCDN_URL + CDN_PROVIDER=bunny
CF_CDN_URLCDN_URL + CDN_PROVIDER=cloudflare
DO_SPACES_CDN_IDCDN_PURGE_ZONE
CF_ZONE_IDCDN_PURGE_ZONE
DIGITALOCEAN_ACCESS_TOKENCDN_PURGE_TOKEN
CF_API_TOKENCDN_PURGE_TOKEN
BUNNY_ACCESS_KEYCDN_PURGE_TOKEN

Or set it programmatically:

#![allow(unused)]
fn main() {
use ferro_storage::DiskConfig;

let config = DiskConfig::local("./storage/public")
    .with_url("https://origin.example.com/storage")
    .with_cdn_url("https://cdn.example.com/storage");
}

Disk::cdn_url(path) and Storage::cdn_url(path) return the CDN edge URL when a CDN base is configured, or fall back to the origin url() otherwise:

#![allow(unused)]
fn main() {
// With CDN configured — returns "https://cdn.example.com/storage/images/logo.png"
let url = storage.disk("public")?.cdn_url("images/logo.png").await?;

// Without CDN configured — falls back to origin URL
let url = storage.disk("local")?.cdn_url("images/logo.png").await?;
}

The CDN URL computation is pure string composition at the facade layer — no network call is made. Double slashes are normalized: a trailing slash on the base and a leading slash on the path produce a single slash in the result.

Cache Invalidation

The PurgeApi trait abstracts CDN cache invalidation across providers:

#![allow(unused)]
fn main() {
use ferro_storage::PurgeApi;

#[async_trait]
pub trait PurgeApi: Send + Sync {
    async fn purge(&self, paths: &[String]) -> Result<(), Error>;
}
}

Paths are relative (e.g. "index.html", "assets/*"). Implementations handle batching, rate limiting, and full-URL construction internally.

DigitalOcean Spaces CDN Adapter

The DoSpacesCdn adapter is the default, batteries-included implementation. It calls the DigitalOcean CDN purge API (DELETE /v2/cdn/endpoints/{id}/cache) and encapsulates:

  • Batching: at most 50 file paths per request (the DO API limit).
  • Rate limiting: an internal sliding-window throttle enforces at most 5 requests per 10-second window.
  • Wildcard paths: "assets/*" counts as one file slot, not an expanded set.
  • Missing endpoint id: when CDN_PURGE_ZONE is unset, purge() is a logged no-op that returns Ok(()). Applications without a CDN endpoint continue to work without error.
CDN_PROVIDER=digitalocean
CDN_PURGE_ZONE=your-cdn-endpoint-id
CDN_PURGE_TOKEN=your-do-api-token
#![allow(unused)]
fn main() {
use ferro_storage::{DoSpacesCdn, DoSpacesCdnConfig, PurgeApi};

let purger = DoSpacesCdn::new(DoSpacesCdnConfig::from_env());

// Purge a set of paths after a deployment promote
purger.purge(&[
    "index.html".to_string(),
    "de/index.html".to_string(),
]).await?;
}

The DIGITALOCEAN_ACCESS_TOKEN is never written to logs. DoSpacesCdnConfig implements a hand-written Debug that prints <redacted> for the token field.

Feature-gated Adapters

Bunny CDN and Cloudflare CDN adapters are available behind optional cargo features. They are not compiled into the default dependency graph:

[dependencies]
ferro-storage = { version = "0.2", features = ["cdn-bunny"] }
# or
ferro-storage = { version = "0.2", features = ["cdn-cloudflare"] }

Bunny CDN (cdn-bunny): calls POST https://api.bunny.net/purge?url={full_url}&async=false per path with an AccessKey header. Set CDN_PROVIDER=bunny, CDN_URL, and CDN_PURGE_TOKEN (the Bunny access key).

#![allow(unused)]
fn main() {
use ferro_storage::{BunnyCdn, BunnyCdnConfig, PurgeApi};

let purger = BunnyCdn::new(BunnyCdnConfig::from_env());
purger.purge(&["index.html".to_string()]).await?;
}

Cloudflare CDN (cdn-cloudflare): calls POST /zones/{zone_id}/purge_cache with {"files": [...full_urls...]} and Bearer auth. Set CDN_PROVIDER=cloudflare, CDN_URL, CDN_PURGE_TOKEN (the CF API token), and CDN_PURGE_ZONE (the CF zone id).

#![allow(unused)]
fn main() {
use ferro_storage::{CloudflareCdn, CloudflareCdnConfig, PurgeApi};

let purger = CloudflareCdn::new(CloudflareCdnConfig::from_env());
purger.purge(&["index.html".to_string()]).await?;
}

Promote → Purge Sequence

The standard deployment workflow is a two-call sequence: promote the new deployment, then purge the affected HTML keys:

#![allow(unused)]
fn main() {
use ferro_storage::{DoSpacesCdn, DoSpacesCdnConfig, PurgeApi};

// After ferro_deployments::promote(...)
let purger = DoSpacesCdn::new(DoSpacesCdnConfig::from_env());
purger.purge(&html_keys).await?;
}

Purge policy: purge only the non-hashed HTML keys after a promote. Content-hashed asset URLs (e.g. app.a1b2c3d4.js) are immutable — their content never changes at the same URL, so purging them is unnecessary and consumes rate-limit budget. Purging * invalidates the entire CDN cache; reserve that for deliberate full-cache invalidation.

Environment Variables Reference

VariableDescriptionDefault
FILESYSTEM_DISKDefault disk namelocal
FILESYSTEM_LOCAL_ROOTLocal disk root path./storage
FILESYSTEM_LOCAL_URLLocal disk URL base-
FILESYSTEM_PUBLIC_ROOTPublic disk root path./storage/public
FILESYSTEM_PUBLIC_URLPublic disk URL base/storage
AWS_ACCESS_KEY_IDS3 access key-
AWS_SECRET_ACCESS_KEYS3 secret key-
AWS_DEFAULT_REGIONS3 regionus-east-1
AWS_BUCKETS3 bucket name-
AWS_URLS3 URL base-

Best Practices

  1. Use meaningful disk names - public, uploads, backups instead of disk1
  2. Set appropriate visibility - Use private for sensitive files
  3. Organize files by date - uploads/2024/01/file.pdf prevents directory bloat
  4. Use the public disk for web assets - Images, CSS, JS that need URLs
  5. Use memory driver for tests - Fast and isolated testing
  6. Clean up temporary files - Delete files that are no longer needed
  7. Validate uploads - Check file types and sizes before storing

MCP Tools

Use code_templates with the storage category to generate file upload and storage handler patterns.

code_templates

Returns ready-to-use code snippets for file upload handling, including multipart parsing, extension validation, and disk selection. Pass category: "storage" to get templates for single-file upload, avatar upload with validation, and temporary URL generation.

Caching

Ferro provides a unified caching API with support for multiple backends, cache tags for bulk invalidation, and the convenient "remember" pattern for lazy caching.

Configuration

Environment Variables

Configure caching in your .env file:

# Cache driver (memory or redis)
CACHE_DRIVER=memory

# Key prefix for all cache entries
CACHE_PREFIX=myapp

# Default TTL in seconds
CACHE_TTL=3600

# Memory store capacity (max entries)
CACHE_MEMORY_CAPACITY=10000

# Redis URL (required if CACHE_DRIVER=redis)
REDIS_URL=redis://127.0.0.1:6379

Bootstrap Setup

In src/bootstrap.rs, configure caching:

#![allow(unused)]
fn main() {
use ferro::{App, Cache};
use std::sync::Arc;

pub async fn register() {
    // ... other setup ...

    // Create cache from environment variables
    let cache = Arc::new(Cache::from_env().await?);

    // Store in app state for handlers to access
    App::set_cache(cache);
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::{Cache, CacheConfig};
use std::time::Duration;

// In-memory cache with custom config
let config = CacheConfig::new()
    .with_ttl(Duration::from_secs(1800))
    .with_prefix("myapp");

let cache = Cache::memory().with_config(config);

// Redis cache
let cache = Cache::redis("redis://127.0.0.1:6379").await?;
}

Basic Usage

Storing Values

#![allow(unused)]
fn main() {
use std::time::Duration;

// Store a value with specific TTL
cache.put("user:1", &user, Duration::from_secs(3600)).await?;

// Store with default TTL
cache.put_default("user:1", &user).await?;

// Store forever (10 years TTL)
cache.forever("config:settings", &settings).await?;
}

Retrieving Values

#![allow(unused)]
fn main() {
// Get a value
let user: Option<User> = cache.get("user:1").await?;

if let Some(user) = user {
    println!("Found user: {}", user.name);
}

// Check if key exists
if cache.has("user:1").await? {
    println!("User is cached");
}
}

Removing Values

#![allow(unused)]
fn main() {
// Remove a single key
cache.forget("user:1").await?;

// Remove all cached values
cache.flush().await?;
}

Pull (Get and Remove)

#![allow(unused)]
fn main() {
// Get value and remove it from cache
let session: Option<Session> = cache.pull("session:abc123").await?;
}

Remember Pattern

The remember pattern retrieves a cached value or computes and caches it if missing:

#![allow(unused)]
fn main() {
use std::time::Duration;

// Get from cache or compute if missing
let users = cache.remember("users:active", Duration::from_secs(3600), || async {
    // This only runs if "users:active" is not in cache
    User::where_active().all().await
}).await?;

// Remember forever
let config = cache.remember_forever("app:config", || async {
    load_config_from_database().await
}).await?;
}

This pattern is excellent for:

  • Database query results
  • API responses
  • Expensive computations
  • Configuration that rarely changes

Cache Tags

Tags allow you to group related cache entries for bulk invalidation.

Storing with Tags

#![allow(unused)]
fn main() {
use std::time::Duration;

// Store with a single tag
cache.tags(&["users"])
    .put("user:1", &user, Duration::from_secs(3600))
    .await?;

// Store with multiple tags
cache.tags(&["users", "admins"])
    .put("admin:1", &admin, Duration::from_secs(3600))
    .await?;

// Remember with tags
let user = cache.tags(&["users"])
    .remember("user:1", Duration::from_secs(3600), || async {
        User::find(1).await
    })
    .await?;
}

Flushing Tags

#![allow(unused)]
fn main() {
// Flush all entries tagged with "users"
cache.tags(&["users"]).flush().await?;

// This removes:
// - "user:1" (tagged with ["users"])
// - "admin:1" (tagged with ["users", "admins"])
}

Tag Use Cases

#![allow(unused)]
fn main() {
// Cache user data
cache.tags(&["users", &format!("user:{}", user.id)])
    .put(&format!("user:{}", user.id), &user, ttl)
    .await?;

// Cache user's posts
cache.tags(&["posts", &format!("user:{}:posts", user.id)])
    .put(&format!("user:{}:posts", user.id), &posts, ttl)
    .await?;

// When user is updated, flush their cache
cache.tags(&[&format!("user:{}", user.id)]).flush().await?;

// When any user data changes, flush all user cache
cache.tags(&["users"]).flush().await?;
}

Atomic Operations

Increment and Decrement

#![allow(unused)]
fn main() {
// Increment a counter
let views = cache.increment("page:views", 1).await?;
println!("Page has {} views", views);

// Increment by more than 1
let score = cache.increment("player:score", 100).await?;

// Decrement
let stock = cache.decrement("product:stock", 1).await?;
}

Cache Backends

Memory Store

Fast in-memory caching backed by moka. Best for:

  • Single-server deployments
  • Development/testing
  • Non-critical cache data
#![allow(unused)]
fn main() {
// Default capacity (10,000 entries)
let cache = Cache::memory();

// Custom capacity
let store = MemoryStore::with_capacity(50_000);
let cache = Cache::new(Arc::new(store));
}

The memory store is bounded: when capacity is reached, least-recently-used entries are evicted automatically. Each entry respects its own TTL — expired entries are never returned and are cleaned up proactively by the cache engine. Counters (increment/decrement) share the same capacity bound.

Redis Store

Distributed caching with Redis. Best for:

  • Multi-server deployments
  • Persistent cache (survives restarts)
  • Shared cache across services
#![allow(unused)]
fn main() {
let cache = Cache::redis("redis://127.0.0.1:6379").await?;

// With authentication
let cache = Cache::redis("redis://:password@127.0.0.1:6379").await?;

// With database selection
let cache = Cache::redis("redis://127.0.0.1:6379/2").await?;
}

Enable the Redis backend in Cargo.toml:

[dependencies]
ferro = { version = "0.1", features = ["redis-backend"] }

Example: API Response Caching

#![allow(unused)]
fn main() {
use ferro::{Request, Response, Cache};
use std::sync::Arc;
use std::time::Duration;

async fn get_products(
    request: Request,
    cache: Arc<Cache>,
) -> Response {
    let category = request.param("category")?;

    // Cache key based on category
    let cache_key = format!("products:category:{}", category);

    // Get from cache or fetch from database
    let products = cache.remember(&cache_key, Duration::from_secs(300), || async {
        Product::where_category(&category).all().await
    }).await?;

    Response::json(&products)
}
}

Example: User Session Caching

#![allow(unused)]
fn main() {
use ferro::Cache;
use std::sync::Arc;
use std::time::Duration;

async fn cache_user_session(
    cache: Arc<Cache>,
    user_id: i64,
    session: &UserSession,
) -> Result<(), Error> {
    // Cache with user-specific tag for easy invalidation
    cache.tags(&["sessions", &format!("user:{}", user_id)])
        .put(
            &format!("session:{}", session.id),
            session,
            Duration::from_secs(86400), // 24 hours
        )
        .await
}

async fn invalidate_user_sessions(
    cache: Arc<Cache>,
    user_id: i64,
) -> Result<(), Error> {
    // Flush all sessions for this user
    cache.tags(&[&format!("user:{}", user_id)]).flush().await
}
}

Example: Rate Limiting with Cache

#![allow(unused)]
fn main() {
use ferro::Cache;
use std::sync::Arc;

async fn check_rate_limit(
    cache: Arc<Cache>,
    user_id: i64,
    limit: i64,
) -> Result<bool, Error> {
    let key = format!("rate_limit:user:{}", user_id);

    // Increment the counter
    let count = cache.increment(&key, 1).await?;

    // Set TTL on first request (1 minute window)
    if count == 1 {
        // Note: For production, use Redis SETEX or similar
        // This is a simplified example
    }

    Ok(count <= limit)
}
}

Environment Variables Reference

VariableDescriptionDefault
CACHE_DRIVERCache backend ("memory" or "redis")memory
CACHE_PREFIXKey prefix for all entries-
CACHE_TTLDefault TTL in seconds3600
CACHE_MEMORY_CAPACITYMax entries for memory store10000
REDIS_URLRedis connection URLredis://127.0.0.1:6379

Best Practices

  1. Use meaningful cache keys - user:123:profile not key1
  2. Set appropriate TTLs - Balance freshness vs performance
  3. Use tags for related data - Makes invalidation easier
  4. Cache at the right level - Cache complete objects, not fragments
  5. Handle cache misses gracefully - Always have a fallback
  6. Use remember pattern - Cleaner code, less boilerplate
  7. Prefix keys in production - Avoid collisions between environments
  8. Monitor cache hit rates - Identify optimization opportunities

MCP Tools

Use cache_inspect to examine live cache state without writing debug code.

cache_inspect

Returns cached keys matching an optional prefix filter, along with their TTL, size, and tags. Use this to verify that values are being cached correctly, diagnose cache miss rates, or inspect which keys are tagged for bulk invalidation.

Authentication

Ferro provides session-based authentication with an Auth facade, Authenticatable trait, UserProvider interface, password hashing (bcrypt), and route protection middleware.

Quick Start

Scaffold a complete auth system with one command:

ferro make:auth

This generates:

  • User migration with email and password fields
  • Authenticatable implementation on your User model
  • DatabaseUserProvider for user retrieval
  • AuthController with register, login, and logout handlers
  • Routes with auth/guest middleware

Configuration

Session

Authentication state is stored in server-side sessions. Configure via environment variables:

VariableDefaultDescription
SESSION_LIFETIME120Idle timeout in minutes (expires after inactivity)
SESSION_ABSOLUTE_LIFETIME43200Absolute timeout in minutes (30 days; expires regardless of activity)
SESSION_COOKIEferro_sessionCookie name
SESSION_SECUREtrueHTTPS-only cookies
SESSION_PATH/Cookie path
SESSION_SAME_SITELaxSameSite attribute (Strict, Lax, None)

Load configuration from environment:

#![allow(unused)]
fn main() {
use ferro::SessionConfig;

let config = SessionConfig::from_env();
}

Session Expiry

Ferro enforces two independent session timeouts per OWASP recommendations:

  • Idle timeout (SESSION_LIFETIME): Sessions expire after a period of inactivity. Default: 2 hours.
  • Absolute timeout (SESSION_ABSOLUTE_LIFETIME): Sessions expire after a fixed duration regardless of activity. Default: 30 days. Prevents stolen sessions from being kept alive indefinitely.

Both timeouts are enforced server-side. The session cookie's Max-Age is set to the longer of the two values.

For high-security applications, OWASP recommends shorter values:

Security LevelIdle TimeoutAbsolute Timeout
Standard web app30-60 min4-8 hours
Financial/medical5-15 min1-2 hours
Framework default120 min30 days

Session Invalidation

Destroy sessions for security-sensitive operations like password changes:

#![allow(unused)]
fn main() {
// Logout all other devices (keeps current session)
if let Some(result) = Auth::logout_other_devices().await {
    let destroyed_count = result?;
}

// Logout and invalidate current session
Auth::logout_and_invalidate();
}

Use logout_other_devices() after password changes to invalidate potentially compromised sessions on other devices.

For admin/security flows that need to invalidate sessions for any user:

#![allow(unused)]
fn main() {
use ferro::{invalidate_all_for_user, DatabaseSessionDriver};

let store = DatabaseSessionDriver::new(idle_lifetime, absolute_lifetime);
let destroyed = invalidate_all_for_user(&store, user_id, None).await?;
}

Password Hashing

Ferro uses bcrypt with a default cost factor of 12 (same as Laravel). The cost factor is not configurable via environment variables -- change it by calling hashing::hash_with_cost() directly.

The Authenticatable Trait

Implement Authenticatable on your User model to enable Auth::user() and Auth::user_as::<T>().

#![allow(unused)]
fn main() {
use ferro::Authenticatable;
use std::any::Any;

impl Authenticatable for User {
    fn auth_identifier(&self) -> i64 {
        self.id as i64
    }

    fn as_any(&self) -> &dyn Any {
        self
    }
}
}

Methods

MethodDefaultDescription
auth_identifier(&self) -> i64RequiredReturns the user's unique ID (primary key)
auth_identifier_name(&self) -> &'static str"id"Column name for the identifier
as_any(&self) -> &dyn AnyRequiredEnables downcasting via Auth::user_as::<T>()

User Provider

The UserProvider trait retrieves users from your data store. Register it in bootstrap.rs to enable Auth::user().

#![allow(unused)]
fn main() {
use ferro::{UserProvider, Authenticatable, FrameworkError};
use async_trait::async_trait;
use std::sync::Arc;

pub struct DatabaseUserProvider;

#[async_trait]
impl UserProvider for DatabaseUserProvider {
    async fn retrieve_by_id(
        &self,
        id: i64,
    ) -> Result<Option<Arc<dyn Authenticatable>>, FrameworkError> {
        let user = User::query()
            .filter(Column::Id.eq(id as i32))
            .first()
            .await?;
        Ok(user.map(|u| Arc::new(u) as Arc<dyn Authenticatable>))
    }
}
}

Methods

MethodRequiredDescription
retrieve_by_id(id)YesLoad user by primary key
retrieve_by_credentials(credentials)NoLoad user by arbitrary credentials (e.g., email lookup)
validate_credentials(user, credentials)NoCheck credentials against a user (e.g., password verification)

Registration

Register the provider in bootstrap.rs:

#![allow(unused)]
fn main() {
bind!(dyn UserProvider, DatabaseUserProvider);
}

Auth Facade

The Auth struct provides static methods for all authentication operations.

Checking Authentication State

#![allow(unused)]
fn main() {
use ferro::Auth;

// Check if a user is authenticated
if Auth::check() {
    // ...
}

// Check if the user is a guest
if Auth::guest() {
    // ...
}

// Get the authenticated user's ID
if let Some(user_id) = Auth::id() {
    println!("User ID: {}", user_id);
}

// Get the ID as a different type (e.g., i32 for SeaORM)
let user_id: i32 = Auth::id_as().expect("must be authenticated");
}

Logging In

#![allow(unused)]
fn main() {
// Log in by user ID
// Regenerates session ID (prevents session fixation) and CSRF token
Auth::login(user_id);

// Log in with "remember me"
Auth::login_remember(user_id, &remember_token);
}

Logging Out

#![allow(unused)]
fn main() {
// Clear authentication state, regenerate CSRF token
Auth::logout();

// Destroy entire session (logout everywhere)
Auth::logout_and_invalidate();
}

Credential Validation

#![allow(unused)]
fn main() {
// Attempt authentication: validates credentials, logs in on success
let result = Auth::attempt(|| async {
    let user = find_user_by_email(&email).await?;
    match user {
        Some(u) if ferro::verify(&password, &u.password_hash)? => Ok(Some(u.id as i64)),
        _ => Ok(None),
    }
}).await?;

if result.is_some() {
    // Authenticated and logged in
}

// Validate credentials without logging in (e.g., password confirmation)
let valid = Auth::validate(|| async {
    let user_id = Auth::id().expect("called inside Auth::validate so user is authenticated");
    let user = find_user_by_id(user_id).await?;
    Ok(ferro::verify(&password, &user.password_hash)?)
}).await?;
}

Retrieving the Current User

#![allow(unused)]
fn main() {
// Get as trait object
if let Some(user) = Auth::user().await? {
    println!("User #{}", user.auth_identifier());
}

// Get as concrete type (requires Authenticatable + Clone)
if let Some(user) = Auth::user_as::<User>().await? {
    println!("Welcome, {}!", user.name);
}
}

Method Reference

MethodReturnsDescription
Auth::check()booltrue if authenticated
Auth::guest()booltrue if not authenticated
Auth::id()Option<i64>Authenticated user's ID
Auth::id_as::<T>()Option<T>ID converted to type T
Auth::login(id)()Log in, regenerate session + CSRF
Auth::login_remember(id, token)()Log in with remember-me
Auth::logout()()Clear auth state, regenerate CSRF
Auth::logout_and_invalidate()()Destroy entire session
Auth::logout_other_devices()Option<Result<u64>>Destroy all sessions except current
Auth::attempt(validator)Result<Option<i64>>Validate + auto-login
Auth::validate(validator)Result<bool>Validate without login
Auth::user()Result<Option<Arc<dyn Authenticatable>>>Current user (trait object)
Auth::user_as::<T>()Result<Option<T>>Current user (concrete type)

Handler Extractors

For ergonomic access to the current user in handler signatures, Ferro provides typed extractors that work as handler parameters.

AuthUser<T>

Injects the authenticated user directly into the handler. Returns 401 Unauthenticated if no user is logged in.

#![allow(unused)]
fn main() {
use ferro::{handler, AuthUser, Response, HttpResponse};
use crate::models::users;

#[handler]
pub async fn profile(user: AuthUser<users::Model>) -> Response {
    Ok(HttpResponse::json(serde_json::json!({
        "id": user.id,
        "name": user.name,
        "email": user.email
    })))
}
}

OptionalUser<T>

Same as AuthUser<T>, but returns None for guests instead of a 401 error.

#![allow(unused)]
fn main() {
use ferro::{handler, OptionalUser, Response, HttpResponse};
use crate::models::users;

#[handler]
pub async fn home(user: OptionalUser<users::Model>) -> Response {
    let greeting = match user.as_ref() {
        Some(u) => format!("Welcome back, {}!", u.name),
        None => "Welcome, guest!".to_string(),
    };
    Ok(HttpResponse::json(serde_json::json!({"greeting": greeting})))
}
}

Deref Behavior

Both AuthUser<T> and OptionalUser<T> implement Deref, so you access fields directly on the wrapper:

  • AuthUser<T> derefs to T -- use user.name, not user.0.name
  • OptionalUser<T> derefs to Option<T> -- use user.as_ref(), user.is_some(), etc.

Limitations

AuthUser and OptionalUser count as a FromRequest parameter. Only one FromRequest parameter is allowed per handler signature, so they cannot be combined with FormRequest types in the same handler. If you need both the authenticated user and the request body, use Request and call Auth::user_as::<T>() manually:

#![allow(unused)]
fn main() {
#[handler]
pub async fn update_profile(req: Request) -> Response {
    let user: users::Model = Auth::user_as::<users::Model>().await?
        .ok_or_else(|| HttpResponse::json(serde_json::json!({"error": "Unauthenticated"})).status(401))?;
    let input: UpdateInput = req.json().await?;
    // ... use both user and input
}
}

Password Hashing

Ferro provides bcrypt hashing functions re-exported at the crate root.

#![allow(unused)]
fn main() {
use ferro::{hash, verify, needs_rehash};

// Hash a password (bcrypt, cost 12)
let hashed = ferro::hash("my_password")?;

// Verify a password against a stored hash (constant-time comparison)
let valid = ferro::verify("my_password", &hashed)?;

// Check if a hash was created with a lower cost factor
if ferro::needs_rehash(&stored_hash) {
    let new_hash = ferro::hash(&password)?;
    // Update stored hash
}
}

Custom Cost Factor

#![allow(unused)]
fn main() {
use ferro::hashing;

// Hash with a specific cost (higher = slower + more secure)
let hashed = hashing::hash_with_cost("my_password", 14)?;
}

Middleware

AuthMiddleware

Protects routes that require authentication.

#![allow(unused)]
fn main() {
use ferro::{AuthMiddleware, group, get};

// API routes: returns 401 JSON for unauthenticated requests
group!("/api")
    .middleware(AuthMiddleware::new())
    .routes([...]);

// Web routes: redirects to login page
group!("/dashboard")
    .middleware(AuthMiddleware::redirect_to("/login"))
    .routes([...]);
}

Inertia-aware: When an Inertia request hits AuthMiddleware::redirect_to(), it returns a 409 response with X-Inertia-Location header instead of a 302 redirect. This tells the Inertia client to perform a full page visit to the login URL.

GuestMiddleware

Protects routes that should only be accessible to unauthenticated users (login, register pages).

#![allow(unused)]
fn main() {
use ferro::{GuestMiddleware, group, get};

group!("/")
    .middleware(GuestMiddleware::redirect_to("/dashboard"))
    .routes([
        get!("/login", auth::show_login),
        get!("/register", auth::show_register),
    ]);
}

Authenticated users visiting these routes are redirected to the specified path. Also Inertia-aware.

Login-resume contract (OAuth/MCP)

When ferro-mcp-oauth's /authorize endpoint receives an unauthenticated request, it stores the in-flight authorize URL in the session and redirects to the app login page. After authentication the login handler must redirect back to that stored URL so the OAuth flow resumes and an authorization code is issued.

Contract

Any login method — synchronous password, asynchronous magic-link, future SSO — must call oauth_resume_redirect(default) (or take_oauth_return_to()) after establishing the session to participate in the OAuth flow. A handler that redirects to a fixed dashboard instead will never resume the authorize request.

Adoption

#![allow(unused)]
fn main() {
use ferro_mcp_oauth::oauth_resume_redirect;

// After Auth::login(user_id):
return oauth_resume_redirect("/");
}

oauth_resume_redirect returns ferro::Response and is used with a bare return, not ?. It redirects to the stored authorize URL when an OAuth flow is in progress, or to the supplied default when it is not.

Available helpers

FunctionDescription
store_oauth_return_to(url)Store the in-flight authorize URL. Called by /authorize when redirecting unauthenticated users.
take_oauth_return_to() -> Option<String>Read and clear the stored URL (consume-on-read). Returns None when no flow is in progress.
oauth_resume_redirect(default) -> ferro::Response302-redirect to the stored URL, or to default when absent. Consumes the stored key.

Open-redirect invariant

The stored URL is written exclusively by the /authorize handler from a URL it constructs itself — never from user-supplied input. The default argument is a static internal path supplied by the caller. The helper therefore never redirects to an attacker-controlled URL.

Worked example

The bundled sample app's magic-link verify handler is the canonical example of a separate-request login method that uses oauth_resume_redirect to resume the OAuth flow after token verification.

Login and Registration Example

Register Handler

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Auth, json_response, Validator};

#[handler]
pub async fn register(req: Request) -> Response {
    let data: serde_json::Value = req.json().await?;

    // Validate input
    let errors = Validator::new()
        .rule("name", rules![required(), string(), min(2)])
        .rule("email", rules![required(), email()])
        .rule("password", rules![required(), string(), min(8), confirmed()])
        .validate(&data);

    if errors.fails() {
        return json_response!(422, {
            "message": "Validation failed.",
            "errors": errors
        });
    }

    // Hash password
    let password = data["password"].as_str().ok_or_else(|| HttpResponse::bad_request())?;
    let password_hash = ferro::hash(password)?;

    // Insert user
    let user = User::insert(NewUser {
        name: data["name"].as_str().ok_or_else(|| HttpResponse::bad_request())?.to_string(),
        email: data["email"].as_str().ok_or_else(|| HttpResponse::bad_request())?.to_string(),
        password: password_hash,
    }).await?;

    // Log in
    Auth::login(user.id as i64);

    json_response!(201, { "message": "Registered." })
}
}

Login Handler

#![allow(unused)]
fn main() {
#[handler]
pub async fn login(req: Request) -> Response {
    let data: serde_json::Value = req.json().await?;

    let errors = Validator::new()
        .rule("email", rules![required(), email()])
        .rule("password", rules![required(), string()])
        .validate(&data);

    if errors.fails() {
        return json_response!(422, {
            "message": "Validation failed.",
            "errors": errors
        });
    }

    let email = data["email"].as_str().ok_or_else(|| HttpResponse::bad_request())?;
    let password = data["password"].as_str().ok_or_else(|| HttpResponse::bad_request())?;

    // Attempt authentication
    let result = Auth::attempt(|| async {
        let user = User::find_by_email(email).await?;
        match user {
            Some(u) if ferro::verify(password, &u.password)? => Ok(Some(u.id as i64)),
            _ => Ok(None),
        }
    }).await?;

    match result {
        Some(_) => json_response!(200, { "message": "Authenticated." }),
        None => json_response!(401, { "message": "Invalid credentials." }),
    }
}
}

Logout Handler

#![allow(unused)]
fn main() {
#[handler]
pub async fn logout(_req: Request) -> Response {
    Auth::logout();
    json_response!(200, { "message": "Logged out." })
}
}

Route Registration

#![allow(unused)]
fn main() {
use ferro::{group, get, post, AuthMiddleware, GuestMiddleware};

// Guest-only routes (login/register pages)
group!("/")
    .middleware(GuestMiddleware::redirect_to("/dashboard"))
    .routes([
        post!("/register", auth::register),
        post!("/login", auth::login),
    ]);

// Authenticated routes
group!("/")
    .middleware(AuthMiddleware::redirect_to("/login"))
    .routes([
        post!("/logout", auth::logout),
        get!("/dashboard", dashboard::index),
    ]);
}

Security

Ferro's auth system applies these protections:

ProtectionMechanism
Session fixationSession ID regenerated on Auth::login()
CSRFToken regenerated on login and logout
Timing attacksBcrypt uses constant-time comparison
Cookie theftHttpOnly flag set by default (not accessible via JavaScript)
Cross-site requestsSameSite=Lax cookie attribute by default
HTTPS enforcementSecure cookie flag on by default
Session hijackingDatabase-backed sessions with configurable lifetime
Session expiryDual idle + absolute timeouts per OWASP
Session invalidationDirect DB deletion on password change via Auth::logout_other_devices()
Password storageBcrypt with cost factor 12 (adaptive hashing)

MCP Tools

Use these tools to inspect authorization policies and active session state.

list_policies

Returns all authorization policy implementations discovered in the project, including the policy struct name, the actions it governs, and the user types it checks. Use this to audit which resources have access control before adding new protected routes.

session_inspect

Returns details about the current session store: active session count, session driver in use, idle and absolute timeout configuration, and whether session invalidation is working correctly. Use this to debug authentication failures or verify session security settings.

MCP OAuth Authorization Server

ferro-mcp-oauth is a mountable crate that turns any Ferro application into an OAuth 2.1 authorization server for its MCP endpoint. It implements the authorization-code flow (RFC 6749 with PKCE, RFC 7636) and the device authorization grant (RFC 8628), backed by the same JWT issuer so both flows produce tokens with identical audience binding and tenant scoping.

Quick Start

Add ferro-mcp-oauth to your Cargo.toml and mount the handlers in app/src/routes.rs:

#![allow(unused)]
fn main() {
use ferro_mcp_oauth::handlers::{
    authorization_server_handler, authorize_get, authorize_post,
    device_authorization, device_verification_get, device_verification_post,
    protected_resource_handler, register_client, token_exchange,
};

routes! {
    // OAuth discovery (public)
    get!("/.well-known/oauth-protected-resource", protected_resource_handler),
    get!("/.well-known/oauth-authorization-server", authorization_server_handler),

    // Authorization-code flow (session + tenant)
    group!("/", {
        get!("/authorize", authorize_get),
        post!("/authorize", authorize_post),
    }).middleware(
        TenantMiddleware::new()
            .resolver(SessionUserTenantResolver::new())
            .on_failure(TenantFailureMode::Allow),
    ),

    // Dynamic Client Registration + token exchange (public)
    post!("/register", register_client),
    post!("/token", token_exchange),

    // Device Authorization Grant (public — no session)
    post!("/device_authorization", device_authorization),

    // Device verification page (session + tenant, Allow so unauthenticated visitors reach login)
    group!("/", {
        get!("/device", device_verification_get),
        post!("/device", device_verification_post),
    }).middleware(
        TenantMiddleware::new()
            .resolver(SessionUserTenantResolver::new())
            .on_failure(TenantFailureMode::Allow),
    ),
}
}

Run the database migration once:

#![allow(unused)]
fn main() {
use ferro_mcp_oauth::CreateOauthClientsTable;

// In your bootstrap or migration runner
CreateOauthClientsTable::up(&db).await?;
}

Configuration

VariableDescription
MCP_TOKEN_SECRETHMAC-SHA256 secret for signing JWTs. Required.
APP_URLBase URL used in discovery metadata and token audience. Required.

Authorization-Code Flow

The standard browser-based OAuth 2.1 flow with PKCE:

  1. Client registers via POST /register (Dynamic Client Registration, RFC 7591).
  2. Client redirects the browser to GET /authorize with response_type=code, client_id, redirect_uri, code_challenge (S256), and state.
  3. If the user is unauthenticated, the handler stores the in-flight URL and redirects to the app login page. After authentication the login handler calls oauth_resume_redirect to return to the authorize endpoint.
  4. Authenticated user sees a consent page; on approval an authorization code is issued and the browser is redirected to redirect_uri with code and state.
  5. Client exchanges the code at POST /token with grant_type=authorization_code and code_verifier (PKCE proof).

Device Authorization Grant (RFC 8628)

The device grant is the MCP auth path for clients that cannot complete a same-device browser redirect: headless CLI tools, cross-device logins, and passwordless flows.

Step 1 — Request a device code

The device (CLI, daemon, or headless client) sends its client_id to the public endpoint:

POST /device_authorization
Content-Type: application/x-www-form-urlencoded

client_id=<registered_client_id>

Response (RFC 8628 §3.2):

{
  "device_code": "<opaque high-entropy string>",
  "user_code": "BCDF-GHJK",
  "verification_uri": "https://your-app.example.com/device",
  "verification_uri_complete": "https://your-app.example.com/device?user_code=BCDF-GHJK",
  "expires_in": 600,
  "interval": 5
}
FieldDescription
device_codeOpaque polling credential. Never shown to the user.
user_codeShort human-typeable code (XXXX-XXXX, RFC 8628 §6.1 charset).
verification_uriURL the user opens on any browser to complete authorization.
verification_uri_completeSame URL with user_code pre-filled (suitable for QR codes).
expires_inGrant TTL in seconds (600 s / 10 min).
intervalMinimum polling interval in seconds (5 s).

Step 2 — User authorizes on any device

The user opens verification_uri (or scans the QR code for verification_uri_complete) on any browser — a phone, tablet, or desktop — and follows the flow:

  1. If unauthenticated: redirected to the app login page via the Phase 202 resume contract; returns to /device automatically after authentication.
  2. Enters or confirms the user_code.
  3. Sees the consent page (same approve/deny UI as the authorization-code flow).
  4. On approval: user_id and tenant_id are captured from the session and bound to the grant. The device receives an access_token on the next poll.

Step 3 — Poll for the token

While the user completes Step 2, the device polls POST /token at intervals ≥ interval seconds:

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=<device_code>&client_id=<client_id>

Response codes (RFC 8628 §3.5):

HTTPerrorMeaning
200Token issued; access_token, token_type, expires_in present.
400authorization_pendingUser has not yet approved. Continue polling.
400slow_downPoll interval too short; add 5 s to the current interval.
400access_deniedUser denied the request. Stop polling.
400expired_tokenGrant TTL elapsed or device_code unknown. Restart the flow.

Token identity

Tokens issued by the device grant are minted by the same jwt.rs path as the authorization-code grant, with identical sub, aud, iss, and tenant_id claims. There is one token issuer; the device grant is an alternate entry point, not a parallel path.

Discovery Metadata

Both flows are advertised in the RFC 8414 authorization-server metadata:

GET /.well-known/oauth-authorization-server
{
  "issuer": "https://your-app.example.com",
  "authorization_endpoint": "https://your-app.example.com/authorize",
  "token_endpoint": "https://your-app.example.com/token",
  "registration_endpoint": "https://your-app.example.com/register",
  "device_authorization_endpoint": "https://your-app.example.com/device_authorization",
  "response_types_supported": ["code"],
  "grant_types_supported": [
    "authorization_code",
    "urn:ietf:params:oauth:grant-type:device_code"
  ],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}

Validating Bearer Tokens

At the MCP endpoint, call validate_bearer to verify and decode incoming tokens:

#![allow(unused)]
fn main() {
use ferro_mcp_oauth::{validate_bearer, BearerCheck};

match validate_bearer(&req) {
    BearerCheck::Valid(claims) => {
        // claims.sub  — user id
        // claims.tenant_id — tenant id (None for single-tenant apps)
    }
    BearerCheck::Missing => { /* 401 */ }
    BearerCheck::Invalid(reason) => { /* 401 with reason */ }
}
}

Security Notes

  • CSRF: The consent and device-verification POST handlers use constant-time comparison (subtle::ConstantTimeEq) on session CSRF tokens.
  • Tenant binding: tenant_id is captured from the session at consent/approval time, not from form input. The TenantFailureMode::Allow mode ensures unauthenticated visitors reach the login redirect rather than a 403.
  • Single-use codes: Authorization codes and device codes are forgotten from cache immediately after a successful token exchange (get-then-forget discipline).
  • Device code entropy: device_code is a 256-bit URL-safe random string (same entropy as PKCE authorization codes). user_code uses the RFC 8628 §6.1 unambiguous consonant charset (BCDFGHJKLMNPQRSTVWXZ) and is accepted case-insensitively with or without the hyphen.
  • Rate limiting: The polling slow_down response enforces a minimum interval between polls. Additional rate limiting on POST /device_authorization is not implemented; apply an upstream reverse-proxy limit if needed.

MCP Per-Tenant API-Key Auth

ferro-mcp-oauth supports two authentication paths on the single /mcp endpoint: OAuth JWT (browser-based authorization-code flow and device grant) and a per-tenant API key. Both paths resolve to the same tenant context and produce the same BearerCheck::Authenticated outcome.

Authentication Paths

The MCP endpoint reads the Authorization: Bearer <token> header on every request. The token shape determines which validation branch runs:

Token shapeBranch
Starts with ferro_API-key validation via validate_api_key
Anything elseJWT validation via validate_bearer
Header absentBearerCheck::Unauthenticated → 401

Both branches produce BearerCheck::Authenticated(principal) on success, where principal is a JSON object with sub, tenant_id, and scope fields. The downstream tool dispatcher receives the same context regardless of which path was taken.

The mcp_api_keys Table

API keys are stored as SHA-256 hashes — the plaintext key is never persisted.

CREATE TABLE mcp_api_keys (
    id          BIGINT PRIMARY KEY AUTOINCREMENT,
    tenant_id   BIGINT NOT NULL,
    key_hash    TEXT NOT NULL,          -- SHA-256 hex of the raw key
    scope       TEXT NOT NULL DEFAULT 'read',
    revoked_at  TIMESTAMP WITH TIME ZONE NULL,
    created_at  TIMESTAMP WITH TIME ZONE NOT NULL,
    updated_at  TIMESTAMP WITH TIME ZONE NOT NULL
);

CREATE UNIQUE INDEX idx_mcp_api_keys_key_hash ON mcp_api_keys (key_hash);
CREATE        INDEX idx_mcp_api_keys_tenant_id ON mcp_api_keys (tenant_id);

ferro-mcp-oauth ships the canonical schema as a migration struct. The consumer app runs it once at bootstrap:

#![allow(unused)]
fn main() {
use ferro_mcp_oauth::CreateMcpApiKeysTable;

CreateMcpApiKeysTable::up(&db).await?;
}

Generating and Storing a Key

generate_mcp_api_key returns the plaintext key once, alongside the hash to store. The plaintext is never passed to any persistence layer.

#![allow(unused)]
fn main() {
use ferro_mcp_oauth::{generate_mcp_api_key, CreateMcpApiKeysTable};

// Generate
let (raw_key, key_hash) = generate_mcp_api_key();
// raw_key  — a "ferro_"-prefixed 49-character BASE62 token; present this to the operator
// key_hash — SHA-256 hex; insert this into mcp_api_keys

// Insert (using your preferred query layer)
db.execute(Statement::from_sql_and_values(
    DbBackend::Sqlite,
    "INSERT INTO mcp_api_keys (tenant_id, key_hash, scope, created_at, updated_at)
     VALUES (?, ?, ?, datetime('now'), datetime('now'))",
    [tenant_id.into(), key_hash.into(), "read_write".into()],
)).await?;
}

Generated keys are ferro_ followed by 43 random BASE62 characters (49 characters total). The prefix makes API keys unambiguously distinguishable from JWTs at the routing branch point.

Key Rotation

Rotation uses a soft-revoke pattern: issue a new key and set revoked_at on the old one. Do not delete rows — the audit trail is preserved.

-- Revoke the old key
UPDATE mcp_api_keys SET revoked_at = NOW() WHERE id = ?;
-- Insert new key (using generate_mcp_api_key as above)

validate_api_key treats any non-null revoked_at as BearerCheck::Invalid.

Scope Model

Each key carries a scope field: read or read_write.

Scopetools/listtools/call (read tool)tools/call (write tool)
readread tools onlyallowedrejected (-32603)
read_writeall toolsallowedallowed

The scope check on tools/call is enforced server-side before tool dispatch, independently of the listing filter. A client that bypasses tools/list and calls a write tool directly with a read-scoped key receives a -32603 error with message "scope insufficient".

Scope is orthogonal to tenant ability. scope governs the credential's permission (what this particular key may do). ServiceDef.mcp_ability governs the tenant's capability (what the tenant's account permits). Both checks apply independently.

Using an API Key

Present the raw key as a Bearer token:

Authorization: Bearer ferro_<token>
curl https://your-app.example.com/mcp \
  -H "Authorization: Bearer ferro_Ab3Cd..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}'

Security Properties

  • Plaintext never stored. Only the SHA-256 hex hash of the key is written to the database.
  • Fail-closed. Any lookup error, unknown key, or revoked key returns BearerCheck::Invalid — the same rejection path as an invalid JWT.
  • Cross-tenant isolation. The validator checks expected_tenant against the tenant_id column; a key issued to tenant A returns BearerCheck::Forbidden if presented on a tenant-B-scoped request.
  • Scope re-check at dispatch. tools/call re-evaluates the key scope before dispatching any write tool, so a tools/list filter bypass does not grant elevated access.

Agent-Operable App (Consumer MCP)

A Ferro application can expose its own data and guarded actions to an AI agent through a per-tenant MCP endpoint. The tools an agent sees are derived from the same ServiceDef projections that drive the visual and text renderers — reads, guarded writes, and a natural-language turn — so an agent operates the business through the same authorization, tenant-scoping, and guard rules as the dashboard.

This page is an end-to-end worked example. It builds on the two authentication pages — MCP OAuth Authorization Server and MCP Per-Tenant API-Key Auth — which secure the /mcp endpoint; here we add the projection-derived tools, write dispatch, confirmation gating, and the inbound intent loop on top of them.

The example domain

An order-fulfillment workflow: an order moves through a guarded state machine (draft → submitted → approved → shipped → delivered/cancelled), where approval requires a manager. The agent should be able to list a tenant's orders and advance their state — but a destructive transition must be confirmed, and a non-manager must not be able to approve.

Step 1 — Define the projection with guarded actions

The tool surface is derived entirely from a ServiceDef. Opting a projection into MCP (mcp_exposed) and declaring its actions is all that is required — no per-tool code.

#![allow(unused)]
fn main() {
use ferro::{
    ActionDef, DataType, FieldMeaning, GuardDef, ServiceDef, StateDef, StateMachine, Transition,
};

pub fn service_def() -> ServiceDef {
    ServiceDef::new("order")
        .mcp_exposed(true)            // expose this projection over /mcp
        .tenant_column("tenant_id")   // every query/write is scoped to this column
        .mcp_ability("view-orders")   // Gate ability required to call the read tool
        .display_name("Order")
        .field("id", DataType::Integer, FieldMeaning::Identifier)
        .field("customer_name", DataType::String, FieldMeaning::EntityName)
        .field("total", DataType::Float, FieldMeaning::Money)
        .field("status", DataType::String, FieldMeaning::Status)
        .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
        .state_machine(
            StateMachine::new("order_lifecycle")
                .initial("draft")
                .state(StateDef::new("draft"))
                .state(StateDef::new("submitted"))
                .state(StateDef::new("approved"))
                .state(StateDef::new("shipped"))
                .state(StateDef::new("delivered").final_state())
                .state(StateDef::new("cancelled").final_state())
                .transition(Transition::new("draft", "submit", "submitted"))
                .transition(Transition::new("submitted", "approve", "approved").guard("is_manager"))
                .transition(Transition::new("submitted", "reject", "cancelled"))
                .transition(Transition::new("approved", "ship", "shipped"))
                .transition(Transition::new("shipped", "deliver", "delivered"))
                .transition(Transition::new("draft", "cancel", "cancelled")),
        )
        .guard(GuardDef::new("is_manager").display_name("Manager Approval Required"))
        .action(ActionDef::new("submit").transition_trigger("submit"))
        .action(
            ActionDef::new("approve")
                .transition_trigger("approve")
                .precondition("is_manager"),
        )
        .action(ActionDef::new("ship").transition_trigger("ship"))
        .belongs_to("customer", "user")
        .has_many("line_items", "line_item")
}
}

This single definition derives the entire tool surface:

ToolDerived fromKind
list_orderthe projection's fieldsread
submit, approve, shipeach ActionDefwrite
request_confirm_<action> / confirm_<action>each transition_trigger action (destructive)confirmation

An action's transition_trigger marks it destructive, which is what synthesizes the two-step confirmation tools. A precondition (here is_manager) is the guard re-evaluated server-side at call time.

Step 2 — Mount the endpoint

The MCP endpoint is a single route group behind the bearer + tenant middleware. The optional /mcp/chat route adds the natural-language turn (Step 5).

#![allow(unused)]
fn main() {
group!("/", {
    post!("/mcp", controllers::mcp::handle).name("mcp.endpoint"),
    post!("/mcp/chat", controllers::mcp_chat::handle_chat).name("mcp.chat"),
})
.middleware(BearerAuthMiddleware { mcp_config: McpServerConfig::from_env() })
.middleware(
    TenantMiddleware::new()
        .resolver(JwtClaimResolver::new("tenant_id", crate::tenant_lookup::get()))
        .on_failure(TenantFailureMode::Forbidden),
);
}

BearerAuthMiddleware accepts either an OAuth JWT or a per-tenant ferro_ API key (see the two auth pages); both resolve to the same tenant context, which the dispatch paths read.

Step 3 — Wire the write dispatcher

Reads need no wiring. Writes need a WriteDispatcher: an executor that performs the mutation tenant-scoped, and a guard evaluator that re-checks preconditions against the live database. The framework never trusts the agent's view of guards — the evaluator runs at call time and is fail-closed (an unknown guard name is denied, not allowed).

#![allow(unused)]
fn main() {
pub(crate) fn make_write_dispatcher() -> WriteDispatcher {
    WriteDispatcher {
        executor: Box::new(|action_name, inputs, tenant_id, db| {
            let action_name = action_name.to_string();
            let id_val = inputs["id"].as_i64();
            let db = db.clone();
            Box::pin(async move {
                let id = id_val
                    .ok_or_else(|| ferro_mcp_server::Error::Validation("missing id".into()))?;

                // find_for_tenant: filter by id AND tenant_id — None => cross-tenant denial.
                let order = Entity::find_by_id(id as i32)
                    .filter(Column::TenantId.eq(tenant_id))
                    .one(&db).await
                    .map_err(|e| ferro_mcp_server::Error::Database(e.to_string()))?
                    .ok_or_else(|| ferro_mcp_server::Error::Validation(
                        "not found or cross-tenant access denied".into()))?;

                let new_status = match action_name.as_str() {
                    "submit" => "submitted",
                    "approve" => "approved",
                    "ship" => "shipped",
                    _ => return Err(ferro_mcp_server::Error::ActionNotFound(action_name)),
                };

                let mut active: OrderActive = order.into();
                active.status = Set(new_status.to_string());
                let updated = active.update(&db).await
                    .map_err(|e| ferro_mcp_server::Error::Database(e.to_string()))?;
                Ok(json!({ "id": updated.id, "status": updated.status }))
            })
        }),
        guard_evaluator: Box::new(|guard_name, tenant_id, _inputs, db| {
            let guard_name = guard_name.to_string();
            let db = db.clone();
            Box::pin(async move {
                match guard_name.as_str() {
                    "is_manager" => Ok(check_is_manager(tenant_id, &db).await), // live DB check
                    // Fail-closed: an unrecognized guard is denied, never silently allowed.
                    _ => Err(ferro_mcp_server::Error::GuardFailed(format!(
                        "unknown guard '{guard_name}': no evaluator registered"))),
                }
            })
        }),
    }
}

pub(crate) fn exposed_services() -> Vec<ServiceDef> {
    vec![crate::projections::order::service_def()]
}
}

The executor's find_by_id(id).filter(tenant_id) pattern is what makes cross-tenant writes structurally impossible: a row owned by another tenant resolves to None and the call fails before any mutation.

Step 4 — Confirmation gating for destructive actions

Enable the confirmation feature and provide a store. Every action with a transition_trigger is then gated by a two-step request_confirm_<action>confirm_<action> flow; calling the destructive tool directly returns a confirmation_required error instead of executing.

# Cargo.toml
[features]
confirmation = ["ferro-mcp-server/confirmation", "dep:ferro-ai"]
#![allow(unused)]
fn main() {
static CONFIRMATION_STORE: OnceLock<ferro_ai::InMemoryConfirmationStore> = OnceLock::new();

pub(crate) fn confirmation_store() -> &'static ferro_ai::InMemoryConfirmationStore {
    CONFIRMATION_STORE.get_or_init(ferro_ai::InMemoryConfirmationStore::new)
}
}

request_confirm_<action> validates inputs, re-evaluates guards, and mints a single-use, cfm_-prefixed token bound to (tenant, action, record) with a TTL (default 300s); confirm_<action> consumes the token exactly once, re-evaluates guards again, and executes. The TTL is configured through McpServerConfig.

Step 5 — The inbound natural-language turn (/mcp/chat)

/mcp/chat accepts { "message": "..." }, classifies it to a tool + arguments with ferro-ai::Classifier, and routes the result through the same read/write/confirm machinery — it adds no parallel dispatch logic. The classifier output is treated as untrusted: a classified tool name and arguments pass the identical validation, guard re-evaluation, and tenant scoping as any direct tool call.

Enable the ai-live feature (it implies confirmation and pulls the live provider) and the endpoint instantiates AnthropicProvider from the environment:

[features]
ai-live = ["ferro-mcp-server/ai-live", "ferro-mcp-server/confirmation", "dep:ferro-ai", "ferro-ai/llm", "confirmation"]

The loop is CI-testable without live-LLM spend: with FERRO_AI_LIVE_EVAL unset it runs from recorded transcript fixtures through a reqwest-free replay provider, exercising every branch with no network. Set FERRO_AI_LIVE_EVAL=1 (with ANTHROPIC_API_KEY) to make a real call; the live path announces an estimated cost before the first request. A low-confidence classification returns a needs_clarification response rather than dispatching to the wrong tool.

What the agent sees — a real session

Authenticated as a tenant (here tenant 1, "Acme"), an agent lists the tools and operates on real data. The tool surface and responses below are the actual endpoint output.

tools/list returns the derived surface:

["list_order", "submit", "approve", "ship",
 "request_confirm_submit", "confirm_submit",
 "request_confirm_approve", "confirm_approve",
 "request_confirm_ship", "confirm_ship"]

tools/call list_order returns only the caller's rows, with a structured result:

{ "result": {
    "content": [{ "type": "text", "text": "{\"rows\":[ ... ],\"total\":2}" }],
    "structuredContent": { "rows": [
        { "id": 1, "customer_name": "Alice Acme", "total": 120.0, "status": "submitted", "tenant_id": 1 },
        { "id": 2, "customer_name": "Alice Acme", "total": 85.5,  "status": "delivered", "tenant_id": 1 }
    ], "total": 2 },
    "isError": false } }

Calling a destructive tool directly is blocked:

// tools/call submit { "id": 1 }
{ "result": {
    "structuredContent": { "error_kind": "confirmation_required",
        "message": "use request_confirm_submit first",
        "request_tool": "request_confirm_submit" },
    "isError": true } }

The two-step flow mints a token, then executes once:

// tools/call request_confirm_submit { "id": 1 }
{ "result": { "structuredContent": {
    "confirmation_token": "cfm_…", "expires_in_seconds": 300 }, "isError": false } }

// tools/call confirm_submit { "confirmation_token": "cfm_…", "id": 1 }
{ "result": { "structuredContent": {
    "status": "ok", "action": "submit", "result": { "id": 1, "status": "submitted" } },
    "isError": false } }

// replay the same token => rejected (single-use)
{ "result": { "structuredContent": { "error_kind": "confirmation_expired" }, "isError": true } }

Security properties

Every property is enforced server-side, regardless of what the agent (or a prompt-injected message) claims:

  • Tenant isolation — reads and writes are scoped to the authenticated tenant's tenant_column; a cross-tenant id resolves to None and the call fails. tenant_id comes from the authenticated principal, never from tool arguments.
  • Guard re-evaluation — preconditions (is_manager) are checked against the live database at call time, fail-closed; the classifier's or agent's view of guards is never trusted.
  • Confirmation — destructive actions require a server-minted, single-use, TTL-bound token; the same token cannot be replayed.
  • Authorization — read tools require the projection's mcp_ability via the app Gate; the natural-language read path enforces the same ability as the direct path.
  • Untrusted classification/mcp/chat arguments enter the identical validation and dispatch pipeline as any direct call; classification is an entry point, not a trust shortcut.

See also

Multi-Tenancy

Ferro provides built-in support for multi-tenant applications using a shared-schema approach where every tenant shares the same database schema. Each tenant's data is isolated by a tenant_id foreign key column, enforced at the query layer via TenantScope.

The middleware resolves the current tenant once per request, stores it in task-local context, and makes it available to handlers and query scopes without requiring explicit parameter passing.

Overview

The multi-tenancy system has three components:

  1. TenantMiddleware — resolves the tenant from the incoming request using a pluggable resolver strategy.
  2. TenantContext — the resolved tenant data (id, slug, name, plan), available as a handler parameter via FromRequest.
  3. TenantScope — a query scope that filters database queries by the current tenant's ID.

Quick Start

Add TenantMiddleware to your application in bootstrap.rs:

#![allow(unused)]
fn main() {
use ferro::{
    global_middleware, TenantMiddleware, SubdomainResolver, DbTenantLookup,
    TenantFailureMode,
};
use std::sync::Arc;

pub fn register() {
    let lookup = Arc::new(DbTenantLookup::new(
        // Find tenant ID by slug
        |slug: String| async move {
            tenant::Entity::find()
                .filter(tenant::Column::Slug.eq(&slug))
                .one(DB::get())
                .await
                .ok()
                .flatten()
                .map(|t| t.id)
        },
        // Find full tenant by ID
        |id: i64| async move {
            tenant::Entity::find_by_id(id)
                .one(DB::get())
                .await
                .ok()
                .flatten()
                .map(|t| TenantContext {
                    id: t.id,
                    slug: t.slug,
                    name: t.name,
                    plan: t.plan,
                })
        },
    ));

    global_middleware!(
        TenantMiddleware::new()
            .resolver(SubdomainResolver {
                base_domain_parts: 2,
                tenant_lookup: lookup,
            })
            .on_failure(TenantFailureMode::NotFound)
    );
}
}

This configuration resolves the tenant from the request subdomain (e.g., acme.yourapp.com resolves to tenant slug acme) and returns 404 if no tenant is found.

Resolver Strategies

Four built-in resolvers cover the most common patterns. Multiple resolvers can be chained — the first one that returns Some wins.

Subdomain Resolver

Extracts the tenant slug from the request subdomain.

#![allow(unused)]
fn main() {
SubdomainResolver {
    base_domain_parts: 2, // strips last 2 parts: yourapp.com
    tenant_lookup: lookup,
}
}
  • When to use: SaaS applications with per-tenant subdomains (acme.yourapp.com).
  • base_domain_parts: 2 means yourapp.com is the base — the first segment before it is the slug.
  • For app.yourapp.com (3 parts), use base_domain_parts: 2 and app. is the slug.

Header Resolver

Extracts the tenant slug from a custom HTTP header.

#![allow(unused)]
fn main() {
HeaderResolver {
    header_name: "X-Tenant-ID".to_string(),
    tenant_lookup: lookup,
}
}
  • When to use: API clients, internal services, or mobile apps where the client explicitly sends the tenant identifier.

Path Resolver

Extracts the tenant slug from a route parameter.

#![allow(unused)]
fn main() {
PathResolver {
    param_name: "tenant".to_string(),
    tenant_lookup: lookup,
}
}

Route: /t/:tenant/dashboard

  • When to use: Applications where the tenant is embedded in the URL path (e.g., /t/acme/dashboard).

JWT Claim Resolver

Extracts the tenant slug from a JWT claim stored in request extensions.

#![allow(unused)]
fn main() {
JwtClaimResolver {
    claim_field: "tenant_id".to_string(),
    tenant_lookup: lookup,
}
}
  • When to use: Stateless APIs where an upstream JWT middleware has already parsed the token and stored claims in the request extensions as serde_json::Value.
  • Requires upstream middleware to insert parsed JWT claims into req.extensions.

Chaining Resolvers

Multiple resolvers are tried in order — the first to return Some is used:

#![allow(unused)]
fn main() {
TenantMiddleware::new()
    .resolver(HeaderResolver { header_name: "X-Tenant".to_string(), tenant_lookup: lookup.clone() })
    .resolver(SubdomainResolver { base_domain_parts: 2, tenant_lookup: lookup })
    .on_failure(TenantFailureMode::Allow)
}

Handler Extraction

TenantContext implements FromRequest and can be used as a handler parameter directly:

#![allow(unused)]
fn main() {
use ferro::{handler, TenantContext, Response, json};

#[handler]
pub async fn dashboard(tenant: TenantContext) -> Response {
    Ok(json!({
        "tenant": tenant.name,
        "plan": tenant.plan,
    }))
}
}

The handler will receive a 400 error if no tenant context is available (i.e., the route is not behind TenantMiddleware). Use this to enforce that handlers always have a tenant.

You can also read the tenant anywhere in a call chain using current_tenant():

#![allow(unused)]
fn main() {
use ferro::current_tenant;

pub async fn some_service() -> Option<String> {
    current_tenant().map(|t| t.slug)
}
}

current_tenant() returns None outside a TenantMiddleware scope.

Query Scoping

Use TenantScope to filter queries by the current tenant's ID. This prevents data from leaking between tenants.

#![allow(unused)]
fn main() {
use ferro::{TenantScope, ScopedQuery};

// Fetch all posts belonging to the current tenant
let posts = post::Entity::scoped(TenantScope(post::Column::TenantId))
    .all()
    .await?;
}

TenantScope(column) implements Scope<E> — it reads the current tenant's ID from task-local context and applies .filter(column.eq(tenant_id)) to the query.

Panic Behavior

TenantScope panics with a clear message if called outside a TenantMiddleware scope:

TenantScope used outside TenantMiddleware scope — ensure this route is behind TenantMiddleware

This is intentional. Using TenantScope without middleware is a programming error, not a runtime condition. The panic surfaces the mistake immediately in development rather than silently returning unscoped data.

Failure Modes

TenantFailureMode controls what happens when the resolver cannot find a tenant:

VariantHTTP StatusWhen to use
NotFound404Standard SaaS apps — unknown tenants don't exist
Forbidden403Apps where tenants exist but some are blocked
Allow— (passes through)Public routes that work with or without a tenant
#![allow(unused)]
fn main() {
TenantMiddleware::new()
    .resolver(SubdomainResolver { ... })
    .on_failure(TenantFailureMode::Allow) // allow public pages without tenant
}

With Allow, current_tenant() returns None for unresolved requests. Handlers that require a tenant should use TenantContext as a parameter — which returns a 400 error — rather than calling current_tenant() directly.

Custom TenantLookup

DbTenantLookup covers the most common case: a tenants table with slug and id columns. For custom schemas, implement TenantLookup directly:

#![allow(unused)]
fn main() {
use ferro::{TenantLookup, TenantContext};
use async_trait::async_trait;

pub struct MyCustomLookup;

#[async_trait]
impl TenantLookup for MyCustomLookup {
    async fn find_by_slug(&self, slug: &str) -> Option<TenantContext> {
        // Custom lookup logic — read from DB, cache, config, etc.
        todo!()
    }
}
}

The TenantLookup trait is object-safe (Arc<dyn TenantLookup>), so resolvers can share a single lookup instance across threads.

Background Jobs

Jobs dispatched from tenant-scoped handlers automatically carry the current tenant's ID in the payload. When a worker processes the job, it restores the full TenantContext before execution — so current_tenant() works inside job handlers the same way it does in HTTP handlers.

Setup

Register the capture hook and configure the worker with a tenant scope provider during bootstrap:

#![allow(unused)]
fn main() {
use ferro::{
    Worker, WorkerConfig, Queue, register_tenant_capture_hook,
    current_tenant,
    tenant::{DbTenantLookup, FrameworkTenantScopeProvider},
};
use std::sync::Arc;

// Register once at startup. Called at dispatch time to capture the current tenant ID.
register_tenant_capture_hook(|| current_tenant().map(|t| t.id));

// Build the lookup (same instance used by TenantMiddleware).
let lookup = Arc::new(DbTenantLookup::new(
    |slug| Box::pin(async move { /* find by slug */ None }),
    |id| Box::pin(async move { /* find by id */ None }),
));

// Attach the scope provider to the worker.
let worker = Worker::new(Queue::connection(), WorkerConfig::default())
    .with_tenant_scope(Arc::new(FrameworkTenantScopeProvider::new(lookup)));
}

register_tenant_capture_hook is a no-op if called more than once — only the first registration takes effect.

Using current_tenant() in Jobs

Job handlers can call current_tenant() directly, without any extra setup:

#![allow(unused)]
fn main() {
use ferro::{Job, Error, current_tenant};

#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct SendWelcomeEmail;

#[async_trait::async_trait]
impl Job for SendWelcomeEmail {
    async fn handle(&self) -> Result<(), Error> {
        let tenant = current_tenant().expect("job must run inside tenant scope");
        // send email for tenant.name ...
        Ok(())
    }
}
}

Dispatching on Behalf of a Tenant

In admin or system contexts where no ambient tenant scope exists (CLI commands, webhooks, scheduled tasks), use .for_tenant(id) to explicitly attach a tenant ID to the job:

#![allow(unused)]
fn main() {
use ferro::queue_dispatch;

// Runs the job in the scope of tenant 42, regardless of current context.
queue_dispatch(SendWelcomeEmail)
    .for_tenant(42)
    .dispatch()
    .await?;
}

Behavior When Tenant Not Found

If the worker cannot resolve the tenant ID stored in the payload (tenant deleted, DB unavailable), the job fails with QueueError::TenantNotFound. The worker follows the normal retry/failed-queue flow based on the job's retry configuration.

Safety Notes

Every query on tenant-owned tables must use TenantScope. Unscoped queries silently return data from all tenants.

#![allow(unused)]
fn main() {
// WRONG: returns posts from all tenants
let all_posts = post::Entity::query().all().await?;

// CORRECT: returns posts for the current tenant only
let tenant_posts = post::Entity::scoped(TenantScope(post::Column::TenantId))
    .all()
    .await?;
}

Cache keys must be tenant-prefixed. When caching tenant-specific data, prefix keys with the tenant ID to prevent cross-tenant cache pollution:

#![allow(unused)]
fn main() {
let key = format!("tenant:{}:feature_flags", tenant.id);
}

Routes that access tenant data must be behind TenantMiddleware. Use route groups or apply the middleware globally. Calling TenantScope on a route that is not behind the middleware panics in development, surfacing the misconfiguration early.

API Resources

API Resources provide a transformation layer between your database models and the JSON responses your API returns. They decouple your database schema from your API contract, letting you control exactly which fields are exposed, how they're named, and under what conditions they appear.

Basic Usage

Derive Macro

The simplest way to create a resource is with #[derive(ApiResource)]:

#![allow(unused)]
fn main() {
use ferro::ApiResource;

#[derive(ApiResource)]
pub struct UserResource {
    pub id: i32,
    pub name: String,
    pub email: String,
}
}

This generates a Resource trait implementation that serializes id, name, and email into a JSON object.

Model Conversion

Link a resource to a database model with the model attribute to generate a From<Model> implementation:

#![allow(unused)]
fn main() {
use ferro::ApiResource;

#[derive(ApiResource)]
#[resource(model = "crate::models::entities::users::Model")]
pub struct UserResource {
    pub id: i32,
    pub name: String,
    pub email: String,
    #[resource(skip)]
    pub password: String,
    #[resource(skip)]
    pub remember_token: Option<String>,
    #[resource(skip)]
    pub created_at: String,
    #[resource(skip)]
    pub updated_at: String,
}
}

The struct must include all fields from the model. Use #[resource(skip)] to exclude fields from JSON output while keeping them available programmatically.

Field Attributes

AttributeEffectExample
#[resource(skip)]Exclude field from JSON outputPasswords, internal tokens
#[resource(rename = "display_name")]Use a different key in JSONAPI naming conventions
#![allow(unused)]
fn main() {
use ferro::ApiResource;

#[derive(ApiResource)]
pub struct ProfileResource {
    pub id: i32,
    #[resource(rename = "display_name")]
    pub name: String,
    pub email: String,
    #[resource(skip)]
    pub password_hash: String,
}
}

Output: {"id": 1, "display_name": "Alice", "email": "alice@example.com"}

ResourceMap Builder

For conditional fields or complex logic, implement the Resource trait manually using ResourceMap:

#![allow(unused)]
fn main() {
use ferro::{Resource, ResourceMap, Request};
use serde_json::json;

struct UserResource {
    id: i32,
    name: String,
    email: String,
    is_admin: bool,
    internal_notes: Option<String>,
}

impl Resource for UserResource {
    fn to_resource(&self, _req: &Request) -> serde_json::Value {
        ResourceMap::new()
            .field("id", json!(self.id))
            .field("name", json!(self.name))
            .when("email", self.is_admin, || json!(self.email))
            .when_some("notes", &self.internal_notes)
            .merge_when(self.is_admin, || vec![
                ("role", json!("admin")),
                ("permissions", json!(["read", "write", "delete"])),
            ])
            .build()
    }
}
}

Builder Methods

MethodDescription
field(key, value)Always include this field
when(key, condition, value_fn)Include only when condition is true
unless(key, condition, value_fn)Include only when condition is false
when_some(key, option)Include only when Option is Some
merge_when(condition, fields_fn)Conditionally include multiple fields

All methods preserve insertion order in the output.

Response Helpers

The Resource trait provides response methods for common patterns:

#![allow(unused)]
fn main() {
use ferro::Resource;

// Direct JSON response
let response = resource.to_response(&req);
// Output: {"id": 1, "name": "Alice", "email": "alice@example.com"}

// Wrapped in data envelope
let response = resource.to_wrapped_response(&req);
// Output: {"data": {"id": 1, "name": "Alice", "email": "alice@example.com"}}

// Wrapped with additional top-level fields
let response = resource.to_response_with(&req, json!({"meta": {"version": "v1"}}));
// Output: {"data": {"id": 1, ...}, "meta": {"version": "v1"}}
}
MethodOutput Shape
to_response(&req){fields...}
to_wrapped_response(&req){"data": {fields...}}
to_response_with(&req, extra){"data": {fields...}, ...extra}

Handler Integration

Use resources in handlers by converting models and calling response helpers:

#![allow(unused)]
fn main() {
use ferro::{handler, Auth, HttpResponse, Request, Resource, Response};
use crate::resources::UserResource;
use crate::models::users;

#[handler]
pub async fn profile(req: Request) -> Response {
    let user = Auth::user_as::<users::Model>()
        .await?
        .ok_or_else(|| HttpResponse::json(
            serde_json::json!({"message": "Unauthenticated."})
        ).status(401))?;

    let resource = UserResource::from(user);
    Ok(resource.to_wrapped_response(&req))
}
}

The From<Model> implementation (generated by the model attribute) handles the conversion. The to_wrapped_response method produces a {"data": {...}} envelope.

CLI Scaffolding

Generate a new resource with the CLI:

# Basic resource
ferro make:resource UserResource

# With model attribute for From<Model> generation
ferro make:resource UserResource --model entities::users::Model

# Name without "Resource" suffix is auto-appended
ferro make:resource User
# Creates UserResource in src/resources/user_resource.rs

The generated file includes the derive macro template with commented examples for rename and skip attributes.

When to Use

Derive macro (#[derive(ApiResource)]):

  • Simple field selection from a model
  • Static field renaming
  • Excluding sensitive fields (passwords, tokens)

Manual ResourceMap:

  • Conditional fields based on user role or request context
  • Computed fields not present in the model
  • Merging data from multiple sources
  • Dynamic field inclusion logic

Resource Collections

ResourceCollection wraps a Vec<T: Resource> and produces a standard JSON envelope. Use it for any endpoint returning a list of resources.

Simple Collection

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Resource, ResourceCollection, Response};
use crate::resources::UserResource;

#[handler]
pub async fn index(req: Request) -> Response {
    let db = req.db();
    let users = User::find().all(db).await?;

    let resources: Vec<UserResource> = users.into_iter()
        .map(UserResource::from)
        .collect();

    let collection = ResourceCollection::new(resources);
    Ok(collection.to_response(&req))
}
}

Output:

{
  "data": [
    {"id": 1, "name": "Alice", "email": "alice@example.com"},
    {"id": 2, "name": "Bob", "email": "bob@example.com"}
  ]
}

Additional Metadata

Add extra top-level fields alongside data with .additional():

#![allow(unused)]
fn main() {
let collection = ResourceCollection::new(resources)
    .additional(json!({"meta": {"version": "v1"}}));

Ok(collection.to_response(&req))
// Output: {"data": [...], "meta": {"version": "v1"}}
}

Collection Mapping Shortcut

Resource::collection() maps a slice of resources to their JSON representations without constructing a full ResourceCollection:

#![allow(unused)]
fn main() {
let users: Vec<UserResource> = /* ... */;
let json_values = UserResource::collection(&users, &req);
// Returns: Vec<serde_json::Value>
}
ConstructorOutput
ResourceCollection::new(items){"data": [...]}
ResourceCollection::paginated(items, meta){"data": [...], "meta": {...}, "links": {...}}
.additional(json!({...}))Merges fields at top level

Pagination

PaginationMeta computes page metadata and ResourceCollection::paginated() produces the standard paginated envelope. Integrates with SeaORM's PaginatorTrait.

PaginationMeta

#![allow(unused)]
fn main() {
use ferro::PaginationMeta;

let meta = PaginationMeta::new(page, per_page, total);
}

PaginationMeta::new() accepts a 1-indexed page number (the value from API query parameters). It computes last_page, from, and to automatically. SeaORM's fetch_page() is 0-indexed -- pass page - 1 to SeaORM and the raw page to PaginationMeta.

Paginated Handler

#![allow(unused)]
fn main() {
use ferro::{handler, PaginationMeta, Request, Resource, ResourceCollection, Response};
use sea_orm::PaginatorTrait;
use crate::resources::UserResource;

#[handler]
pub async fn index(req: Request) -> Response {
    let db = req.db();
    let page: u64 = req.query("page").unwrap_or(1);
    let per_page: u64 = req.query("per_page").unwrap_or(15);

    let paginator = User::find()
        .order_by_desc(users::Column::Id)
        .paginate(db, per_page);

    let items = paginator.fetch_page(page - 1).await?;  // 0-indexed
    let total = paginator.num_items().await?;

    let resources: Vec<UserResource> = items.into_iter()
        .map(UserResource::from)
        .collect();

    let meta = PaginationMeta::new(page, per_page, total);  // 1-indexed
    Ok(ResourceCollection::paginated(resources, meta).to_response(&req))
}
}

JSON Output Format

{
  "data": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}],
  "meta": {
    "current_page": 1,
    "per_page": 15,
    "total": 42,
    "last_page": 3,
    "from": 1,
    "to": 15
  },
  "links": {
    "first": "/users?page=1",
    "last": "/users?page=3",
    "prev": null,
    "next": "/users?page=2"
  }
}

Pagination links are relative URLs. Existing query parameters (e.g., sort=name) are preserved in links.

Relationship Inclusion

Ferro uses explicit batch-loading for relationships. All related data is loaded before resource construction -- never inside to_resource(). This prevents N+1 queries by design.

when_loaded (belongs_to / has_one)

when_loaded() looks up a key in a HashMap. If the key exists, the field is included in the output. If absent, the field is omitted.

#![allow(unused)]
fn main() {
use ferro::{Resource, ResourceMap, Request};
use std::collections::HashMap;
use serde_json::{json, Value};

struct PostResource {
    post: posts::Model,
    authors: HashMap<i32, users::Model>,
}

impl Resource for PostResource {
    fn to_resource(&self, req: &Request) -> Value {
        ResourceMap::new()
            .field("id", json!(self.post.id))
            .field("title", json!(self.post.title))
            .when_loaded("author", &self.post.author_id, &self.authors, |user| {
                json!({"id": user.id, "name": &user.name})
            })
            .build()
    }
}
}

when_loaded_many (has_many)

when_loaded_many() operates on HashMap<K, Vec<M>>. An empty vec is still included (loaded but empty); a missing key means the field is omitted entirely.

#![allow(unused)]
fn main() {
struct UserResource {
    user: users::Model,
    posts: HashMap<i32, Vec<posts::Model>>,
}

impl Resource for UserResource {
    fn to_resource(&self, req: &Request) -> Value {
        ResourceMap::new()
            .field("id", json!(self.user.id))
            .field("name", json!(self.user.name))
            .when_loaded_many("posts", &self.user.id, &self.posts, |items| {
                json!(items.iter().map(|p| {
                    json!({"id": p.id, "title": &p.title})
                }).collect::<Vec<_>>())
            })
            .build()
    }
}
}

Complete Paginated Handler with Relationships

#![allow(unused)]
fn main() {
use ferro::{handler, PaginationMeta, Request, Resource, ResourceCollection, ResourceMap, Response};
use sea_orm::PaginatorTrait;
use std::collections::HashMap;
use serde_json::{json, Value};

struct UserWithPostsResource {
    user: users::Model,
    posts_map: HashMap<i32, Vec<posts::Model>>,
}

impl Resource for UserWithPostsResource {
    fn to_resource(&self, _req: &Request) -> Value {
        ResourceMap::new()
            .field("id", json!(self.user.id))
            .field("name", json!(self.user.name))
            .when_loaded_many("posts", &self.user.id, &self.posts_map, |items| {
                json!(items.iter().map(|p| {
                    json!({"id": p.id, "title": &p.title})
                }).collect::<Vec<_>>())
            })
            .build()
    }
}

#[handler]
pub async fn index(req: Request) -> Response {
    let db = req.db();
    let page: u64 = req.query("page").unwrap_or(1);
    let per_page: u64 = 15;

    // 1. Paginate parent entity
    let paginator = User::find()
        .order_by_asc(users::Column::Id)
        .paginate(db, per_page);
    let users = paginator.fetch_page(page - 1).await?;
    let total = paginator.num_items().await?;

    // 2. Batch load relations for this page
    let user_ids: Vec<i32> = users.iter().map(|u| u.id).collect();
    let posts_map: HashMap<i32, Vec<posts::Model>> = Post::find()
        .filter(posts::Column::UserId.is_in(user_ids))
        .all(db)
        .await?
        .into_iter()
        .fold(HashMap::new(), |mut map, post| {
            map.entry(post.user_id).or_default().push(post);
            map
        });

    // 3. Map to resources with relations
    let resources: Vec<UserWithPostsResource> = users.into_iter()
        .map(|u| UserWithPostsResource {
            user: u,
            posts_map: posts_map.clone(),
        })
        .collect();

    // 4. Return paginated collection
    let meta = PaginationMeta::new(page, per_page, total);
    Ok(ResourceCollection::paginated(resources, meta).to_response(&req))
}
}

Anti-Patterns

N+1 inside to_resource(): Never call database queries inside to_resource(). All data must be loaded before resource construction.

#![allow(unused)]
fn main() {
// BAD: queries the database for every resource
impl Resource for UserResource {
    fn to_resource(&self, req: &Request) -> Value {
        let posts = Post::find()  // N+1 query!
            .filter(posts::Column::UserId.eq(self.user.id))
            .all(db).await;
        // ...
    }
}
}

Paginating joined queries: SeaORM's find_with_related() (SelectTwoMany) does not support .paginate(). Always paginate the parent entity first, then batch-load relations for the fetched page.

#![allow(unused)]
fn main() {
// BAD: won't compile
let results = User::find()
    .find_with_related(Post)
    .paginate(db, 15);  // SelectTwoMany has no PaginatorTrait

// GOOD: paginate parent, then batch load
let users = User::find().paginate(db, 15).fetch_page(0).await?;
// Then load posts for these users in a second query

# MCP Tools

Use `list_resources` to discover all API resource types defined in the project.

## `list_resources`

Returns all structs that implement the `Resource` trait, including their fields, any `skip` or `rename` annotations, and the model they wrap. Use this to understand what data is exposed in API responses before modifying a resource or adding a new endpoint.
}

REST API Scaffold

Ferro generates a production-ready REST API from existing models with one command. The scaffold includes CRUD controllers, API key authentication, rate limiting, OpenAPI documentation, and request validation types.

Quick Start

# Generate API for all models
ferro make:api --all

# Generate for specific models
ferro make:api User Post

# Skip confirmation prompts
ferro make:api --all --yes

After generation, wire the scaffold into your application:

  1. Add mod api; to src/main.rs or src/lib.rs
  2. Register api_routes() in your route configuration
  3. Register docs_routes() for API documentation
  4. Register ApiKeyProviderImpl as a service: App::bind::<dyn ApiKeyProvider>(Box::new(ApiKeyProviderImpl));
  5. Run ferro db:migrate to create the api_keys table
  6. Generate your first API key programmatically

Generated Files

ferro make:api generates the following files for each model:

FilePurpose
src/api/{model}_api.rsCRUD controller with index, show, store, update, destroy handlers
src/resources/{model}_resource.rsAPI resource with Resource trait implementation and From<Model> conversion
src/requests/{model}_request.rsCreate{Model}Request and Update{Model}Request with validation

Infrastructure files (generated once):

FilePurpose
src/api/mod.rsModule declarations for all API controllers
src/api/routes.rsRoute group with ApiKeyMiddleware and Throttle middleware
src/api/docs.rsOpenAPI JSON and ReDoc HTML handlers
src/models/api_key.rsSeaORM entity for the api_keys table
src/providers/api_key_provider.rsApiKeyProvider implementation with revocation and expiry checks
src/migrations/m*_create_api_keys_table.rsMigration for the api_keys table

Existing files are never overwritten. If a file already exists, it is skipped with an info message.

API Key Authentication

Key Format

API keys follow the fe_{env}_{random} pattern:

  • fe_live_ prefix for production keys
  • fe_test_ prefix for test/development keys
  • 43 random base62 characters for the secret portion

Full key length: 51 characters (e.g., fe_live_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcde).

Generating Keys

#![allow(unused)]
fn main() {
use ferro::generate_api_key;

let key = generate_api_key("live");

// Show the raw key to the user exactly once
println!("Your API key: {}", key.raw_key);

// Store these in the database
// key.prefix — first 16 chars, used for DB lookup (unhashed)
// key.hashed_key — SHA-256 hex digest, used for verification
}

The raw key is never stored. After creation, only the prefix and hash are persisted. If the user loses the key, a new one must be generated.

Storage Schema

The generated api_keys migration creates:

ColumnTypeDescription
idBIGINT PKAuto-increment identifier
nameVARCHARHuman-readable label (e.g., "Production Bot")
prefixVARCHAR(16)First 16 characters for indexed lookup
hashed_keyVARCHAR(64)SHA-256 hex digest
scopesTEXT NULLJSON array of permission scopes
last_used_atTIMESTAMPTZ NULLLast request timestamp
expires_atTIMESTAMPTZ NULLExpiration timestamp
revoked_atTIMESTAMPTZ NULLRevocation timestamp
created_atTIMESTAMPTZCreation timestamp

An index on prefix enables fast key lookup.

Verification Flow

  1. Extract Bearer {key} from the Authorization header
  2. Look up the key record by prefix (first 16 characters)
  3. Check revocation (revoked_at IS NULL)
  4. Check expiry (expires_at not passed)
  5. Constant-time SHA-256 hash comparison via subtle::ConstantTimeEq
  6. Check required scopes against granted scopes
  7. Store ApiKeyInfo in request extensions for downstream handlers

ApiKeyProvider Trait

The middleware resolves an ApiKeyProvider from the service container. Implement this trait to connect to any key store:

#![allow(unused)]
fn main() {
use ferro::{async_trait, ApiKeyProvider, ApiKeyInfo};

pub struct MyKeyProvider;

#[async_trait]
impl ApiKeyProvider for MyKeyProvider {
    async fn verify_key(&self, raw_key: &str) -> Result<ApiKeyInfo, ()> {
        let prefix = &raw_key[..16.min(raw_key.len())];
        // Look up by prefix, verify hash, return metadata
        // ...
        Ok(ApiKeyInfo {
            id: record.id,
            name: record.name,
            scopes: vec!["read".to_string(), "write".to_string()],
        })
    }
}
}

Register in bootstrap:

#![allow(unused)]
fn main() {
App::bind::<dyn ApiKeyProvider>(Box::new(MyKeyProvider));
}

The generated ApiKeyProviderImpl in src/providers/api_key_provider.rs provides a complete database-backed implementation with revocation and expiry checks.

API Key Management

CLI Key Generation

Generate API keys from the command line without writing code:

ferro make:api-key "My App"

Options:

FlagDescriptionDefault
--envKey environment: live or testlive

Example with test environment:

ferro make:api-key "Dev Bot" --env test

Output includes:

  • Raw key (e.g., fe_live_aBcDeFg...) -- shown once, store securely
  • Prefix -- first 16 characters, used for database lookup
  • Hashed key -- SHA-256 hex digest for verification
  • SQL insert -- ready-to-run INSERT statement
  • Rust snippet -- copy-paste SeaORM code

The raw key is displayed only once. If lost, generate a new key.

Creating Keys Programmatically

#![allow(unused)]
fn main() {
use ferro::generate_api_key;
use sea_orm::{EntityTrait, Set};
use crate::models::api_key;

let key = generate_api_key("live");

let record = api_key::ActiveModel {
    name: Set("Production Bot".to_string()),
    prefix: Set(key.prefix),
    hashed_key: Set(key.hashed_key),
    scopes: Set(Some(serde_json::to_string(&["read", "write"]).expect("serialization is infallible"))),
    ..Default::default()
};

api_key::Entity::insert(record)
    .exec(&db)
    .await?;

// Return key.raw_key to the user (show once)
}

Revoking Keys

Set revoked_at to the current timestamp. The provider checks this before verifying the hash:

#![allow(unused)]
fn main() {
use sea_orm::{EntityTrait, Set, IntoActiveModel};
use chrono::Utc;

let mut key: api_key::ActiveModel = record.into_active_model();
key.revoked_at = Set(Some(Utc::now()));
key.update(&db).await?;
}

Key Expiration

Set expires_at when creating the key. The provider rejects keys past their expiration:

#![allow(unused)]
fn main() {
use chrono::{Utc, Duration};

let record = api_key::ActiveModel {
    expires_at: Set(Some(Utc::now() + Duration::days(90))),
    // ...
    ..Default::default()
};
}

Scope-Based Permissions

Scopes are stored as a JSON array in the scopes column. The middleware checks required scopes against granted scopes:

#![allow(unused)]
fn main() {
use ferro::ApiKeyMiddleware;

// Require any valid API key
group!("/api/v1")
    .middleware(ApiKeyMiddleware::new())
    .routes([...]);

// Require specific scopes
group!("/api/v1/admin")
    .middleware(ApiKeyMiddleware::scopes(&["admin"]))
    .routes([...]);
}

A key with ["*"] in its scopes bypasses all scope checks (wildcard).

Accessing Key Info in Handlers

After middleware verification, ApiKeyInfo is available in request extensions:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, HttpResponse, ApiKeyInfo};

#[handler]
pub async fn index(req: Request) -> Response {
    let key_info = req.get::<ApiKeyInfo>().ok_or_else(|| HttpResponse::unauthorized())?;
    println!("Request from: {} (scopes: {:?})", key_info.name, key_info.scopes);
    // ...
}
}

Field Selection

By default, make:api auto-excludes sensitive fields from generated API resources. Fields matching these patterns are omitted:

  • password, password_hash, hashed_password
  • secret, token, api_key, hashed_key
  • remember_token

Matching is case-insensitive, exact match only. A field named token_count is not excluded.

Custom Exclusion

Exclude additional fields with --exclude:

ferro make:api --all --exclude password_hash,secret_token

Multiple fields are comma-separated. Custom exclusions stack with auto-exclusion.

Including All Fields

Override auto-exclusion with --include-all:

ferro make:api --all --include-all

When --include-all is set, no auto-exclusion is applied. Custom --exclude fields are still honored:

# Include all fields except internal_notes
ferro make:api --all --include-all --exclude internal_notes

Verifying Your API

After scaffolding and wiring routes, verify the setup with ferro api:check:

ferro api:check

The command runs four sequential checks:

  1. Server connectivity -- can the CLI reach your server?
  2. Spec available -- does /api/openapi.json return a response?
  3. Spec valid -- is the response a valid OpenAPI 3.x document?
  4. Auth working -- does the API key authenticate successfully?

With Authentication

ferro api:check --api-key fe_live_...

Custom URL

ferro api:check --url http://localhost:3000

Custom Spec Path

ferro api:check --spec-path /api/docs/openapi.json

On success, api:check prints a ready-to-copy ferro-api-mcp command for MCP integration.

Endpoints

The generated routes follow standard REST conventions under /api/v1/:

List Resources

GET /api/v1/{resource}?page=1&per_page=15
curl -H "Authorization: Bearer fe_live_..." \
     "https://example.com/api/v1/users?page=1&per_page=15"

Response:

{
  "data": [
    {"id": 1, "name": "Alice", "email": "alice@example.com"},
    {"id": 2, "name": "Bob", "email": "bob@example.com"}
  ],
  "meta": {
    "current_page": 1,
    "per_page": 15,
    "total": 42,
    "last_page": 3,
    "from": 1,
    "to": 15
  },
  "links": {
    "first": "/api/v1/users?page=1",
    "last": "/api/v1/users?page=3",
    "prev": null,
    "next": "/api/v1/users?page=2"
  }
}

per_page is capped at 100.

Create Resource

POST /api/v1/{resource}
curl -X POST -H "Authorization: Bearer fe_live_..." \
     -H "Content-Type: application/json" \
     -d '{"name": "Charlie", "email": "charlie@example.com"}' \
     "https://example.com/api/v1/users"

Response (201):

{
  "data": {"id": 3, "name": "Charlie", "email": "charlie@example.com"}
}

Show Resource

GET /api/v1/{resource}/{id}
curl -H "Authorization: Bearer fe_live_..." \
     "https://example.com/api/v1/users/1"

Response:

{
  "data": {"id": 1, "name": "Alice", "email": "alice@example.com"}
}

Update Resource

PUT /api/v1/{resource}/{id}
curl -X PUT -H "Authorization: Bearer fe_live_..." \
     -H "Content-Type: application/json" \
     -d '{"name": "Alice Smith"}' \
     "https://example.com/api/v1/users/1"

Response:

{
  "data": {"id": 1, "name": "Alice Smith", "email": "alice@example.com"}
}

Update handlers use conditional field assignment (if let Some), so only included fields are modified.

Delete Resource

DELETE /api/v1/{resource}/{id}
curl -X DELETE -H "Authorization: Bearer fe_live_..." \
     "https://example.com/api/v1/users/1"

Response:

{"message": "Deleted"}

OpenAPI Documentation

The scaffold includes auto-generated OpenAPI 3.0 documentation:

EndpointDescription
/api/docsInteractive ReDoc UI
/api/openapi.jsonRaw OpenAPI specification

How Specs Are Built

The OpenAPI spec builder reads from the Ferro route registry:

  1. Filters routes matching the /api/ prefix
  2. Generates operations with auto-summaries (e.g., GET /api/v1/users becomes "List users")
  3. Extracts path parameters from {param} patterns
  4. Groups endpoints by resource name as tags
  5. Adds API key security scheme (Authorization header)

Specs and HTML are cached via OnceLock -- generated once on first request, zero cost per subsequent request.

Configuration

#![allow(unused)]
fn main() {
use ferro::{OpenApiConfig, build_openapi_spec, get_registered_routes};

let config = OpenApiConfig {
    title: "My API".to_string(),
    version: "1.0.0".to_string(),
    description: Some("Application API".to_string()),
    api_prefix: "/api/".to_string(),
};

let spec = build_openapi_spec(&config, &get_registered_routes());
}

The generated src/api/docs.rs reads the APP_NAME environment variable for the title.

Rate Limiting

Generated API routes include Throttle::named("api") middleware. Define the limiter in bootstrap.rs:

#![allow(unused)]
fn main() {
use ferro::{RateLimiter, Limit};

RateLimiter::define("api", |_req| {
    Limit::per_minute(60)
});
}

The default configuration allows 60 requests per minute per client IP. Adjust the limit or segment by API key:

#![allow(unused)]
fn main() {
use ferro::{ApiKeyInfo, RateLimiter, Limit};

RateLimiter::define("api", |req| {
    match req.get::<ApiKeyInfo>() {
        Some(key) => Limit::per_minute(1000).by(format!("key:{}", key.id)),
        None => Limit::per_minute(60),
    }
});
}

See Rate Limiting for full documentation on time windows, multiple limits, custom responses, and cache backends.

MCP Tools

Ferro's MCP server provides four CRUD tools for direct database access without the HTTP API layer. These enable AI agents to manage application data programmatically.

crud_create

Create a new record for any model:

{
  "model": "User",
  "data": {"name": "Alice", "email": "alice@example.com"}
}

Returns the created record as JSON.

crud_list

List records with optional filtering and pagination:

{
  "model": "User",
  "filters": {"status": "active"},
  "page": 1,
  "per_page": 25
}

Returns records array with total, page, and per_page metadata. Per-page is capped at 100.

crud_update

Update an existing record by primary key:

{
  "model": "User",
  "id": 1,
  "data": {"name": "Alice Smith"}
}

Returns the updated record as JSON.

crud_delete

Delete a record by primary key:

{
  "model": "User",
  "id": 1
}

Returns a confirmation message.

How It Works

The MCP CRUD tools:

  1. Parse model metadata via syn AST visitor (same pattern as ferro make:api)
  2. Validate field names against the model struct definition
  3. Build parameterized SQL using sea_orm::Statement::from_sql_and_values
  4. Execute against the project's configured database
  5. Use RETURNING * on Postgres, last_insert_rowid() fallback on SQLite

All queries use parameterized statements to prevent SQL injection. Timestamp fields (created_at, updated_at) are excluded from required-field validation since they typically have database defaults.

Customization

Adding Custom Endpoints

Add routes to the generated src/api/routes.rs:

#![allow(unused)]
fn main() {
pub fn api_routes() -> GroupBuilder {
    group!("/api/v1")
        .middleware(ApiKeyMiddleware::new())
        .middleware(Throttle::named("api"))
        .routes([
            // Generated CRUD routes...
            get!("/users", user_api::index),
            post!("/users", user_api::store),
            // Custom endpoints
            post!("/users/:id/activate", user_api::activate),
            get!("/stats", stats_api::overview),
        ])
}
}

Modifying Response Format

Edit the generated resource in src/resources/{model}_resource.rs. Use ResourceMap for conditional fields:

#![allow(unused)]
fn main() {
impl Resource for UserResource {
    fn to_resource(&self, _req: &Request) -> serde_json::Value {
        ResourceMap::new()
            .field("id", json!(self.id))
            .field("name", json!(self.name))
            .when("email", self.is_admin, || json!(self.email))
            .build()
    }
}
}

See API Resources for full documentation on ResourceMap, conditional fields, and relationship inclusion.

Adding Relationships

Load related data in the controller before constructing resources:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show(req: Request, user: user::Model) -> Response {
    let db = ferro::DB::connection()
        .map_err(|e| HttpResponse::json(json!({"error": e.to_string()})).status(500))?;
    let posts = Post::find()
        .filter(posts::Column::UserId.eq(user.id))
        .all(&db)
        .await
        .map_err(|e| HttpResponse::json(json!({"error": e.to_string()})).status(500))?;

    let mut resource = UserResource::from(&user);
    // Add posts to the response
    // ...
    Ok(resource.to_wrapped_response(&req))
}
}

Custom Validation Rules

Edit the generated request types in src/requests/{model}_request.rs:

#![allow(unused)]
fn main() {
#[request]
pub struct CreateUserRequest {
    #[validate(length(min = 1, max = 255))]
    pub name: String,
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 8))]
    pub password: String,
}
}

See Validation for all available validation rules.

Custom Scopes

Define application-specific scopes and check them in handlers:

#![allow(unused)]
fn main() {
// In route registration
group!("/api/v1/admin")
    .middleware(ApiKeyMiddleware::scopes(&["admin"]))
    .routes([...]);

// In handler, check additional granular permissions
#[handler]
pub async fn destroy(req: Request, user: user::Model) -> Response {
    let key = req.get::<ApiKeyInfo>().ok_or_else(|| HttpResponse::unauthorized())?;
    if !key.scopes.contains(&"users:delete".to_string()) {
        return Err(HttpResponse::json(json!({"error": "Missing users:delete scope"})).status(403));
    }
    // proceed with deletion
}
}

Security

ProtectionMechanism
Key storageSHA-256 hash only; raw key never persisted
Timing attacksConstant-time comparison via subtle::ConstantTimeEq
Key rotationRevocation via revoked_at timestamp
Key expiryexpires_at checked on every request
SQL injectionParameterized queries in all CRUD operations
Rate limitingPer-key or per-IP throttling with configurable windows
Scope enforcementMiddleware-level scope checking with wildcard support

MCP Bridge (ferro-api-mcp)

ferro-api-mcp is a standalone binary that bridges any Ferro REST API to the Model Context Protocol (MCP). AI agents can discover and call your API endpoints as MCP tools without custom integration code.

Quick Start Workflow

From scaffold to working MCP integration in seven steps:

  1. Scaffold the API:

    ferro make:api --all
    
  2. Wire routes in src/main.rs:

    #![allow(unused)]
    fn main() {
    mod api;
    // In route registration:
    api::routes::api_routes()
    api::docs::docs_routes()
    }
  3. Run the migration:

    ferro db:migrate
    
  4. Generate an API key:

    ferro make:api-key "My Key"
    

    Save the raw key -- it is shown only once.

  5. Start the server:

    cargo run
    
  6. Verify the setup:

    ferro api:check --api-key fe_live_...
    
  7. Add MCP config to your AI agent (see MCP Host Configuration below).

How It Works

  1. Reads the OpenAPI spec from your Ferro app's /api/docs/openapi.json endpoint
  2. Converts each API operation into an MCP tool with typed input schemas
  3. Runs as a stdio MCP server that AI agents connect to
  4. Supports x-mcp vendor extensions for customizing tool names, descriptions, hints, and visibility

Prerequisites

  • A Ferro app with make:api scaffold (see REST API)
  • The API running and accessible (e.g., ferro serve on localhost:8080)
  • An API key generated via ferro make:api setup

Setup

Building

cargo build --release -p ferro-api-mcp

Binary location: target/release/ferro-api-mcp

CLI Options

ferro-api-mcp [OPTIONS] --spec-url <URL>

Options:
  --spec-url <URL>    URL to fetch the OpenAPI spec from
  --api-key <KEY>     API key for Authorization header (optional)
  --base-url <URL>    Override the base URL for API calls
  --log-level <LEVEL> Log level: debug, info, warn, error [default: info]
  --dry-run           Validate spec and print tool summary without starting server

Validating Setup

ferro-api-mcp --spec-url http://localhost:8080/api/docs/openapi.json \
  --api-key your-api-key \
  --dry-run

Expected output:

Fetched spec: 4521 bytes

ferro-api-mcp v0.1.0
API: My App
Base URL: http://localhost:8080/
Tools: 5 registered

Tools:
  - list_users: List all users with pagination.
  - create_user: Create a new user.
  - show_user: Retrieve a single user by ID.
  - update_user: Update an existing user.
  - delete_user: Delete a user by ID.

Dry run complete. 5 tools validated.

MCP Host Configuration

Claude Desktop

Add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):

{
  "mcpServers": {
    "my-app": {
      "command": "/path/to/ferro-api-mcp",
      "args": [
        "--spec-url", "http://localhost:8080/api/docs/openapi.json",
        "--api-key", "your-api-key"
      ]
    }
  }
}

Claude Code

Add to .claude.json (project-level) or ~/.claude.json (global):

{
  "mcpServers": {
    "my-app": {
      "command": "/path/to/ferro-api-mcp",
      "args": [
        "--spec-url", "http://localhost:8080/api/docs/openapi.json",
        "--api-key", "your-api-key"
      ]
    }
  }
}

Cursor

Add via Settings > MCP Servers:

{
  "my-app": {
    "command": "/path/to/ferro-api-mcp",
    "args": [
      "--spec-url", "http://localhost:8080/api/docs/openapi.json",
      "--api-key", "your-api-key"
    ]
  }
}

x-mcp Extensions

Ferro's build_openapi_spec() automatically emits x-mcp vendor extensions on each operation. ferro-api-mcp reads these at startup to customize tool behavior.

ExtensionEffect
x-mcp-tool-nameAI-friendly snake_case tool name (e.g., list_users)
x-mcp-descriptionAI-optimized description for the tool
x-mcp-hintUsage hint appended to tool description
x-mcp-hiddenSet to true to exclude the operation from MCP tools

These are emitted automatically by the framework. No configuration is needed. ferro-api-mcp uses the extension values as overrides, falling back to auto-generated names and descriptions when extensions are absent.

Route Customization

Override the auto-generated MCP metadata on individual routes using builder methods:

#![allow(unused)]
fn main() {
use ferro::{group, get, post, delete, ApiKeyMiddleware};

group!("/api/v1")
    .middleware(ApiKeyMiddleware::new())
    .routes([
        get!("/users", user_api::index)
            .mcp_tool_name("search_users")
            .mcp_description("Search users by name or email with pagination")
            .mcp_hint("Use page and per_page params for large result sets"),

        post!("/users", user_api::store)
            .mcp_description("Create a new user account"),

        delete!("/users/:id/sessions", user_api::clear_sessions)
            .mcp_hidden(),  // Exclude from MCP tools
    ])
}

Available Methods

MethodEffectWhen to Use
.mcp_tool_name("name")Override auto-generated tool nameWhen the default name is unclear (e.g., store_user -> create_user_account)
.mcp_description("desc")Override auto-generated descriptionWhen the default summary needs more context for AI agents
.mcp_hint("hint")Append hint text to descriptionTo guide AI agents on parameter usage or expected behavior
.mcp_hidden()Exclude route from MCP toolsFor internal/admin endpoints that agents should not call

Group-Level Defaults

Set MCP defaults at the group level. Route-level overrides take precedence:

#![allow(unused)]
fn main() {
group!("/api/v1/internal")
    .mcp_hidden()  // Hide all routes in this group
    .routes([
        get!("/health", internal_api::health),
        get!("/metrics", internal_api::metrics),
    ])
}

How It Works

Customizations are stored in the route registry and emitted as x-mcp vendor extensions in the OpenAPI spec. ferro-api-mcp reads these extensions at startup, using them as overrides over auto-generated values.

Troubleshooting

ProblemCauseSolution
"Cannot connect to {url}"API server not runningStart the server with ferro serve
"HTTP 401" on tool callsMissing or invalid API keyCheck --api-key matches a key in the database
"HTTP 404" on tool callsEndpoint does not existVerify the API is running and the spec is current
"request timed out"API slow or network issueCheck server logs, verify connectivity
"spec parsed but 0 operations"Empty or malformed specCheck /api/docs/openapi.json manually
"unsupported OpenAPI version"Spec is not 3.0.xferro-api-mcp requires OpenAPI 3.0.x
Tool arguments rejectedMissing required fieldsCheck tool input schema for required params

Base URL Resolution

ferro-api-mcp resolves the API base URL in this order:

  1. --base-url flag (explicit override)
  2. servers[0].url from the OpenAPI spec
  3. Origin of the --spec-url (scheme + host + port)

This means most setups need only --spec-url. Use --base-url when the API server is behind a reverse proxy or on a different host than the spec endpoint.

Rate Limiting

Ferro provides cache-backed rate limiting through the Throttle middleware. Define named limiters with dynamic, per-request rules or apply inline limits directly on routes. Rate counters use the framework's Cache facade -- in-memory by default, Redis for multi-server deployments.

Defining Rate Limiters

Register named limiters in bootstrap.rs (or a service provider). Each limiter receives the incoming Request and returns one or more Limit values.

Basic Limiter

#![allow(unused)]
fn main() {
use ferro::{RateLimiter, Limit};

pub fn register_rate_limiters() {
    RateLimiter::define("api", |_req| {
        Limit::per_minute(60)
    });
}
}

Auth-Based Segmentation

Use the request to vary limits by authentication state:

#![allow(unused)]
fn main() {
use ferro::{RateLimiter, Limit, Auth};

RateLimiter::define("api", |req| {
    match Auth::id() {
        Some(id) => Limit::per_minute(120).by(format!("user:{}", id)),
        None => Limit::per_minute(60),
    }
});
}

Unauthenticated requests default to the client IP as the rate limit key. Authenticated users get a higher limit keyed by their user ID.

Multiple Limits

Return a Vec<Limit> to enforce several windows simultaneously. The first limit exceeded triggers a 429 response.

#![allow(unused)]
fn main() {
use ferro::{RateLimiter, Limit};

RateLimiter::define("login", |req| {
    let ip = req.header("X-Forwarded-For")
        .and_then(|s| s.split(',').next())
        .unwrap_or("unknown")
        .trim()
        .to_string();

    vec![
        Limit::per_minute(500),           // Global burst cap
        Limit::per_minute(5).by(ip),      // Per-IP cap
    ]
});
}

Applying to Routes

Named Throttle

Reference a registered limiter by name with Throttle::named():

#![allow(unused)]
fn main() {
use ferro::Throttle;

routes! {
    group!("/api", {
        get!("/users", controllers::users::index),
        get!("/users/{id}", controllers::users::show),
    }).middleware(Throttle::named("api")),

    group!("/auth", {
        post!("/login", controllers::auth::login),
    }).middleware(Throttle::named("login")),
}
}

Inline Throttle

For simple cases that do not need a named registration:

#![allow(unused)]
fn main() {
use ferro::Throttle;

get!("/health", controllers::health::check)
    .middleware(Throttle::per_minute(10))
}

Inline limits support the same time windows: per_second, per_minute, per_hour, per_day.

The Limit Struct

Limit describes how many requests are allowed in a time window.

Constructors

MethodWindow
Limit::per_second(n)1 second
Limit::per_minute(n)60 seconds
Limit::per_hour(n)3600 seconds
Limit::per_day(n)86400 seconds

Key Segmentation

By default, rate limits are keyed by client IP (from X-Forwarded-For or X-Real-IP headers). Override with .by():

#![allow(unused)]
fn main() {
// Per-user limit
Limit::per_minute(120).by(format!("user:{}", user_id))

// Per-API-key limit
Limit::per_minute(1000).by(api_key)
}

Custom 429 Response

Override the default JSON error with .response():

#![allow(unused)]
fn main() {
use ferro::HttpResponse;

Limit::per_minute(60).response(|| {
    HttpResponse::json(serde_json::json!({
        "error": "Quota exceeded",
        "upgrade_url": "https://example.com/pricing"
    })).status(429)
})
}

Response Headers

Every response from a throttled route includes rate limit headers:

HeaderDescription
X-RateLimit-LimitMaximum requests allowed in the window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetSeconds until the current window resets

When a request is rejected (429), an additional header is included:

HeaderDescription
Retry-AfterSeconds until the client should retry

Example Headers

Successful request:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 38

Rate limited request (429):

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 38
Retry-After: 38

Cache Backend

Rate limiting uses the framework's Cache facade for counter storage. The algorithm is fixed-window counters with atomic INCR + EXPIRE operations.

SetupConfiguration
Single server (default)No configuration needed. Uses in-memory cache.
Multi-serverSet CACHE_DRIVER=redis and REDIS_URL in .env
# .env for multi-server deployments
CACHE_DRIVER=redis
REDIS_URL=redis://127.0.0.1:6379

Cache keys follow the pattern rate_limit:{name}:{identifier}:{window} and expire automatically after each window.

Fail-Open Behavior

Rate limiting is designed to never cause application errors:

  • Cache unavailable: Requests are allowed with a warning logged to stderr.
  • Named limiter not registered: Requests are allowed with a warning logged to stderr.
  • Expire call fails: The counter still works; the key may persist longer than intended.

Rate limiting failures never produce 500 errors. The system prioritizes availability over strict enforcement.

MCP Tools

Use list_rate_limiters to inspect all configured rate limiters in the project.

list_rate_limiters

Returns all named rate limiters registered via RateLimiter::define(), including the limiter name, limit values, time windows, and which routes apply the limiter via Throttle::named(). Use this to audit rate limiting coverage before adding new API endpoints.

Database

Ferro provides a SeaORM-based database layer with Laravel-like API, automatic route model binding, fluent query builder, and testing utilities.

Configuration

Environment Variables

Configure database in your .env file:

# Database connection URL (required)
DATABASE_URL=postgres://user:pass@localhost:5432/mydb

# For SQLite:
# DATABASE_URL=sqlite://./database.db

# Connection pool settings
DB_MAX_CONNECTIONS=10
DB_MIN_CONNECTIONS=1
DB_CONNECT_TIMEOUT=30

# Enable SQL query logging
DB_LOGGING=false

Bootstrap Setup

In src/bootstrap.rs, configure the database:

#![allow(unused)]
fn main() {
use ferro::{Config, DB, DatabaseConfig};

pub async fn register() {
    // Register database configuration
    Config::register(DatabaseConfig::from_env());

    // Initialize the database connection
    DB::init().await.expect("Failed to connect to database");
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::{Config, DB, DatabaseConfig};

// Build configuration programmatically
let config = DatabaseConfig::builder()
    .url("postgres://localhost/mydb")
    .max_connections(20)
    .min_connections(5)
    .connect_timeout(60)
    .logging(true)
    .build();

// Initialize with custom config
DB::init_with(config).await?;
}

Basic Usage

Getting a Connection

#![allow(unused)]
fn main() {
use ferro::DB;

// Get the database connection
let conn = DB::connection()?;

// Use with SeaORM queries
let users = User::find().all(conn.inner()).await?;

// Shorthand
let conn = DB::get()?;
}

Checking Connection Status

#![allow(unused)]
fn main() {
use ferro::DB;

if DB::is_connected() {
    let conn = DB::connection()?;
    // Perform database operations
}
}

Models

Ferro provides Laravel-like traits for SeaORM entities.

Defining a Model

#![allow(unused)]
fn main() {
use sea_orm::entity::prelude::*;
use ferro::{Model, ModelMut};

#[derive(Clone, Debug, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
    pub email: String,
    pub created_at: DateTime,
    pub updated_at: DateTime,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

// Add Ferro's Model traits for convenient methods
impl ferro::Model for Entity {}
impl ferro::ModelMut for Entity {}
}

Reading Records

#![allow(unused)]
fn main() {
use ferro::models::user;

// Find all records
let users = user::Entity::all().await?;

// Find by primary key
let user = user::Entity::find_by_pk(1).await?;

// Find or return error
let user = user::Entity::find_or_fail(1).await?;

// Get first record
let first = user::Entity::first().await?;

// Count all records
let count = user::Entity::count_all().await?;

// Check if any exist
if user::Entity::exists_any().await? {
    println!("Users exist!");
}
}

Creating Records

ActiveValue is re-exported from the ferro facade as ferro::ActiveValue, so generated and hand-written code can import it from ferro without a direct sea_orm dependency:

use ferro::ActiveValue; // equivalent to use sea_orm::ActiveValue
#![allow(unused)]
fn main() {
use sea_orm::Set;
use ferro::models::user;

let new_user = user::ActiveModel {
    name: Set("John Doe".to_string()),
    email: Set("john@example.com".to_string()),
    ..Default::default()
};

let user = user::Entity::insert_one(new_user).await?;
println!("Created user with id: {}", user.id);
}

Updating Records

#![allow(unused)]
fn main() {
use ferro::models::user::User;

// Find and update with builder pattern
let user = User::find_or_fail(1).await?;
let updated = user
    .update()
    .set_name("Updated Name")
    .save()
    .await?;
}

The UpdateBuilder tracks which fields were modified and only sends those to the database. It automatically updates the updated_at timestamp.

For nullable fields, use clear_*() to set a column to NULL:

#![allow(unused)]
fn main() {
let updated = user
    .update()
    .clear_bio()
    .save()
    .await?;
}

Deleting Records

#![allow(unused)]
fn main() {
use ferro::models::user;

// Delete by primary key
let rows_deleted = user::Entity::delete_by_pk(1).await?;
println!("Deleted {} rows", rows_deleted);
}

Save (Insert or Update)

#![allow(unused)]
fn main() {
use sea_orm::Set;
use ferro::models::user;

// Save will insert if no primary key, update if present
let model = user::ActiveModel {
    name: Set("Jane Doe".to_string()),
    email: Set("jane@example.com".to_string()),
    ..Default::default()
};

let saved = user::Entity::save_one(model).await?;
}

Query Builder

The fluent query builder provides an Eloquent-like API.

Basic Queries

#![allow(unused)]
fn main() {
use ferro::models::todo::{self, Column};

// Get all records
let todos = todo::Entity::query().all().await?;

// Get first record
let todo = todo::Entity::query().first().await?;

// Get first or error
let todo = todo::Entity::query().first_or_fail().await?;
}

Filtering

#![allow(unused)]
fn main() {
use ferro::models::todo::{self, Column};

// Single filter
let todos = todo::Entity::query()
    .filter(Column::Active.eq(true))
    .all()
    .await?;

// Multiple filters (AND)
let todo = todo::Entity::query()
    .filter(Column::Title.eq("My Task"))
    .filter(Column::Id.gt(5))
    .first()
    .await?;

// Using SeaORM conditions
use sea_orm::Condition;

let todos = todo::Entity::query()
    .filter(
        Condition::any()
            .add(Column::Priority.eq("high"))
            .add(Column::DueDate.lt(chrono::Utc::now()))
    )
    .all()
    .await?;
}

Ordering

#![allow(unused)]
fn main() {
use ferro::models::todo::{self, Column};

// Order ascending
let todos = todo::Entity::query()
    .order_by_asc(Column::Title)
    .all()
    .await?;

// Order descending
let todos = todo::Entity::query()
    .order_by_desc(Column::CreatedAt)
    .all()
    .await?;

// Multiple orderings
let todos = todo::Entity::query()
    .order_by_desc(Column::Priority)
    .order_by_asc(Column::Title)
    .all()
    .await?;
}

Pagination

#![allow(unused)]
fn main() {
use ferro::models::todo;

// Limit results
let todos = todo::Entity::query()
    .limit(10)
    .all()
    .await?;

// Offset and limit (pagination)
let page = 2;
let per_page = 10;

let todos = todo::Entity::query()
    .offset((page - 1) * per_page)
    .limit(per_page)
    .all()
    .await?;
}

Counting and Existence

#![allow(unused)]
fn main() {
use ferro::models::todo::{self, Column};

// Count matching records
let count = todo::Entity::query()
    .filter(Column::Active.eq(true))
    .count()
    .await?;

// Check if any exist
let has_active = todo::Entity::query()
    .filter(Column::Active.eq(true))
    .exists()
    .await?;
}

Advanced Queries

#![allow(unused)]
fn main() {
use ferro::models::todo;

// Get underlying SeaORM Select for advanced operations
let select = todo::Entity::query()
    .filter(Column::Active.eq(true))
    .into_select();

// Use with SeaORM directly
let conn = DB::connection()?;
let todos = select
    .join(JoinType::LeftJoin, todo::Relation::User.def())
    .all(conn.inner())
    .await?;
}

Route Model Binding

Ferro automatically resolves models from route parameters.

Automatic Binding

#![allow(unused)]
fn main() {
use ferro::{handler, json_response, Response};
use ferro::models::user;

// The 'user' parameter is automatically resolved from the route
#[handler]
pub async fn show(user: user::Model) -> Response {
    json_response!({ "name": user.name, "email": user.email })
}

// Route definition: get!("/users/{user}", controllers::user::show)
// The {user} parameter is parsed as the primary key and the model is fetched
}

How It Works

  1. Route parameter {user} is extracted from the URL
  2. The parameter value is parsed as the model's primary key type
  3. The model is fetched from the database
  4. If not found, a 404 response is returned automatically
  5. If the parameter can't be parsed, a 400 response is returned

Custom Route Binding

For custom binding logic, implement the RouteBinding trait:

#![allow(unused)]
fn main() {
use ferro::{RouteBinding, FrameworkError};
use async_trait::async_trait;

#[async_trait]
impl RouteBinding for user::Model {
    fn param_name() -> &'static str {
        "user"
    }

    async fn from_route_param(value: &str) -> Result<Self, FrameworkError> {
        // Custom logic: find by email instead of ID
        let conn = DB::connection()?;
        user::Entity::find()
            .filter(user::Column::Email.eq(value))
            .one(conn.inner())
            .await?
            .ok_or_else(|| FrameworkError::model_not_found("User"))
    }
}
}

Migrations

Ferro uses SeaORM migrations with a timestamp-based naming convention.

Creating Migrations

# Create a new migration
ferro make:migration create_posts_table

# Creates: src/migrations/m20240115_143052_create_posts_table.rs

Migration Structure

#![allow(unused)]
fn main() {
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Posts::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Posts::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(Posts::Title).string().not_null())
                    .col(ColumnDef::new(Posts::Content).text().not_null())
                    .col(
                        ColumnDef::new(Posts::CreatedAt)
                            .timestamp()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .col(
                        ColumnDef::new(Posts::UpdatedAt)
                            .timestamp()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Posts::Table).to_owned())
            .await
    }
}

#[derive(DeriveIden)]
enum Posts {
    Table,
    Id,
    Title,
    Content,
    CreatedAt,
    UpdatedAt,
}
}

Running Migrations

# Run all pending migrations
ferro db:migrate

# Rollback the last batch
ferro db:rollback

# Rollback all and re-run
ferro db:fresh

# Check migration status
ferro db:status

Testing

Ferro provides utilities for isolated database testing.

Test Database

#![allow(unused)]
fn main() {
use ferro::test_database;
use ferro::models::user;

#[tokio::test]
async fn test_create_user() {
    // Creates fresh in-memory SQLite with migrations
    let db = test_database!();

    // Your test code - DB::connection() automatically uses test database
    let new_user = user::ActiveModel {
        name: Set("Test User".to_string()),
        email: Set("test@example.com".to_string()),
        ..Default::default()
    };

    let user = user::Entity::insert_one(new_user).await.unwrap();
    assert!(user.id > 0);

    // Query directly
    let found = user::Entity::find_by_id(user.id)
        .one(db.conn())
        .await
        .unwrap();
    assert!(found.is_some());
}
}

Custom Migrator

#![allow(unused)]
fn main() {
use ferro::TestDatabase;

#[tokio::test]
async fn test_with_custom_migrator() {
    let db = TestDatabase::fresh::<my_crate::CustomMigrator>()
        .await
        .unwrap();

    // Test code here
}
}

Isolation

Each TestDatabase creates a completely isolated in-memory database:

  • Fresh database for each test
  • Migrations are run automatically
  • No interference between tests
  • Automatically cleaned up when dropped

Dependency Injection

Use the Database type alias with dependency injection:

#![allow(unused)]
fn main() {
use ferro::{injectable, Database};

#[injectable]
pub struct CreateUserAction {
    #[inject]
    db: Database,
}

impl CreateUserAction {
    pub async fn execute(&self, email: &str) -> Result<user::Model, Error> {
        let new_user = user::ActiveModel {
            email: Set(email.to_string()),
            ..Default::default()
        };

        new_user.insert(self.db.conn()).await
    }
}
}

Environment Variables Reference

VariableDescriptionDefault
DATABASE_URLDatabase connection URLsqlite://./database.db
DB_MAX_CONNECTIONSMaximum pool connections10
DB_MIN_CONNECTIONSMinimum pool connections1
DB_CONNECT_TIMEOUTConnection timeout (seconds)30
DB_LOGGINGEnable SQL query loggingfalse

Supported Databases

DatabaseURL FormatNotes
PostgreSQLpostgres://user:pass@host:5432/dbRecommended for production
SQLitesqlite://./path/to/db.sqliteGreat for development
SQLite (memory)sqlite::memory:For testing

Best Practices

  1. Use migrations - Never modify database schema manually
  2. Implement Model traits - Get convenient static methods for free
  3. Use QueryBuilder - Cleaner API than raw SeaORM queries
  4. Leverage route binding - Automatic 404 handling for missing models
  5. Test with test_database! - Isolated, repeatable tests
  6. Use dependency injection - Cleaner code with #[inject] db: Database
  7. Enable logging in development - DB_LOGGING=true for debugging
  8. Set appropriate pool sizes - Match your expected concurrency

MCP Tools

Ferro provides seven database-focused MCP tools covering schema inspection, query execution, migration status, model analysis, and relationship mapping.

database_schema

  • Returns: All tables with column names, types, nullability, defaults, and indexes
  • When to use: Understand the current database structure; verify a migration ran correctly; check column types before writing a query

database_query

  • Returns: Query results as JSON rows
  • When to use: Run ad-hoc SELECT queries against the live database to inspect data; debug unexpected values; validate seeded test data. Read-only — does not support INSERT, UPDATE, or DELETE.

list_migrations

  • Returns: All migration files with their status (applied or pending), applied-at timestamp, and the migration name
  • When to use: Check whether pending migrations need to be run; verify the current migration state before deployment

list_models

  • Returns: All SeaORM entity structs found in src/models/, with field names, types, and SeaORM column annotations
  • When to use: Discover all models in the project; find the correct field names before writing a query or resource

explain_model

  • Returns: Detailed breakdown of one model: fields, types, relations, and any Ferro trait implementations (Model, ModelMut, RouteBinding)
  • When to use: Deep-dive into a single model's structure; verify that the correct traits are implemented; understand relation definitions

model_usages

  • Returns: All locations in source code that reference a given model, grouped by file and usage type (query, insert, update, delete, relation)
  • When to use: Understand how widely a model is used before changing its schema; find all query sites when adding a new filter condition

relation_map

  • Returns: A graph of all model relationships: belongs-to, has-many, and many-to-many edges derived from SeaORM Relation enums
  • When to use: Visualize the entity relationship diagram; verify that relations are defined correctly on both sides; plan batch loading strategies to avoid N+1 queries

Atomic Updates

The ferro-orm crate exposes GuardedUpdate<E>, a typed builder that compiles to a single UPDATE … WHERE … SQL statement. It replaces the hand-rolled read → check → write pattern wherever a column's value is conditionally mutated — counter decrements, status transitions, optimistic concurrency checks. The database engine's per-statement atomicity (SQLite serial writer, Postgres READ COMMITTED) is the entire correctness mechanism; GuardedUpdate adds the chainable surface and the rows-affected → GuardedError mapping on top.

The Anti-Pattern: read → check → write

A typical conditional update without GuardedUpdate looks like this:

// Anti-pattern — do not write this.
let unit = inventory_units::Entity::find_by_id(unit_id)
    .one(&conn)
    .await?
    .ok_or(Error::NotFound)?;

if unit.quantity < needed {
    return Err(Error::InsufficientCapacity);
}

let mut active: inventory_units::ActiveModel = unit.into();
active.quantity = Set(active.quantity.unwrap() - needed);
active.update(&conn).await?;

Two concurrent callers can both pass the if unit.quantity < needed check before either writes. Both then write the decrement, and capacity is exceeded. The read-check-write round trip leaves a race window between the SELECT and the UPDATE.

The Replacement: GuardedUpdate

The same operation as a single statement:

use ferro_orm::{GuardedUpdate, ColumnTrait};
use sea_orm::sea_query::Expr;

GuardedUpdate::new(inventory_units::Entity)
    .filter(inventory_units::Column::Id.eq(unit_id))
    .filter(inventory_units::Column::Quantity.gte(needed))
    .set_expr(
        inventory_units::Column::Quantity,
        Expr::col(inventory_units::Column::Quantity).sub(needed),
    )
    .exec_one(&conn)
    .await?;

One round trip, one statement. The database atomically tests both id = unit_id and quantity >= needed; if both hold, the row is decremented in the same statement. If either fails, exec_one returns Err(GuardedError::NoRowsAffected) — capacity is exhausted or the row no longer exists.

API

GuardedUpdate::new(entity)

Construct an empty builder targeting a SeaORM entity.

.filter(condition)

Add a filter expression. Multiple .filter(...) calls are AND-combined onto an internal Condition. Accepts anything implementing IntoCondition — typically Column::field.eq(value) or any SimpleExpr.

.set_expr(column, expression) and .set_value(column, value)

Set a column. set_expr takes a SimpleExpr (for value-derived updates such as Expr::col(Column::Quantity).sub(1)); set_value takes a Value (for literal assignments). Both are chainable; multiple calls set multiple columns in one statement. Insertion order is preserved; later sets to the same column override earlier ones.

.exec_one(&conn) vs .exec_at_most_one(&conn)

Both methods compile and execute the UPDATE against a &impl ConnectionTrait (works equally with &DatabaseConnection or &DatabaseTransaction):

Outcomeexec_oneexec_at_most_one
1 row matchedOk(())Ok(true)
0 rows matchedErr(NoRowsAffected)Ok(false)
>1 rows matchedErr(TooManyRows { affected })Err(TooManyRows { affected })
DB errorErr(Db(_))Err(Db(_))
Empty builder (no set_* calls)Err(EmptyUpdate)Err(EmptyUpdate)

Use exec_one when predicate failure is the operative signal (capacity exhausted, pre-condition unmet) and should surface as an error. Use exec_at_most_one when predicate failure is a normal outcome that should not pollute error logs (e.g., refreshing a session that may have expired).

Common Patterns

Counter decrement

The canonical race-free counter decrement (inventory, queue capacity, rate limit budget):

GuardedUpdate::new(counters::Entity)
    .filter(counters::Column::Id.eq(counter_id))
    .filter(counters::Column::Quantity.gte(needed))
    .set_expr(
        counters::Column::Quantity,
        Expr::col(counters::Column::Quantity).sub(needed),
    )
    .exec_one(&conn)
    .await?;

Status transition

Atomic state machine transition with multi-column update — set the new status and the timestamp in the same statement:

use ferro_orm::Value;
use chrono::Utc;

GuardedUpdate::new(reservations::Entity)
    .filter(reservations::Column::Id.eq(handle_id))
    .filter(reservations::Column::Status.eq("held"))
    .set_value(
        reservations::Column::Status,
        Value::String(Some(Box::new("committed".into()))),
    )
    .set_value(
        reservations::Column::CommittedAt,
        Value::ChronoDateTimeUtc(Some(Box::new(Utc::now()))),
    )
    .exec_one(&conn)
    .await?;

Optimistic update (predicate failure is normal)

Refresh a session's last_seen_at if the token is valid and not expired. If the session expired between issue and now, this is a normal outcome, not an error log:

let updated = GuardedUpdate::new(sessions::Entity)
    .filter(sessions::Column::Token.eq(&token))
    .filter(sessions::Column::ExpiresAt.gt(now))
    .set_value(
        sessions::Column::LastSeenAt,
        Value::ChronoDateTimeUtc(Some(Box::new(now))),
    )
    .exec_at_most_one(&conn)
    .await?;

if !updated {
    return Err(AuthError::SessionExpired);
}

Atomicity Guarantee (and Its Limit)

GuardedUpdate guarantees atomicity per statement, not per builder. The single UPDATE … WHERE … SQL statement is atomic at the database engine level — SQLite's serial writer and Postgres's READ COMMITTED semantics both ensure that the predicate test and the row mutation cannot interleave with another concurrent statement on the same row.

However, a caller building .set_expr(qty - 1) and then reading the resulting qty value in a separate query without a transaction re-introduces a race window between the two queries. The crate's responsibility is to make the conditional UPDATE itself race-free; bracketing it in a transaction (when post-update inspection is required) is the caller's responsibility.

If the post-update row state is needed, wrap the GuardedUpdate and the subsequent SELECT in the same DatabaseTransaction:

use sea_orm::TransactionTrait;

let txn = conn.begin().await?;
GuardedUpdate::new(counters::Entity)
    .filter(counters::Column::Id.eq(1))
    .set_expr(
        counters::Column::Quantity,
        Expr::col(counters::Column::Quantity).sub(1),
    )
    .exec_one(&txn)
    .await?;
let row = counters::Entity::find_by_id(1).one(&txn).await?;
txn.commit().await?;

Errors

VariantWhenNotes
NoRowsAffectedPredicate matched zero rowsThe operative "capacity exhausted" / "pre-condition unmet" signal for exec_one
TooManyRows { affected }Predicate matched more than one rowIndicates the filter is not unique-key-equivalent — typically an index or uniqueness bug at the call site
EmptyUpdateexec_* called with no set_* callsProgramming error; surfaces immediately without touching the database
Db(sea_orm::DbErr)Underlying SeaORM errorConnection failure, constraint violation, deadlock, etc.

Every variant's Display impl is prefixed "guarded: " for log greppability — matches the workspace convention used by other ferro crate error types (e.g. "wallet: ", "config: ").

Postgres vs SQLite

GuardedUpdate is dialect-agnostic; the SQL it emits is standard UPDATE … WHERE …. SQLite enforces per-statement atomicity via its serial writer; Postgres enforces it via READ COMMITTED (the default isolation level) — both are sufficient for the contract.

UPDATE … RETURNING is not currently supported; SeaORM does not yet abstract it cleanly across dialects. Callers needing the post-update row should re-fetch inside the same transaction (see above).

Audit Log

Ferro's ferro-audit crate provides an append-only structured before/after audit log for state-changing operations. Every recorded entry captures what happened — for forensic investigation, regulatory evidence, and state replay. The crate ships a SeaORM migration that consumer apps register in their own Migrator, plus a chainable builder API for recording entries and pure functions for replaying them.

Audit entries are the historical twin of ferro-events: events are "something happened, react now"; audit entries are "something happened, here is the evidence forever".

The Anti-Pattern

Without a structured audit log, applications either log to unstructured text (tracing::info!), persist ad-hoc JSON columns on every domain table, or rely on database transaction logs that disappear when the storage engine rolls. Each approach loses information: unstructured logs are hard to query, ad-hoc JSON columns drift across tables, transaction logs are operational data not preserved evidence.

// Anti-pattern: scattered logging with no structure
tracing::info!(user = %user_id, "decremented inventory unit {} by {}", unit_id, qty);

// Anti-pattern: ad-hoc JSON column on every table
inventory_unit.history.push(json!({ "action": "adjust", "qty": qty, "user": user_id }));

Neither approach lets you answer "what was this unit's history?" without writing custom query code per domain table, and neither preserves before/after state for replay.

The Replacement

ferro-audit provides a typed, queryable audit primitive:

use ferro_audit::{AuditEntry, AuditActor, AuditTarget};
use serde_json::json;

AuditEntry::record("inventory.stock.adjust")
    .actor(AuditActor::User(user_id.to_string()))
    .target(AuditTarget::new("inventory.unit", unit_id.to_string()))
    .before(json!({ "quantity": old }))
    .after(json!({ "quantity": new }))
    .reason("order_committed")
    .write(&conn)
    .await?;

One row in the audit_log table per state change. Query helpers traverse it by target, by actor, or globally. The reconstruct_state helper folds the recorded after payloads into the current state.

API

AuditEntry::record(action) -> AuditEntryBuilder

Entry point. action is a dotted-namespace verb (e.g. "inventory.stock.adjust", "user.password_reset_requested") — required, only validation is non-empty. The builder defaults actor to AuditActor::System and all other fields to None.

Builder methods (all consuming self, returning Self)

MethodEffect
.actor(AuditActor)Set the actor (default AuditActor::System)
.target(AuditTarget)Set the target (optional; missing target emits tracing::warn!)
.before(serde_json::Value)JSON snapshot of state BEFORE the action
.after(serde_json::Value)JSON snapshot of state AFTER the action
.reason(impl Into<String>)Free-text cause / reason
.correlation(Uuid)Caller-supplied correlation id
.tenant(impl Into<String>)Tenant scoping (stringly-typed)
.write(&conn).awaitPersist the entry and return the AuditEntry

Query helpers

HelperReturnsOrder
history_for_target(&target, &conn)Vec<AuditEntry> for the targetcreated_at ASC
recent_by_actor(&actor, &conn, limit)Vec<AuditEntry> for the actorcreated_at DESC
recent(&conn, limit)Vec<AuditEntry> globallycreated_at DESC

For pagination or custom filters, drop down to SeaORM directly via the re-exported AuditLogEntity.

Retention

HelperEffect
prune_older_than(cutoff: NaiveDateTime, &conn)Deletes rows with created_at < cutoff; returns count

AuditActor

Typed enum, stringly-keyed so ferro doesn't bind to a consumer's user-id type:

VariantDB actor_kindDB actor_id
User(String)"user"the contained string
System"system"NULL
Job(String)"job"the contained string (job name)
ApiClient(String)"api_client"the contained string
Anonymous"anonymous"NULL

AuditTarget

Open struct so ferro doesn't bind to a closed set of target types:

pub struct AuditTarget {
    pub kind: String,  // "inventory.unit", "user", "checkout.session"
    pub id: String,    // consumer-stringified primary key
}

The dotted-namespace convention for kind (and for action) is a convention, not enforced at compile time. Use it for consistency across your audit codebase.

Schema

The audit_log table created by CreateAuditLogTable:

ColumnTypeNullableNotes
idUUIDNOClient-generated UUIDv4 at write time
tenant_idVARCHARYESMulti-tenant scoping
actor_kindVARCHARNOsnake_case enum variant name
actor_idVARCHARYESNULL for System / Anonymous
actionVARCHARNORequired verb (dotted namespace)
target_kindVARCHARYESNULL for pure events
target_idVARCHARYESNULL for pure events
beforeJSONYESPre-state snapshot
afterJSONYESPost-state snapshot
reasonVARCHARYESFree-text cause
correlation_idUUIDYESCaller-supplied request correlation
created_atTIMESTAMPNODB-stamped via DEFAULT CURRENT_TIMESTAMP

Indexes:

NameColumns
idx_audit_target(tenant_id, target_kind, target_id, created_at)
idx_audit_actor(tenant_id, actor_kind, actor_id, created_at)

Register the migration in your Migrator:

use ferro_audit::CreateAuditLogTable;

impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(CreateAuditLogTable),
            // ... your app migrations
        ]
    }
}

Replay

history_for_target(&target, &conn) returns the entries in ascending order. Pair it with reconstruct_state to fold the recorded after payloads into the current state:

use ferro_audit::{history_for_target, reconstruct_state, AuditTarget};

let target = AuditTarget::new("inventory.unit", "abc");
let entries = history_for_target(&target, &conn).await?;
let final_state = reconstruct_state(&entries);

The fold is a shallow object merge: keys from newer entries overwrite older keys at the top level. Nested objects and arrays are replaced wholesale, not deep-merged. A consumer needing deep-merge runs its own fold over the Vec<AuditEntry>.

Returns None if the slice is empty or no entry has a non-None after. Returns Some(non_object_value) if any entry's after is a non-object — that value replaces the running state from that point on (useful for "tombstone" patterns like recording after: "DELETED").

Retention and Pruning

Audit trails are evidence. Aggressive pruning is usually wrong. GDPR / privacy law may force retention limits; in that case, 1–3 years is the conventional default. Run prune_older_than from a scheduled job in your application — ferro-queue is the natural fit.

PII responsibility: ferro-audit does NOT automatically redact before / after payloads. The caller must remove or hash PII fields BEFORE passing JSON to the builder. The audit_log table is treated as a normal application table for backup and access-control purposes; design accordingly.

Errors

AuditError variants:

VariantDisplayWhen
MissingActionaudit: action is requiredwrite() called with empty action
Db(DbErr)audit: db error: {0}Underlying SeaORM error (transparent forwarding)
Json(serde_json::Error)audit: json serialization error: {0}Rare; malformed serde_json::Value

Missing target is NOT an error — it emits a tracing::warn! diagnostic and the write succeeds (audit must never refuse a write). Pure events (without a target) are intentional in the model.

Postgres vs SQLite

ferro-audit works against both backends transparently. The before / after columns use SeaORM's json() column type (stored as TEXT in SQLite, native json in Postgres); the id and correlation_id columns use the uuid() column type (TEXT in SQLite, native uuid in Postgres). serde_json::Value round-trips identically on both. Tests run against in-memory SQLite for speed; production deployments on Postgres get the same semantics.

Reservations

ferro-reservation is a generic resource reservation kernel for the Ferro framework. Capacity-constrained apps — booking, ticketing, checkout, queue admission, rate limiting — all need the same primitive: hold N units of a resource for a deadline, then commit or release. This crate provides it as a typed, race-free state machine with automatic audit emission and event broadcast. No hand-rolled read → check → write SQL required.

The Anti-Pattern

Without a kernel, every consumer writes the same fragile code:

// BAD: read-check-write has a race window
let current = db.query("SELECT held FROM inventory WHERE id = ?", id).await?;
if current.held + qty <= capacity {
    db.execute("UPDATE inventory SET held = held + ? WHERE id = ?", qty, id).await?;
} else {
    return Err(NoCapacity);
}

Under concurrent load, two callers can both observe held = 4 (capacity = 5) and both execute the UPDATE, leaving held = 6 > capacity. The fix is structural: fold the check into the UPDATE statement so the database enforces atomicity. ferro-reservation uses [ferro-orm::GuardedUpdate] under the hood to make every state transition race-free by construction.

The Replacement

use ferro_reservation::{ReservationKernel, ReservationContext, Resource, ReleaseReason};
use std::time::Duration;

struct InventoryUnitResource { /* db reference, business rules */ }

#[async_trait::async_trait]
impl Resource for InventoryUnitResource {
    type Key = ProductId;
    type Window = BookingWindow;
    const KIND: &'static str = "inventory.unit";

    async fn capacity<C: ConnectionTrait>(&self, conn: &C, key: &Self::Key, _w: &Self::Window)
        -> Result<u32, ReservationError> { /* ... */ }
    async fn held<C: ConnectionTrait>(&self, conn: &C, key: &Self::Key, w: &Self::Window)
        -> Result<u32, ReservationError> { /* ... */ }
}

// Construct once at application start
let kernel = ReservationKernel::new(db.clone(), InventoryUnitResource::new());

// Hold during payment
let ctx = ReservationContext::user(user_id.to_string()).with_correlation(request_id);
let handle = kernel.hold(&conn, key, window, 1, Duration::from_secs(15 * 60), &ctx).await?;

// Commit or release based on payment outcome
match stripe_result {
    Ok(_)  => kernel.commit(&conn, handle, &ctx).await?,
    Err(_) => kernel.release(&conn, handle, ReleaseReason::PaymentFailed, &ctx).await?,
}

State Diagram

              hold()                  commit()
   ──────────────▶ held ──────────────────────▶ committed
                    │
                    │ release(reason)
                    ▼
                released

              hold()
   ──────────────▶ held ─── ttl ─────────────▶ expired
                                 run_sweep_once()

Terminal states (committed, released, expired) have no outgoing transitions. Any attempt surfaces as ReservationError::ConflictingState.

Resource Trait

Consumers implement Resource to define their capacity model:

#[async_trait]
pub trait Resource: Send + Sync + 'static {
    type Key: Hash + Eq + Clone + Send + Sync + Serialize + DeserializeOwned;
    type Window: PartialEq + Clone + Send + Sync + Serialize + DeserializeOwned;

    const KIND: &'static str;

    async fn capacity<C: ConnectionTrait>(
        &self,
        conn: &C,
        key: &Self::Key,
        window: &Self::Window,
    ) -> Result<u32, ReservationError>;

    async fn held<C: ConnectionTrait>(
        &self,
        conn: &C,
        key: &Self::Key,
        window: &Self::Window,
    ) -> Result<u32, ReservationError>;
}
  • Key identifies a resource instance (ProductId, ShowId, ApiClientId, ...).
  • Window scopes capacity (a date+time range for booking, () for non-windowed resources).
  • KIND is a &'static str dotted-namespace constant — convention mirrors ferro-audit's action/target convention ("inventory.unit", "checkout.slot", "api.quota").

Multi-tenancy is a Key concern: include the tenant identifier in Key, not as a kernel-level parameter. The kernel does not scope queries by tenant automatically.

Lifecycle Methods

MethodTransitionReturns
hold(&conn, key, window, qty, ttl, ctx)(none) → heldReservationHandle
commit(&conn, handle, ctx)held → committed()
release(&conn, handle, reason, ctx)held → released()
extend(&conn, handle, by, ctx)held → held (new expires_at)()
run_sweep_once()held → expired (TTL)SweepReport

handle is taken by value in commit / release / extend — use-once is a compile-time guarantee. Reusing a handle after commit or release is a compile error.

hold sequence:

  1. Call R::capacity(&conn, &key, &window).
  2. Call R::held(&conn, &key, &window).
  3. If held + quantity > capacityErr(Insufficient { requested, available, capacity }).
  4. INSERT one reservations row with status = 'held', expires_at = now() + ttl.
  5. Write one AuditEntry with action = "reservation.held" via ferro-audit.
  6. Emit ReservationEvent::Held via ferro-events.
  7. Return ReservationHandle.

The capacity check, the INSERT, and the audit write all execute inside a single SERIALIZABLE transaction. The kernel atomically arbitrates concurrent holds at the database level — no application-layer mutex is required. The conflict-losing task receives ReservationError::Insufficient. See the Consistency Model section.

ReservationContext

Per-call audit metadata bundle:

pub struct ReservationContext {
    pub actor: AuditActor,
    pub correlation_id: Option<Uuid>,
    pub tenant_id: Option<String>,
    pub reason: Option<String>,
}

impl ReservationContext {
    pub fn system() -> Self;
    pub fn user(user_id: impl Into<String>) -> Self;
    pub fn job(name: impl Into<String>) -> Self;
    pub fn anonymous() -> Self;

    pub fn with_correlation(self, id: Uuid) -> Self;
    pub fn with_tenant(self, t: impl Into<String>) -> Self;
    pub fn with_reason(self, r: impl Into<String>) -> Self;
}

The actor is recorded on every audit entry written during the state transition. The optional fields propagate to the audit log when populated. Builder methods follow the consuming mut self → Self convention used across the framework.

TTL and the Sweeper

hold(...) accepts a ttl: Duration. The persisted expires_at is computed at hold time as now() + ttl. run_sweep_once() scans for held rows whose expires_at is in the past, transitions them to expired, and emits one audit entry and one event per row.

The crate has no ferro-queue runtime dependency — consumers schedule sweeps with their preferred mechanism. Three idiomatic patterns:

Pattern 1: ferro-queue Job

struct SweepReservations;

impl ferro_queue::Job for SweepReservations {
    async fn handle(self) -> Result<(), Error> {
        KERNEL.run_sweep_once().await?;
        Ok(())
    }
}

// Schedule every 60 seconds via your queue's recurring-job API

Pattern 2: tokio::time::interval

tokio::spawn(async {
    let mut tick = tokio::time::interval(Duration::from_secs(60));
    loop {
        tick.tick().await;
        if let Err(e) = KERNEL.run_sweep_once().await {
            tracing::warn!(error = %e, "sweep failed");
        }
    }
});

Pattern 3: Cron-driven CLI

// e.g. your-app reservation:sweep
fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        KERNEL.run_sweep_once().await.expect("sweep");
    });
}

SweepReport { expired_count, scanned_at } is returned for observability. A consistent non-zero expired_count across successive sweeps indicates a backlog; schedule sweeps more frequently. The sweeper processes at most 500 rows per call; subsequent calls drain the backlog.

The sweeper is idempotent under concurrent execution. If two sweeper tasks race on the same expired row, only one wins the guarded held → expired transition; the other gets zero rows affected and skips that row silently.

ReservationEvent Subscription

Every state transition dispatches a ReservationEvent via ferro-events. Subscribe with ferro_events::global_dispatcher():

ferro_events::global_dispatcher()
    .on::<ReservationEvent, _, _>(|event: ReservationEvent| {
        Box::pin(async move {
            match event {
                ReservationEvent::Held { id, quantity, .. } => {
                    // notify a live dashboard, update a cache, etc.
                }
                ReservationEvent::Committed { id, .. } => { /* ... */ }
                ReservationEvent::Released { id, reason, .. } => { /* ... */ }
                ReservationEvent::Expired { id, .. } => { /* ... */ }
            }
            Ok(())
        })
    });

Event dispatch is best-effort. If dispatch fails, the kernel logs at tracing::warn! and returns Ok(()) — the state change is already committed to the database. Consumers needing durable replay use the audit log.

Audit Log Inspection

Every state transition writes one [ferro_audit::AuditEntry] with action = "reservation.{held|committed|released|expired|extended}" and target = AuditTarget::new("reservation", id.to_string()). The full history for a reservation:

let target = ferro_audit::AuditTarget::new("reservation", id.to_string());
let history = ferro_audit::history_for_target(&target, &conn).await?;
let final_state = ferro_audit::reconstruct_state(&history);

reconstruct_state performs a shallow merge of after payloads in created_at ASC order; the resulting value reflects the most recently written state fields.

Audit emission is unconditional. If AuditEntry::write fails (e.g., the audit_log table is missing), the kernel returns ReservationError::Audit but the DB state transition is already committed. See Operational Footguns.

Common Patterns

Slot hold during online checkout

Resource::Key = ProductId. Hold for 15 minutes during Stripe payment; on payment_intent.succeeded webhook commit, on payment_intent.payment_failed release with ReleaseReason::PaymentFailed. If the user abandons, the sweeper transitions the row to expired automatically.

Ticket reservations

Resource::Key = ShowId, Resource::Window = (). capacity = venue.seat_count, held = sum of held + committed reservations. A short TTL (e.g., 5 minutes) prevents zombie holds from blocking legitimate purchasers.

API rate-limit buckets

Resource::Key = ApiClientId, Resource::Window = MinuteBucket. capacity = client.rate_limit, held = requests_in_bucket. The kernel rejects holds beyond capacity with Insufficient. Combine with run_sweep_once on a per-minute cron to garbage-collect expired buckets.

Schema

The migration creates a single reservations table:

-- 12 columns
id              UUID PRIMARY KEY DEFAULT gen_random_uuid()  -- client-generated UUIDv4
resource_kind   VARCHAR NOT NULL      -- "inventory.unit", "checkout.slot"
resource_key    JSON NOT NULL         -- serialized Resource::Key
window          JSON NULL             -- serialized Resource::Window; NULL when Window = ()
quantity        INTEGER NOT NULL
status          VARCHAR NOT NULL      -- "held" | "committed" | "released" | "expired"
expires_at      TIMESTAMP NOT NULL
held_at         TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
committed_at    TIMESTAMP NULL
released_at     TIMESTAMP NULL
release_reason  VARCHAR NULL
tenant_id       VARCHAR NULL

-- 2 indexes
idx_reservations_kind_key_window_status  -- (resource_kind, resource_key, window, status)
idx_reservations_status_expires           -- (status, expires_at) — sweeper scan path

Register the migration in your Migrator alongside ferro-audit's:

impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(ferro_audit::CreateAuditLogTable),
            Box::new(ferro_reservation::CreateReservationsTable),
            // ... your app migrations
        ]
    }
}

Errors

ReservationError is the single error enum:

VariantWhen
Insufficient { requested, available, capacity }Hold rejected — not enough capacity
ConflictingState { id, expected }State-transition predicate failed (row already committed, released, or expired; or never existed)
NotFound { id }Introspection or debug paths
Db(#[from] sea_orm::DbErr)Underlying database error
Guarded(#[from] ferro_orm::GuardedError)Guarded-update error other than NoRowsAffected (programming error)
Audit(#[from] ferro_audit::AuditError)Audit emission failed AFTER state was already committed
Json(#[from] serde_json::Error)Resource::Key or Window serialization failure

Display prefix is "reservation: …" for log greppability across the workspace.

Consistency Model

State transitions (commit, release, extend) are race-free on both SQLite and Postgres: each is a single UPDATE … WHERE status = 'held' AND id = ? statement executed via [ferro-orm::GuardedUpdate]. Per-statement atomicity ensures concurrent callers cannot both succeed on the same row; one wins and the other gets ConflictingState.

hold: The capacity check, INSERT, and audit write execute inside a SERIALIZABLE transaction (sea_orm::IsolationLevel::Serializable). On SQLite the transaction aligns with the WAL single-writer model; on Postgres it prevents phantom reads between the SELECT and INSERT. If two concurrent tasks race on the same (key, window), the database serializes them — exactly one succeeds and the other receives ReservationError::Insufficient. No application-layer mutex is needed.

A conflict-losing task on Postgres may receive SQLSTATE 40001 (serialization failure); the kernel translates this to ReservationError::Insufficient before returning to the caller. The error contract is uniform across backends.

commit, release, and extend via GuardedUpdate are race-free on both dialects (single UPDATE … WHERE statement).

Operational Footguns

  1. Audit failure does not roll back state. If AuditEntry::write fails for any reason (database connection lost, audit_log table missing), the kernel returns ReservationError::Audit AFTER the DB row has already been transitioned. The state change is committed; only the audit record is missing. Monitor for ReservationError::Audit in your error-handling layer.

  2. Event dispatch is best-effort. ferro_events::dispatch failure logs at tracing::warn! but does not propagate. Use the audit log for durable replay of state transitions.

  3. No upper cap on extend. A held reservation can be extended indefinitely. Consumers wanting a hard TTL ceiling must enforce it at the call site before calling extend.

Derive Macros

Ferro provides two derive macros that eliminate boilerplate for database models and validation. FerroModel scaffolds CRUD operations on SeaORM entities; ValidateRules co-locates validation rules with struct field definitions.

FerroModel

FerroModel generates create, update, delete, and query methods from a SeaORM entity model struct. Place it alongside DeriveEntityModel in the derive list.

Entity Definition

#![allow(unused)]
fn main() {
use ferro::FerroModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize, FerroModel)]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
    pub body: String,
    pub published: bool,
    pub author_id: i32,
    pub slug: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
}

Generated API

Create — returns a builder with a setter for each non-primary-key field:

#![allow(unused)]
fn main() {
let post = Post::create()
    .set_title("Hello")
    .set_body("World")
    .set_published(false)
    .set_author_id(1)
    .insert()
    .await?;
}

Update — selectively updates fields on an existing model instance:

#![allow(unused)]
fn main() {
let post = post.update()
    .set_title("Updated Title")
    .save()
    .await?;
}

Clear optional fieldsclear_<field>() sets a nullable field to None:

#![allow(unused)]
fn main() {
let post = post.update()
    .clear_slug()
    .save()
    .await?;
}

Delete — removes the row from the database:

#![allow(unused)]
fn main() {
post.delete().await?;
}

Query — returns a SeaORM Select<Entity> for building queries:

#![allow(unused)]
fn main() {
let published_posts = Post::query()
    .filter(Column::Published.eq(true))
    .all(&db)
    .await?;
}

Generated Methods

MethodReturnsDescription
Entity::create()CreateBuilderBuilder for inserting a new row
instance.update()UpdateBuilderBuilder for selectively updating fields
instance.delete()ResultDeletes the row from the database
Entity::query()Select<Entity>SeaORM Select for building queries

The primary key field is excluded from CreateBuilder. Option<T> fields gain both set_<field>() and clear_<field>() on UpdateBuilder.

ValidateRules

ValidateRules generates a .validate() method from #[rule(...)] attributes on struct fields. It uses Ferro's built-in rule functions — the same functions available in the fluent Validator::new() API (documented in Validation).

Note: #[rule(...)] is Ferro's own attribute syntax. It is not the same as the #[validate(...)] attribute from the validator crate.

Struct Definition

#![allow(unused)]
fn main() {
use ferro::ValidateRules;

#[derive(ValidateRules)]
struct RegistrationRequest {
    #[rule(required, email)]
    email: String,

    #[rule(required, min(8))]
    password: String,

    #[rule(required, string, min(2), max(50))]
    name: String,

    #[rule(required, integer, min(18), max(120))]
    age: i32,

    #[rule(nullable, url)]
    website: Option<String>,
}
}

Usage

#![allow(unused)]
fn main() {
let req = RegistrationRequest { /* ... */ };
req.validate()?;  // Returns Result<(), ValidationErrors>
}

The .validate() method returns Ok(()) when all rules pass, or Err(ValidationErrors) with per-field error messages when any rule fails.

Available Rules

RuleDescriptionExample
requiredField must be present and non-empty#[rule(required)]
emailMust be a valid email address#[rule(required, email)]
stringMust be a string value#[rule(string)]
integerMust be an integer value#[rule(integer)]
min(n)Minimum value or minimum string length#[rule(min(8))]
max(n)Maximum value or maximum string length#[rule(max(100))]
between(a, b)Value between a and b inclusive#[rule(between(1, 10))]
urlMust be a valid URL#[rule(url)]
nullableField may be None; skips other rules when absent#[rule(nullable, url)]

Rules are evaluated in declaration order. For Option<T> fields, use nullable first to skip subsequent rules when the value is None.

See Also

  • Database — SeaORM entity setup and query patterns
  • Validation — fluent Validator::new() API

MCP Tools

Use explain_model to inspect the generated CRUD API for a FerroModel-derived entity.

explain_model

Returns the full structure of a SeaORM entity, including which fields are included in the CreateBuilder and UpdateBuilder, optional fields that gain clear_*() methods, and any RouteBinding implementation. This is the same tool documented in Database — it works on any entity, whether or not it uses #[derive(FerroModel)].

Validation

Ferro provides a powerful validation system with a fluent API, built-in rules, custom messages, and automatic request validation through Form Requests.

Basic Usage

Creating a Validator

#![allow(unused)]
fn main() {
use ferro::Validator;

let data = serde_json::json!({
    "name": "John Doe",
    "email": "john@example.com",
    "age": 25
});

let errors = Validator::new()
    .rule("name", rules![required(), string(), min(2)])
    .rule("email", rules![required(), email()])
    .rule("age", rules![required(), integer(), between(18, 120)])
    .validate(&data);

if errors.is_empty() {
    println!("Validation passed!");
} else {
    println!("Errors: {:?}", errors.all());
}
}

Quick Validation

#![allow(unused)]
fn main() {
use ferro::validate;

let data = serde_json::json!({
    "email": "invalid-email"
});

let errors = validate(&data, vec![
    ("email", rules![required(), email()]),
]);

if errors.fails() {
    for (field, messages) in errors.all() {
        println!("{}: {:?}", field, messages);
    }
}
}

Built-in Rules

Required Rules

#![allow(unused)]
fn main() {
// rules like required(), string(), etc. are available directly (no import needed)

// Field must be present and not empty
required()

// Field required only if another field has a specific value
required_if("role", "admin")
}

Type Rules

#![allow(unused)]
fn main() {
// Must be a string
string()

// Must be an integer
integer()

// Must be numeric (integer or float)
numeric()

// Must be a boolean
boolean()

// Must be an array
array()
}

Size Rules

#![allow(unused)]
fn main() {
// Minimum length (strings) or value (numbers)
min(5)

// Maximum length (strings) or value (numbers)
max(100)

// Between minimum and maximum (inclusive)
between(1, 10)
}

Format Rules

#![allow(unused)]
fn main() {
// Valid email address
email()

// Valid URL
url()

// Matches a regex pattern
regex(r"^[A-Z]{2}\d{4}$")

// Only alphabetic characters
alpha()

// Only alphanumeric characters
alpha_num()

// Alphanumeric, dashes, and underscores
alpha_dash()

// Valid date (YYYY-MM-DD format)
date()
}

Comparison Rules

#![allow(unused)]
fn main() {
// Must match {field}_confirmation
confirmed()

// Must be one of the specified values
in_array(vec!["active", "inactive", "pending"])

// Must NOT be one of the specified values
not_in(vec!["admin", "root"])

// Must be different from another field
different("old_password")

// Must be the same as another field
same("password")
}

Special Rules

#![allow(unused)]
fn main() {
// Field can be null/missing (stops validation if null)
nullable()

// Must be "yes", "on", "1", or true
accepted()
}

Validation Examples

User Registration

#![allow(unused)]
fn main() {
use ferro::Validator;

let data = serde_json::json!({
    "username": "johndoe",
    "email": "john@example.com",
    "password": "secret123",
    "password_confirmation": "secret123",
    "age": 25,
    "terms": "yes"
});

let errors = Validator::new()
    .rule("username", rules![required(), string(), min(3), max(20), alpha_dash()])
    .rule("email", rules![required(), email()])
    .rule("password", rules![required(), string(), min(8), confirmed()])
    .rule("age", rules![required(), integer(), between(13, 120)])
    .rule("terms", rules![accepted()])
    .validate(&data);
}

Nested Data Validation

Use dot notation to validate nested JSON structures:

#![allow(unused)]
fn main() {
let data = serde_json::json!({
    "user": {
        "profile": {
            "name": "John",
            "bio": "Developer"
        }
    },
    "settings": {
        "notifications": true
    }
});

let errors = Validator::new()
    .rule("user.profile.name", rules![required(), string(), min(2)])
    .rule("user.profile.bio", rules![nullable(), string(), max(500)])
    .rule("settings.notifications", rules![required(), boolean()])
    .validate(&data);
}

Conditional Validation

#![allow(unused)]
fn main() {
let data = serde_json::json!({
    "type": "business",
    "company_name": "Acme Corp",
    "tax_id": "123456789"
});

let errors = Validator::new()
    .rule("type", rules![required(), in_array(vec!["personal", "business"])])
    .rule("company_name", rules![required_if("type", "business"), string()])
    .rule("tax_id", rules![required_if("type", "business"), string()])
    .validate(&data);
}

Custom Messages

Override default error messages for specific fields and rules:

#![allow(unused)]
fn main() {
let errors = Validator::new()
    .rule("email", rules![required(), email()])
    .rule("password", rules![required(), min(8)])
    .message("email.required", "Please provide your email address")
    .message("email.email", "The email format is invalid")
    .message("password.required", "Password is required")
    .message("password.min", "Password must be at least 8 characters")
    .validate(&data);
}

Custom Attributes

Replace field names in error messages with friendlier names:

#![allow(unused)]
fn main() {
let errors = Validator::new()
    .rule("dob", rules![required(), date()])
    .rule("cc_number", rules![required(), string()])
    .attribute("dob", "date of birth")
    .attribute("cc_number", "credit card number")
    .validate(&data);

// Error: "The date of birth field is required"
// Instead of: "The dob field is required"
}

Validation Errors

The ValidationError type collects and manages validation errors:

#![allow(unused)]
fn main() {
use ferro::ValidationError;

let errors: ValidationError = validator.validate(&data);

// Check if validation failed
if errors.fails() {
    // Get first error for a field
    if let Some(message) = errors.first("email") {
        println!("Email error: {}", message);
    }

    // Get all errors for a field
    if let Some(messages) = errors.get("password") {
        for msg in messages {
            println!("Password: {}", msg);
        }
    }

    // Check if specific field has errors
    if errors.has("username") {
        println!("Username has validation errors");
    }

    // Get all errors as HashMap
    let all_errors = errors.all();

    // Get total error count
    println!("Total errors: {}", errors.count());

    // Convert to JSON for API responses
    let json = errors.to_json();
}
}

JSON Error Response

#![allow(unused)]
fn main() {
use ferro::{Response, json_response};

if errors.fails() {
    return json_response!(422, {
        "message": "Validation failed",
        "errors": errors
    });
}
}

Form Requests

Form Requests provide automatic validation and authorization for HTTP requests.

Defining a Form Request

#![allow(unused)]
fn main() {
use ferro::FormRequest;
use serde::Deserialize;
use validator::Validate;

#[derive(Debug, Deserialize, Validate)]
pub struct CreateUserRequest {
    #[validate(length(min = 2, max = 50))]
    pub name: String,

    #[validate(email)]
    pub email: String,

    #[validate(length(min = 8))]
    pub password: String,

    #[validate(range(min = 13, max = 120))]
    pub age: Option<i32>,
}

impl FormRequest for CreateUserRequest {}
}

Using Form Requests in Handlers

#![allow(unused)]
fn main() {
use ferro::{handler, Response, json_response};
use crate::requests::CreateUserRequest;

#[handler]
pub async fn store(request: CreateUserRequest) -> Response {
    // Request is automatically validated
    // If validation fails, 422 response is returned

    let user = User::create(
        &request.name,
        &request.email,
        &request.password,
    ).await?;

    json_response!(201, { "user": user })
}
}

Authorization

Override the authorize method to add authorization logic:

#![allow(unused)]
fn main() {
use ferro::{FormRequest, Request};

impl FormRequest for UpdatePostRequest {
    fn authorize(req: &Request) -> bool {
        // Check if user can update the post
        if let Some(user) = req.user() {
            if let Some(post_id) = req.param("post") {
                return user.can_edit_post(post_id);
            }
        }
        false
    }
}
}

Validation Attributes

The validator crate provides these validation attributes:

#![allow(unused)]
fn main() {
#[derive(Deserialize, Validate)]
pub struct ExampleRequest {
    // Length validation
    #[validate(length(min = 1, max = 100))]
    pub title: String,

    // Email validation
    #[validate(email)]
    pub email: String,

    // URL validation
    #[validate(url)]
    pub website: Option<String>,

    // Range validation
    #[validate(range(min = 0, max = 100))]
    pub score: i32,

    // Regex validation
    #[validate(regex(path = "RE_PHONE"))]
    pub phone: String,

    // Custom validation
    #[validate(custom(function = "validate_username"))]
    pub username: String,

    // Nested validation
    #[validate(nested)]
    pub address: Address,

    // Required (use Option for optional fields)
    pub required_field: String,
    pub optional_field: Option<String>,
}

// Define regex patterns
lazy_static! {
    static ref RE_PHONE: Regex = Regex::new(r"^\+?[1-9]\d{1,14}$").expect("valid regex pattern");
}

// Custom validation function
fn validate_username(username: &str) -> Result<(), validator::ValidationError> {
    if username.contains("admin") {
        return Err(validator::ValidationError::new("username_reserved"));
    }
    Ok(())
}
}

Custom Rules

Create custom validation rules by implementing the Rule trait:

#![allow(unused)]
fn main() {
use ferro::Rule;
use serde_json::Value;

pub struct Uppercase;

impl Rule for Uppercase {
    fn validate(&self, field: &str, value: &Value, _data: &Value) -> Result<(), String> {
        match value {
            Value::String(s) => {
                if s.chars().all(|c| c.is_uppercase() || !c.is_alphabetic()) {
                    Ok(())
                } else {
                    Err(format!("The {} field must be uppercase.", field))
                }
            }
            _ => Err(format!("The {} field must be a string.", field)),
        }
    }

    fn name(&self) -> &'static str {
        "uppercase"
    }
}

// Usage
let errors = Validator::new()
    .rule("code", rules![required(), Uppercase])
    .validate(&data);
}

Custom Rule with Parameters

#![allow(unused)]
fn main() {
pub struct StartsWithRule {
    prefix: String,
}

impl StartsWithRule {
    pub fn new(prefix: impl Into<String>) -> Self {
        Self { prefix: prefix.into() }
    }
}

impl Rule for StartsWithRule {
    fn validate(&self, field: &str, value: &Value, _data: &Value) -> Result<(), String> {
        match value {
            Value::String(s) => {
                if s.starts_with(&self.prefix) {
                    Ok(())
                } else {
                    Err(format!("The {} field must start with {}.", field, self.prefix))
                }
            }
            _ => Err(format!("The {} field must be a string.", field)),
        }
    }

    fn name(&self) -> &'static str {
        "starts_with"
    }
}

// Helper function
pub fn starts_with(prefix: impl Into<String>) -> StartsWithRule {
    StartsWithRule::new(prefix)
}

// Usage
let errors = Validator::new()
    .rule("product_code", rules![required(), starts_with("PRD-")])
    .validate(&data);
}

API Validation Pattern

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, json_response, Validator};

#[handler]
pub async fn store(req: Request) -> Response {
    let data: serde_json::Value = req.json().await?;

    let errors = Validator::new()
        .rule("title", rules![required(), string(), min(1), max(200)])
        .rule("content", rules![required(), string()])
        .rule("status", rules![required(), in_array(vec!["draft", "published"])])
        .rule("tags", rules![nullable(), array()])
        .message("title.required", "Please provide a title")
        .message("content.required", "Content cannot be empty")
        .validate(&data);

    if errors.fails() {
        return json_response!(422, {
            "message": "The given data was invalid.",
            "errors": errors
        });
    }

    // Proceed with valid data
    let post = Post::create(&data).await?;
    json_response!(201, { "post": post })
}
}

Rules Reference

RuleDescriptionExample
required()Field must be present and not emptyrequired()
required_if(field, value)Required if another field equals valuerequired_if("type", "business")
string()Must be a stringstring()
integer()Must be an integerinteger()
numeric()Must be numericnumeric()
boolean()Must be a booleanboolean()
array()Must be an arrayarray()
min(n)Minimum length/valuemin(8)
max(n)Maximum length/valuemax(255)
between(min, max)Value between min and maxbetween(1, 100)
email()Valid email formatemail()
url()Valid URL formaturl()
regex(pattern)Matches regex patternregex(r"^\d{5}$")
alpha()Only alphabetic charactersalpha()
alpha_num()Only alphanumericalpha_num()
alpha_dash()Alphanumeric, dashes, underscoresalpha_dash()
date()Valid date (YYYY-MM-DD)date()
confirmed()Must match {field}_confirmationconfirmed()
in_array(values)Must be one of valuesin_array(vec!["a", "b"])
not_in(values)Must not be one of valuesnot_in(vec!["x", "y"])
different(field)Must differ from fielddifferent("old_email")
same(field)Must match fieldsame("password")
nullable()Can be null (stops if null)nullable()
accepted()Must be "yes", "on", "1", trueaccepted()

Async Rules (DB-backed)

Some validation rules must query the database — for example, checking whether a slug is already taken. These run through AsyncValidator and validate_async, a parallel path that leaves the synchronous Validator API unchanged.

Create Form (No Exclude-Self)

On a create form every submitted value must be globally unique:

#![allow(unused)]
fn main() {
use ferro::{AsyncValidator, AsyncValidationError, unique, rules, required, string};

let data = req.input::<serde_json::Value>().await?;

match AsyncValidator::new(&data)
    .rules("slug", rules![required(), string()])
    .async_rule("slug", unique("pages", "slug"))
    .validate_async()
    .await
{
    Ok(()) => { /* proceed to insert */ }
    Err(AsyncValidationError::Validation(e)) => {
        return Err(e.with_old_input(&data).into_action_error("/pages/new"));
    }
    Err(AsyncValidationError::Infra(fe)) => return Err(fe.into()),
}
}

Sync rules run first; an async rule is skipped for any field that already failed a sync rule (fail-fast — no needless DB query). A Validation error surfaces under the field with old input preserved (303 redirect-back); an Infra error is a 500, never a silent validation pass.

Exclude-Self on Edit Forms

On edit forms the record being edited would fail its own current value. .ignore(id) excludes the record from the uniqueness check:

#![allow(unused)]
fn main() {
// Edit form: allow record #42 to keep its current slug.
.async_rule("slug", unique("pages", "slug").ignore(record_id))
}

Use .ignore_on("uuid", id) when the primary key column is not named id.

The async rule is the proactive layer. It cannot eliminate the check-then-write race under concurrency — pair it with the defensive Constraint Mapping layer at the write site.

Constraint Mapping

Even with the proactive async rule, two requests can both pass the uniqueness check and one loses the INSERT race (TOCTOU). ConstraintMap maps the resulting DB UNIQUE violation to the same field-level error instead of leaking a raw SQL error to the caller.

This is the defensive complement to the proactive Async Rules layer — use both together.

#![allow(unused)]
fn main() {
use ferro::{ConstraintMap, MapConstraintExt};

let map = ConstraintMap::new()
    .on("pages_slug_unique", "slug", "has already been taken")
    .sqlite("pages.slug");

let page = new_page
    .insert(db.inner())
    .await
    .map_constraint(&map, &data, "/pages/new")?;
}

Two-layer rationale: the proactive unique rule catches the common case before the write (good UX — inline error, no wasted INSERT); the defensive ConstraintMap closes the TOCTOU race at the write (concurrency safety net). A non-matching DbErr falls through map_constraint unchanged to the existing From<DbErr> for ActionError passthrough — no error is ever swallowed.

Note: map_constraint is implemented on Result<T, sea_orm::DbErr>, so the write must be a SeaORM-native call (.insert(...) / .update(...) on an ActiveModel), not the framework-wrapped .save().

Postgres vs SQLite identity: Postgres matches by the structured constraint NAME (the .on("pages_slug_unique", ...) key); SQLite matches by table.column from the error message (the .sqlite("pages.slug") discriminator). One registration covers both backends by chaining both.

Best Practices

  1. Use Form Requests for complex validation - Keeps controllers clean
  2. Provide custom messages - User-friendly error messages improve UX
  3. Use custom attributes - Replace technical field names with readable ones
  4. Validate early - Fail fast with clear error messages
  5. Use nullable() for optional fields - Prevents errors on missing optional data
  6. Create custom rules - Reuse validation logic across the application
  7. Return 422 status - Standard HTTP status for validation errors
  8. Structure errors as JSON - Easy to consume by frontend applications

MCP Tools

Use code_templates with the validation category to generate validator boilerplate without memorizing rule names. For handlers with unique fields, use category: "handler" — the action_handler template demonstrates the full two-layer pattern (proactive AsyncValidator + unique async rule, defensive ConstraintMap at the write site) so an agent scaffolds both layers together.

code_templates

Returns ready-to-use code snippets for common validation patterns. Pass category: "validation" to get templates for the fluent Validator::new() API, Form Requests with the validator crate, and custom rule implementations. Pass category: "handler" to get the action_handler template, which shows both the proactive unique async rule and the defensive ConstraintMap write-site guard — ensuring neither layer is omitted when scaffolding a handler for a unique field. Useful when setting up validation for a new handler quickly.

Localization

Ferro provides JSON-based localization via the ferro-lang crate with per-request locale detection, parameter interpolation, pluralization, and automatic validation message translation.

Configuration

Environment Variables

Configure localization in your .env file:

VariableDefaultDescription
APP_LOCALEenDefault locale
APP_FALLBACK_LOCALEenFallback when key missing in requested locale
LANG_PATHlangDirectory containing translation files

Programmatic Configuration

Override defaults in your config_fn() before Application::run():

#![allow(unused)]
fn main() {
use ferro::{Config, LangConfig};

pub fn config_fn() {
    Config::register(LangConfig::builder()
        .locale("es")
        .fallback_locale("en")
        .path("resources/lang")
        .build());
}
}

Unset builder fields fall back to environment variables automatically.

Directory Structure

Translation files are organized by locale, with each locale in its own subdirectory:

lang/
  en/
    app.json
    validation.json
  es/
    app.json
    validation.json

JSON files support nested objects, flattened via dot notation:

{
  "auth": {
    "login": "Log in",
    "register": "Create account"
  }
}

Accessed as t("auth.login", &[]).

Multiple JSON files per locale are merged. File names are arbitrary -- use them to organize by domain (e.g., app.json, validation.json, auth.json).

Translation Helpers

t() / trans()

Basic translation with parameter interpolation:

#![allow(unused)]
fn main() {
use ferro::t;

// Simple translation
let msg = t("welcome", &[]);

// With parameters -- :param syntax
let msg = t("greeting", &[("name", "Alice")]);
// "Hello, :name!" becomes "Hello, Alice!"
}

trans() is an alias for t() for those who prefer the longer name.

Parameter Interpolation

Parameters use the :param placeholder syntax with three case variants:

  • :name -- value as-is
  • :Name -- first character uppercased
  • :NAME -- entire value uppercased
#![allow(unused)]
fn main() {
// Given translation: ":name :Name :NAME"
let msg = t("example", &[("name", "alice")]);
// Result: "alice Alice ALICE"
}

Parameters are processed longest-key-first to avoid partial replacement (e.g., :username is replaced before :user). Missing placeholders are left as-is.

lang_choice()

Pluralized translation:

#![allow(unused)]
fn main() {
use ferro::lang_choice;

let msg = lang_choice("items.count", 1, &[]);  // "One item"
let msg = lang_choice("items.count", 5, &[]);  // "5 items"
}

A :count parameter is added automatically.

Pluralization

Simple Forms

Pipe-separated values where count of 1 selects the first form, everything else selects the second:

{
  "items.count": "One item|:count items"
}

Explicit Ranges

For finer control, use range syntax:

{
  "cart.summary": "{0} Your cart is empty|{1} One item|[2,*] :count items"
}

Range syntax:

SyntaxMeaning
{N}Exact match for count N
[N,M]Inclusive range N through M
[N,*]N or more
Plain pipe |First form for count=1, second for everything else

Example

{
  "apples": "{0} No apples|{1} One apple|[2,5] A few apples|[6,*] :count apples"
}
#![allow(unused)]
fn main() {
lang_choice("apples", 0, &[]);   // "No apples"
lang_choice("apples", 1, &[]);   // "One apple"
lang_choice("apples", 3, &[]);   // "A few apples"
lang_choice("apples", 10, &[]);  // "10 apples"
}

Locale Detection

The LangMiddleware detects locale per-request with this priority:

  1. ?locale=xx query parameter (explicit override)
  2. Accept-Language header (first language tag)
  3. APP_LOCALE default from config

Setup

Register the middleware globally:

#![allow(unused)]
fn main() {
use ferro::{global_middleware, LangMiddleware};

pub fn register() {
    global_middleware!(LangMiddleware);
}
}

Manual Override in Handlers

#![allow(unused)]
fn main() {
use ferro::{locale, set_locale};

#[handler]
pub async fn show(req: Request) -> Response {
    let current = locale();  // e.g. "en"
    set_locale("fr");        // override for this request
    // subsequent t() calls use "fr"
}
}

Locale Normalization

Locale identifiers are normalized to lowercase with hyphens: en_US, EN-US, and en-us all resolve to en-us. This applies to directory names, query parameters, and Accept-Language headers.

Validation Messages

Validation error messages are automatically localized when translations are loaded. The framework registers a validation bridge at boot that routes message lookups through the Translator.

Translation keys follow the pattern validation.{rule_name}:

{
  "validation": {
    "required": "The :attribute field is required.",
    "email": "The :attribute field must be a valid email address.",
    "min": {
      "string": "The :attribute field must be at least :min characters.",
      "numeric": "The :attribute field must be at least :min.",
      "array": "The :attribute field must have at least :min items."
    }
  }
}

Default English validation messages are bundled with the framework. Custom messages in your translation files override them per-locale.

Size rules (min, max, between) use nested keys for type-specific messages: validation.min.string, validation.min.numeric, validation.min.array.

CLI Commands

Generate Translation Files

Create translation files for a new locale:

ferro make:lang es

This creates lang/es/ with app.json and validation.json starter templates.

New Project Scaffolding

ferro new includes lang/en/ by default with English starter translations and the locale environment variables in .env.example.

Fallback Chain

When looking up a translation key:

  1. Check the requested locale
  2. If not found, check the fallback locale (APP_FALLBACK_LOCALE)

Fallback keys are pre-merged into each locale at load time, so runtime lookup is a single hash map access with no fallback chain traversal.

Graceful Degradation

If no lang/ directory exists or translations fail to load:

  • t() and trans() return the key as-is (e.g., "welcome")
  • lang_choice() returns the key as-is
  • Validation rules fall back to hardcoded English messages
  • No panics, no errors -- the application runs normally without localization

This means localization is entirely opt-in. Applications work without any translation files present.

MCP Tools

Use list_lang_files to discover all translation files in the project.

list_lang_files

Returns all JSON translation files found under the configured LANG_PATH directory, organized by locale. For each file, shows the locale, file name, and the top-level translation keys it defines. Use this to audit translation coverage across locales before adding new keys or supporting a new language.

Testing

Ferro provides a comprehensive testing suite with HTTP test client, Jest-like assertions, database factories, and isolated test databases.

HTTP Testing

TestClient

The TestClient provides a fluent API for making HTTP requests to your application.

#![allow(unused)]
fn main() {
use ferro::TestClient;

#[tokio::test]
async fn test_homepage() {
    let client = TestClient::new(app());

    let response = client.get("/").send().await;

    response.assert_ok();
    response.assert_see("Welcome");
}
}

Making Requests

#![allow(unused)]
fn main() {
use ferro::TestClient;

let client = TestClient::new(app());

// GET request
let response = client.get("/users").send().await;

// POST request with JSON body
let response = client
    .post("/users")
    .json(&serde_json::json!({
        "name": "John Doe",
        "email": "john@example.com"
    }))
    .send()
    .await;

// PUT request
let response = client
    .put("/users/1")
    .json(&serde_json::json!({ "name": "Jane Doe" }))
    .send()
    .await;

// PATCH request
let response = client
    .patch("/users/1")
    .json(&serde_json::json!({ "active": true }))
    .send()
    .await;

// DELETE request
let response = client.delete("/users/1").send().await;
}

Request Builder

Customize requests with headers, authentication, and body data.

#![allow(unused)]
fn main() {
// With headers
let response = client
    .get("/api/data")
    .header("X-Custom-Header", "value")
    .header("Accept", "application/json")
    .send()
    .await;

// With bearer token authentication
let response = client
    .get("/api/protected")
    .bearer_token("your-jwt-token")
    .send()
    .await;

// With query parameters
let response = client
    .get("/search")
    .query(&[("q", "rust"), ("page", "1")])
    .send()
    .await;

// With form data
let response = client
    .post("/login")
    .form(&[("email", "user@example.com"), ("password", "secret")])
    .send()
    .await;

// With JSON body
let response = client
    .post("/api/posts")
    .json(&serde_json::json!({
        "title": "My Post",
        "content": "Hello, World!"
    }))
    .send()
    .await;
}

Acting As User

Test authenticated routes by acting as a specific user.

#![allow(unused)]
fn main() {
use ferro::models::user;

let user = user::Entity::find_by_pk(1).await?.expect("user with id=1 exists");

let response = client
    .get("/dashboard")
    .acting_as(&user)
    .send()
    .await;

response.assert_ok();
}

Response Assertions

Status Assertions

#![allow(unused)]
fn main() {
// Specific status code
response.assert_status(200);
response.assert_status(201);
response.assert_status(422);

// Common status helpers
response.assert_ok();           // 200
response.assert_created();      // 201
response.assert_no_content();   // 204
response.assert_redirect();     // 3xx
response.assert_not_found();    // 404
response.assert_unauthorized(); // 401
response.assert_forbidden();    // 403
response.assert_unprocessable(); // 422
response.assert_server_error(); // 5xx
}

JSON Assertions

#![allow(unused)]
fn main() {
// Assert JSON path exists
response.assert_json_has("data.user.name");
response.assert_json_has("data.items[0].id");

// Assert JSON path has specific value
response.assert_json_is("data.user.name", "John Doe");
response.assert_json_is("data.count", 42);
response.assert_json_is("data.active", true);

// Assert entire JSON structure
response.assert_json_equals(&serde_json::json!({
    "status": "success",
    "data": {
        "id": 1,
        "name": "John"
    }
}));

// Assert array count at path
response.assert_json_count("data.items", 5);

// Assert JSON matches predicate
response.assert_json_matches("data.items", |items| {
    items.as_array().map(|arr| arr.len() > 0).unwrap_or(false)
});

// Assert JSON path is missing
response.assert_json_missing("data.password");
}

Content Assertions

#![allow(unused)]
fn main() {
// Assert body contains text
response.assert_see("Welcome");
response.assert_see("Hello, World!");

// Assert body does NOT contain text
response.assert_dont_see("Error");
response.assert_dont_see("Unauthorized");
}

Validation Error Assertions

#![allow(unused)]
fn main() {
// Assert validation errors for specific fields
response.assert_validation_errors(&["email", "password"]);

// Typical validation error test
#[tokio::test]
async fn test_registration_validation() {
    let client = TestClient::new(app());

    let response = client
        .post("/register")
        .json(&serde_json::json!({
            "email": "invalid-email",
            "password": "123"  // too short
        }))
        .send()
        .await;

    response.assert_unprocessable();
    response.assert_validation_errors(&["email", "password"]);
}
}

Header Assertions

#![allow(unused)]
fn main() {
// Assert header exists and has value
response.assert_header("Content-Type", "application/json");
response.assert_header("X-Request-Id", "abc123");
}

Accessing Response Data

#![allow(unused)]
fn main() {
// Get status code
let status = response.status();

// Get response body as string
let body = response.text();

// Get response body as JSON
let json: serde_json::Value = response.json();

// Get specific header
let content_type = response.header("Content-Type");
}

Expect Assertions

Ferro provides Jest-like expect assertions for expressive tests.

#![allow(unused)]
fn main() {
use ferro::testing::Expect; // not re-exported at crate root

#[tokio::test]
async fn test_user_creation() {
    let user = create_user().await;

    Expect::that(&user.name).to_equal("John Doe");
    Expect::that(&user.email).to_contain("@");
    Expect::that(&user.age).to_be_greater_than(&18);
}
}

Equality

#![allow(unused)]
fn main() {
Expect::that(&value).to_equal("expected");
Expect::that(&value).to_not_equal("unexpected");
}

Boolean

#![allow(unused)]
fn main() {
Expect::that(&result).to_be_true();
Expect::that(&result).to_be_false();
}

Option

#![allow(unused)]
fn main() {
let some_value: Option<i32> = Some(42);
let none_value: Option<i32> = None;

Expect::that(&some_value).to_be_some();
Expect::that(&none_value).to_be_none();
Expect::that(&some_value).to_contain_value(&42);
}

Result

#![allow(unused)]
fn main() {
let ok_result: Result<i32, &str> = Ok(42);
let err_result: Result<i32, &str> = Err("error");

Expect::that(&ok_result).to_be_ok();
Expect::that(&err_result).to_be_err();
Expect::that(&ok_result).to_contain_ok(&42);
Expect::that(&err_result).to_contain_err(&"error");
}

Strings

#![allow(unused)]
fn main() {
Expect::that(&text).to_contain("hello");
Expect::that(&text).to_start_with("Hello");
Expect::that(&text).to_end_with("!");
Expect::that(&text).to_have_length(11);
Expect::that(&text).to_be_empty();
Expect::that(&text).to_not_be_empty();
}

Vectors

#![allow(unused)]
fn main() {
let items = vec![1, 2, 3, 4, 5];

Expect::that(&items).to_have_length(5);
Expect::that(&items).to_contain(&3);
Expect::that(&items).to_be_empty();
Expect::that(&items).to_not_be_empty();
}

Numeric Comparisons

#![allow(unused)]
fn main() {
Expect::that(&value).to_be_greater_than(&10);
Expect::that(&value).to_be_less_than(&100);
Expect::that(&value).to_be_greater_than_or_equal(&10);
Expect::that(&value).to_be_less_than_or_equal(&100);
}

Database Factories

Factories generate fake data for testing, inspired by Laravel's model factories.

Defining a Factory

#![allow(unused)]
fn main() {
use ferro::{Factory, FactoryBuilder, Fake};
use sea_orm::Set;
use crate::models::user;

pub struct UserFactory;

impl Factory for UserFactory {
    type Model = user::ActiveModel;

    fn definition() -> Self::Model {
        user::ActiveModel {
            name: Set(Fake::name()),
            email: Set(Fake::email()),
            password: Set(Fake::sentence(3)),
            active: Set(true),
            ..Default::default()
        }
    }
}
}

Using Factories

#![allow(unused)]
fn main() {
use crate::factories::UserFactory;

// Make a single model (not persisted)
let user = UserFactory::factory().make();

// Make multiple models
let users = UserFactory::factory().count(5).make_many();

// Override attributes
let admin = UserFactory::factory()
    .set("role", "admin")
    .set("active", true)
    .make();
}

Factory States

Define reusable states for common variations.

#![allow(unused)]
fn main() {
use ferro::Factory;
use ferro::testing::FactoryTraits; // not re-exported at crate root

impl Factory for UserFactory {
    type Model = user::ActiveModel;

    fn definition() -> Self::Model {
        user::ActiveModel {
            name: Set(Fake::name()),
            email: Set(Fake::email()),
            active: Set(true),
            role: Set("user".to_string()),
            ..Default::default()
        }
    }

    fn traits() -> FactoryTraits<Self::Model> {
        FactoryTraits::new()
            .register("admin", |model| {
                let mut model = model;
                model.role = Set("admin".to_string());
                model
            })
            .register("inactive", |model| {
                let mut model = model;
                model.active = Set(false);
                model
            })
            .register("unverified", |model| {
                let mut model = model;
                model.email_verified_at = Set(None);
                model
            })
    }
}

// Using traits
let admin = UserFactory::factory().trait_("admin").make();
let inactive_admin = UserFactory::factory()
    .trait_("admin")
    .trait_("inactive")
    .make();
}

Database Factory

For factories that persist to the database.

#![allow(unused)]
fn main() {
use ferro::{Factory, Fake};
use ferro::testing::DatabaseFactory; // not re-exported at crate root
use ferro::DB;

pub struct UserFactory;

impl Factory for UserFactory {
    type Model = user::ActiveModel;

    fn definition() -> Self::Model {
        user::ActiveModel {
            name: Set(Fake::name()),
            email: Set(Fake::email()),
            ..Default::default()
        }
    }
}

impl DatabaseFactory for UserFactory {
    type Entity = user::Entity;
}

// Create and persist to database
let user = UserFactory::factory().create().await?;

// Create multiple
let users = UserFactory::factory().count(10).create_many().await?;
}

Factory Callbacks

Execute code after making or creating models.

#![allow(unused)]
fn main() {
let user = UserFactory::factory()
    .after_make(|user| {
        println!("Made user: {:?}", user);
    })
    .after_create(|user| {
        // Send welcome email, create related records, etc.
        println!("Created user in database: {:?}", user);
    })
    .create()
    .await?;
}

Sequences

Generate unique sequential values.

#![allow(unused)]
fn main() {
use ferro::Sequence;

let seq = Sequence::new();

// Get next value
let id1 = seq.next(); // 1
let id2 = seq.next(); // 2
let id3 = seq.next(); // 3

// Use in factories
fn definition() -> Self::Model {
    static SEQ: Sequence = Sequence::new();

    user::ActiveModel {
        email: Set(format!("user{}@example.com", SEQ.next())),
        ..Default::default()
    }
}
}

Fake Data Generation

The Fake helper generates realistic test data.

Personal Information

#![allow(unused)]
fn main() {
use ferro::Fake;

let name = Fake::name();           // "John Smith"
let first = Fake::first_name();    // "John"
let last = Fake::last_name();      // "Smith"
let email = Fake::email();         // "john.smith@example.com"
let phone = Fake::phone();         // "+1-555-123-4567"
}

Text Content

#![allow(unused)]
fn main() {
let word = Fake::word();               // "lorem"
let sentence = Fake::sentence(5);      // 5-word sentence
let paragraph = Fake::paragraph(3);    // 3-sentence paragraph
let text = Fake::text(100);            // ~100 characters
}

Numbers

#![allow(unused)]
fn main() {
let num = Fake::number(1, 100);        // Random 1-100
let float = Fake::float(0.0, 1.0);     // Random 0.0-1.0
let bool = Fake::boolean();            // true or false
}

Identifiers

#![allow(unused)]
fn main() {
let uuid = Fake::uuid();               // "550e8400-e29b-41d4-a716-446655440000"
let slug = Fake::slug(3);              // "lorem-ipsum-dolor"
}

Addresses

#![allow(unused)]
fn main() {
let address = Fake::address();         // "123 Main St"
let city = Fake::city();               // "New York"
let country = Fake::country();         // "United States"
let zip = Fake::zip_code();            // "10001"
}

Internet

#![allow(unused)]
fn main() {
let url = Fake::url();                 // "https://example.com/page"
let domain = Fake::domain();           // "example.com"
let ip = Fake::ip_v4();                // "192.168.1.1"
let user_agent = Fake::user_agent();   // "Mozilla/5.0..."
}

Dates and Times

#![allow(unused)]
fn main() {
use chrono::{NaiveDate, NaiveDateTime};

let date = Fake::date();               // Random date
let datetime = Fake::datetime();       // Random datetime
let past = Fake::past_date(30);        // Within last 30 days
let future = Fake::future_date(30);    // Within next 30 days
}

Collections

#![allow(unused)]
fn main() {
// Pick one from list
let status = Fake::one_of(&["pending", "active", "completed"]);

// Pick multiple from list
let tags = Fake::many_of(&["rust", "web", "api", "testing"], 2);
}

Custom Generators

#![allow(unused)]
fn main() {
// With closure
let custom = Fake::custom(|| {
    format!("USER-{}", Fake::number(1000, 9999))
});
}

Test Database

Ferro provides isolated database testing with automatic migrations.

Using test_database! Macro

#![allow(unused)]
fn main() {
use ferro::test_database;
use ferro::models::user;

#[tokio::test]
async fn test_user_creation() {
    // Creates fresh in-memory SQLite with migrations
    let db = test_database!();

    // Create a user
    let new_user = user::ActiveModel {
        name: Set("Test User".to_string()),
        email: Set("test@example.com".to_string()),
        ..Default::default()
    };

    let user = user::Entity::insert_one(new_user).await.unwrap();
    assert!(user.id > 0);

    // Query using test database connection
    let found = user::Entity::find_by_id(user.id)
        .one(db.conn())
        .await
        .unwrap();

    assert!(found.is_some());
}
}

Custom Migrator

#![allow(unused)]
fn main() {
use ferro::TestDatabase;

#[tokio::test]
async fn test_with_custom_migrator() {
    let db = TestDatabase::fresh::<my_crate::CustomMigrator>()
        .await
        .unwrap();

    // Test code here
}
}

Database Isolation

Each TestDatabase:

  • Creates a fresh in-memory SQLite database
  • Runs all migrations automatically
  • Is completely isolated from other tests
  • Is cleaned up when dropped

This ensures tests don't interfere with each other.

Test Container

Mock dependencies using the test container.

Faking Services

#![allow(unused)]
fn main() {
use ferro::{TestContainer, TestContainerGuard};
use std::sync::Arc;

#[tokio::test]
async fn test_with_fake_service() {
    // Create isolated container for this test
    let _guard = TestContainer::fake();

    // Register a fake singleton
    TestContainer::singleton(FakePaymentGateway::new());

    // Register a factory
    TestContainer::factory(|| {
        Box::new(MockEmailService::default())
    });

    // Your test code - Container::get() returns fakes
    let gateway = Container::get::<FakePaymentGateway>();
    // ...
}
}

Binding Interfaces

#![allow(unused)]
fn main() {
use std::sync::Arc;

#[tokio::test]
async fn test_with_mock_repository() {
    let _guard = TestContainer::fake();

    // Bind a mock implementation of a trait
    let mock_repo: Arc<dyn UserRepository> = Arc::new(MockUserRepository::new());
    TestContainer::bind(mock_repo);

    // Or with a factory
    TestContainer::bind_factory::<dyn UserRepository, _>(|| {
        Arc::new(MockUserRepository::with_users(vec![
            User { id: 1, name: "Test".into() }
        ]))
    });

    // Test code uses the mock
}
}

Complete Test Example

#![allow(unused)]
fn main() {
use ferro::{TestClient, TestDatabase, Fake};
use ferro::testing::Expect; // not re-exported at crate root
use ferro::test_database;
use crate::factories::UserFactory;

#[tokio::test]
async fn test_user_registration_flow() {
    // Set up isolated test database
    let _db = test_database!();

    // Create test client
    let client = TestClient::new(app());

    // Test validation errors
    let response = client
        .post("/api/register")
        .json(&serde_json::json!({
            "email": "invalid"
        }))
        .send()
        .await;

    response.assert_unprocessable();
    response.assert_validation_errors(&["email", "password", "name"]);

    // Test successful registration
    let email = Fake::email();
    let response = client
        .post("/api/register")
        .json(&serde_json::json!({
            "name": Fake::name(),
            "email": &email,
            "password": "password123",
            "password_confirmation": "password123"
        }))
        .send()
        .await;

    response.assert_created();
    response.assert_json_has("data.user.id");
    response.assert_json_is("data.user.email", &email);

    // Verify user was created in database
    let user = user::Entity::query()
        .filter(user::Column::Email.eq(&email))
        .first()
        .await
        .unwrap();

    Expect::that(&user).to_be_some();
}

#[tokio::test]
async fn test_authenticated_endpoint() {
    let _db = test_database!();

    // Create a user with factory
    let user = UserFactory::factory()
        .trait_("admin")
        .create()
        .await
        .unwrap();

    let client = TestClient::new(app());

    // Test unauthorized access
    let response = client.get("/api/admin/users").send().await;
    response.assert_unauthorized();

    // Test authorized access
    let response = client
        .get("/api/admin/users")
        .acting_as(&user)
        .send()
        .await;

    response.assert_ok();
    response.assert_json_has("data.users");
}
}

Running Tests

# Run all tests
cargo test

# Run specific test
cargo test test_user_registration

# Run tests with output
cargo test -- --nocapture

# Run tests in parallel (default)
cargo test

# Run tests sequentially
cargo test -- --test-threads=1

Best Practices

  1. Use test_database! for isolation - Each test gets a fresh database
  2. Use factories for test data - Consistent, readable test setup
  3. Test both success and failure cases - Validate error handling
  4. Use meaningful test names - test_user_cannot_access_admin_panel
  5. Keep tests focused - One assertion concept per test
  6. Use Expect for readable assertions - Fluent API improves clarity
  7. Mock external services - Use TestContainer to isolate from APIs
  8. Test validation thoroughly - Cover edge cases in input validation

Static Files

Ferro serves static files from the public/ directory for any request that doesn't match a registered route. This works automatically with zero configuration.

How It Works

When a GET or HEAD request doesn't match any route, Ferro checks if a corresponding file exists in public/. If found, it serves the file with the correct MIME type and cache headers. If not, the request falls through to the fallback handler (e.g., Inertia SPA catch-all) or returns 404.

Request: GET /assets/main-abc123.js
  1. Route matching → no match
  2. Static file check → public/assets/main-abc123.js exists
  3. Serve file with Content-Type: application/javascript

Only GET and HEAD requests trigger static file checks. POST, PUT, DELETE, and other methods skip filesystem checks entirely.

Cache Strategy

Ferro applies differentiated cache headers based on the request path:

Path patternCache-ControlRationale
/assets/*public, max-age=31536000, immutableVite hashed output — content hash in filename means the URL changes when content changes
Everything elsepublic, max-age=0, must-revalidateRoot files like favicon.ico, robots.txt may change without URL change

This means Vite build output (public/assets/) is cached for one year with no revalidation, while root-level files are always revalidated.

Security

Static file serving includes the following protections:

  • Dotfile rejection: Paths containing segments starting with . are rejected (prevents serving .env, .git/config, .planning/, etc.)
  • Directory traversal protection: Paths are canonicalized and verified to remain within public/. Symlinks and .. segments that resolve outside public/ are blocked.
  • Null byte rejection: Paths containing null bytes are rejected.
  • Directory listing disabled: Requests to directories return nothing (falls through to fallback/404).

Development vs Production

In development, ferro serve starts the Vite dev server which handles asset serving via HMR. The HTML references http://localhost:5173/src/main.tsx, not /assets/main.js. Since public/assets/ doesn't exist until vite build runs, static file serving is effectively a no-op in development.

In production, vite build outputs hashed files to public/assets/. The compiled Ferro binary serves these files directly.

Large Files

Static file serving reads entire files into memory. This is appropriate for typical web assets (JS, CSS, fonts, images under 1MB).

For large files (video, datasets, user uploads), use Storage with a CDN or object storage service instead of placing files in public/.

Inertia.js

Ferro provides first-class Inertia.js integration, enabling you to build modern single-page applications using React while keeping your routing and controllers on the server. This gives you the best of both worlds: the snappy feel of an SPA with the simplicity of server-side rendering.

How Inertia Works

Inertia.js is a protocol that connects your server-side framework to a client-side framework (React, Vue, or Svelte). Instead of returning HTML or building a separate API:

  1. Your controller returns an Inertia response with a component name and props
  2. On the first request, a full HTML page is rendered with the initial data
  3. On subsequent requests, only JSON is returned
  4. The client-side adapter swaps components without full page reloads

Configuration

Environment Variables

Configure Inertia in your .env file:

# Vite development server URL
VITE_DEV_SERVER=http://localhost:5173

# Frontend entry point
VITE_ENTRY_POINT=src/main.tsx

# Asset version for cache busting
INERTIA_VERSION=1.0

# Development mode (enables HMR)
APP_ENV=development

Bootstrap Setup

In src/bootstrap.rs, configure Inertia:

#![allow(unused)]
fn main() {
use ferro::{App, InertiaConfig};

pub async fn register() {
    // Configure from environment
    let config = InertiaConfig::from_env();
    App::set_inertia_config(config);
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::InertiaConfig;

let config = InertiaConfig {
    vite_dev_server: "http://localhost:5173".to_string(),
    entry_point: "src/main.tsx".to_string(),
    version: "1.0".to_string(),
    development: true,
    html_template: None,
};
}

Basic Usage

Rendering Responses

Use Inertia::render() to return an Inertia response:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Inertia};
use serde::Serialize;

#[derive(Serialize)]
pub struct HomeProps {
    pub title: String,
    pub message: String,
}

#[handler]
pub async fn index(req: Request) -> Response {
    Inertia::render(&req, "Home", HomeProps {
        title: "Welcome".to_string(),
        message: "Hello from Ferro!".to_string(),
    })
}
}

The component name ("Home") maps to frontend/src/pages/Home.tsx.

The InertiaProps Derive Macro

For automatic camelCase conversion (standard in JavaScript), use the InertiaProps derive macro:

#![allow(unused)]
fn main() {
use ferro::InertiaProps;

#[derive(InertiaProps)]
pub struct DashboardProps {
    pub user_name: String,      // Serializes as "userName"
    pub total_posts: i32,       // Serializes as "totalPosts"
    pub is_admin: bool,         // Serializes as "isAdmin"
}

#[handler]
pub async fn dashboard(req: Request) -> Response {
    Inertia::render(&req, "Dashboard", DashboardProps {
        user_name: "John".to_string(),
        total_posts: 42,
        is_admin: true,
    })
}
}

In your React component:

interface DashboardProps {
    userName: string;
    totalPosts: number;
    isAdmin: boolean;
}

export default function Dashboard({ userName, totalPosts, isAdmin }: DashboardProps) {
    return <h1>Welcome, {userName}!</h1>;
}

Compile-Time Component Validation

The inertia_response! macro validates that your component exists at compile time:

#![allow(unused)]
fn main() {
use ferro::inertia_response;

#[handler]
pub async fn show(req: Request) -> Response {
    // Validates that frontend/src/pages/Users/Show.tsx exists
    inertia_response!(&req, "Users/Show", UserProps { ... })
}
}

If the component doesn't exist, you get a compile error with fuzzy matching suggestions:

error: Component "Users/Shwo" not found. Did you mean "Users/Show"?

Shared Props

Shared props are data that should be available to every page component, like authentication state, flash messages, and CSRF tokens.

Creating the Middleware

#![allow(unused)]
fn main() {
use ferro::{Middleware, Request, Response, Next, InertiaShared};
use async_trait::async_trait;

pub struct ShareInertiaData;

#[async_trait]
impl Middleware for ShareInertiaData {
    async fn handle(&self, mut request: Request, next: Next) -> Response {
        let mut shared = InertiaShared::new();

        // Add CSRF token
        if let Some(token) = request.csrf_token() {
            shared = shared.csrf(token);
        }

        // Add authenticated user
        if let Some(user) = request.user() {
            shared = shared.auth(AuthUser {
                id: user.id,
                name: user.name.clone(),
                email: user.email.clone(),
            });
        }

        // Add flash messages
        if let Some(flash) = request.session().get::<FlashMessages>("flash") {
            shared = shared.flash(flash);
        }

        // Add custom shared data
        shared = shared.with(serde_json::json!({
            "app_name": "My Application",
            "app_version": "1.0.0",
        }));

        // Store in request extensions
        request.insert(shared);
        next(request).await
    }
}
}

Registering the Middleware

In src/bootstrap.rs:

#![allow(unused)]
fn main() {
use ferro::global_middleware;
use crate::middleware::ShareInertiaData;

pub async fn register() {
    global_middleware!(ShareInertiaData);
}
}

Using Shared Props in Controllers

When InertiaShared is in the request extensions, it's automatically merged:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request) -> Response {
    // Shared props (auth, flash, csrf) are automatically included
    Inertia::render(&req, "Home", HomeProps {
        title: "Welcome".to_string(),
    })
}
}

Accessing Shared Props in React

import { usePage } from '@inertiajs/react';

interface SharedProps {
    auth?: {
        id: number;
        name: string;
        email: string;
    };
    flash?: {
        success?: string;
        error?: string;
    };
    csrf?: string;
}

export default function Layout({ children }) {
    const { auth, flash } = usePage<{ props: SharedProps }>().props;

    return (
        <div>
            {auth && <nav>Welcome, {auth.name}</nav>}
            {flash?.success && <div className="alert-success">{flash.success}</div>}
            {children}
        </div>
    );
}

SavedInertiaContext

When you need to consume the request body (e.g., for validation) before rendering, use SavedInertiaContext:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Inertia, SavedInertiaContext, Validator};

#[handler]
pub async fn store(req: Request) -> Response {
    // Save context BEFORE consuming the request
    let ctx = SavedInertiaContext::from_request(&req);

    // Now consume the request body
    let data: serde_json::Value = req.json().await?;

    // Validate
    let errors = Validator::new()
        .rule("title", rules![required(), string(), min(1)])
        .rule("content", rules![required(), string()])
        .validate(&data);

    if errors.fails() {
        // Use saved context to render with validation errors
        return Inertia::render_ctx(&ctx, "Posts/Create", CreatePostProps {
            errors: errors.to_json(),
            old: data,
        });
    }

    // Create the post...
    let post = Post::create(&data).await?;

    redirect!(format!("/posts/{}", post.id))
}
}

Common Patterns

Form Handling with Validation

The most common pattern requiring SavedInertiaContext is form validation. Here's the complete flow:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Inertia, SavedInertiaContext, Validator};

#[handler]
pub async fn store(req: Request) -> Response {
    // STEP 1: Save context BEFORE consuming the request body
    // This is required because req.json()/req.input() consumes the body
    let ctx = SavedInertiaContext::from_request(&req);

    // STEP 2: Now safely consume the request body
    let data: CreateItemRequest = req.json().await?;

    // STEP 3: Validate
    let errors = Validator::new()
        .rule("name", rules![required(), string(), min(1)])
        .rule("email", rules![required(), email()])
        .validate(&data);

    // STEP 4: On validation failure, render with saved context
    if errors.fails() {
        return Inertia::render_ctx(&ctx, "Items/Create", FormProps {
            errors: Some(errors.to_json()),
            old: Some(data),
        });
    }

    // STEP 5: On success, redirect
    let item = Item::create(&data).await?;
    Inertia::redirect_ctx(&ctx, &format!("/items/{}", item.id))
}
}

Why SavedInertiaContext? The request body in Rust can only be read once. Once you call req.json() or req.input(), the body is consumed. But Inertia::render() needs request metadata (headers, URL). SavedInertiaContext captures this metadata before body consumption.

Frontend Setup

Project Structure

your-app/
├── src/                    # Rust backend
│   ├── controllers/
│   ├── middleware/
│   └── main.rs
├── frontend/               # React frontend
│   ├── src/
│   │   ├── pages/          # Inertia page components
│   │   │   ├── Home.tsx
│   │   │   ├── Dashboard.tsx
│   │   │   └── Users/
│   │   │       ├── Index.tsx
│   │   │       └── Show.tsx
│   │   ├── components/     # Shared components
│   │   ├── layouts/        # Layout components
│   │   └── main.tsx        # Entry point
│   ├── package.json
│   └── vite.config.ts
└── Cargo.toml

Entry Point (main.tsx)

import { createInertiaApp } from '@inertiajs/react';
import { createRoot } from 'react-dom/client';

createInertiaApp({
    resolve: (name) => {
        const pages = import.meta.glob(['./pages/**/*.tsx', '!**/*.test.tsx'], { eager: true });
        return pages[`./pages/${name}.tsx`];
    },
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />);
    },
});

Vite Configuration

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [react()],
    server: {
        port: 5173,
        strictPort: true,
    },
    build: {
        manifest: true,
        outDir: '../public/build',
        rollupOptions: {
            input: 'src/main.tsx',
        },
    },
});

Package Dependencies

{
    "dependencies": {
        "@inertiajs/react": "^1.0.0",
        "react": "^18.2.0",
        "react-dom": "^18.2.0"
    },
    "devDependencies": {
        "@types/react": "^18.2.0",
        "@types/react-dom": "^18.2.0",
        "@vitejs/plugin-react": "^4.0.0",
        "typescript": "^5.0.0",
        "vite": "^5.0.0"
    }
}

Use the Inertia Link component for client-side navigation:

import { Link } from '@inertiajs/react';

export default function Navigation() {
    return (
        <nav>
            <Link href="/">Home</Link>
            <Link href="/about">About</Link>
            <Link href="/users" method="get" as="button">Users</Link>
        </nav>
    );
}

Programmatic Navigation

import { router } from '@inertiajs/react';

function handleClick() {
    router.visit('/dashboard');
}

function handleSubmit(data) {
    router.post('/posts', data, {
        onSuccess: () => {
            // Handle success
        },
    });
}

Partial Reloads

Inertia supports partial reloads to refresh only specific props without a full page reload.

Requesting Partial Data

import { router } from '@inertiajs/react';

// Only reload the 'users' prop
router.reload({ only: ['users'] });

// Reload specific props
router.visit('/dashboard', {
    only: ['notifications', 'messages'],
});

Server-Side Handling

Ferro automatically handles partial reload requests. The X-Inertia-Partial-Data header specifies which props to return:

#![allow(unused)]
fn main() {
#[handler]
pub async fn dashboard(req: Request) -> Response {
    // All props are computed, but only requested ones are sent
    Inertia::render(&req, "Dashboard", DashboardProps {
        user: get_user().await?,          // Always sent on full load
        notifications: get_notifications().await?,  // Only if requested
        stats: get_stats().await?,        // Only if requested
    })
}
}

Version Conflict Handling

When your assets change (new deployment), Inertia uses versioning to force a full page reload.

Checking Version

#![allow(unused)]
fn main() {
use ferro::Inertia;

#[handler]
pub async fn index(req: Request) -> Response {
    // Check if client version matches
    if let Some(response) = Inertia::check_version(&req, "1.0", "/") {
        return response;  // Returns 409 Conflict
    }

    Inertia::render(&req, "Home", HomeProps { ... })
}
}

Middleware Approach

#![allow(unused)]
fn main() {
pub struct InertiaVersionCheck;

#[async_trait]
impl Middleware for InertiaVersionCheck {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let current_version = std::env::var("INERTIA_VERSION")
            .unwrap_or_else(|_| "1.0".to_string());

        if let Some(response) = Inertia::check_version(&request, &current_version, "/") {
            return response;
        }

        next(request).await
    }
}
}

Forms

Basic Form Handling

import { useForm } from '@inertiajs/react';

export default function CreatePost() {
    const { data, setData, post, processing, errors } = useForm({
        title: '',
        content: '',
    });

    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        post('/posts');
    }

    return (
        <form onSubmit={handleSubmit}>
            <input
                value={data.title}
                onChange={e => setData('title', e.target.value)}
            />
            {errors.title && <span>{errors.title}</span>}

            <textarea
                value={data.content}
                onChange={e => setData('content', e.target.value)}
            />
            {errors.content && <span>{errors.content}</span>}

            <button type="submit" disabled={processing}>
                Create Post
            </button>
        </form>
    );
}

Server-Side Validation Response

#![allow(unused)]
fn main() {
use ferro::{Inertia, SavedInertiaContext};

#[handler]
pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from_request(&req);
    let data: CreatePostRequest = req.json().await?;

    let errors = validate_post(&data);
    if errors.fails() {
        // Return to form with errors
        return Inertia::render_ctx(&ctx, "Posts/Create", CreatePostProps {
            errors: errors.to_json(),
        });
    }

    let post = Post::create(&data).await?;
    redirect!(format!("/posts/{}", post.id))
}
}

TypeScript Generation

Ferro can generate TypeScript types from your Rust props:

ferro generate-types

This creates type definitions for your InertiaProps structs:

// Generated: frontend/src/types/props.d.ts
export interface HomeProps {
    title: string;
    message: string;
}

export interface DashboardProps {
    userName: string;
    totalPosts: number;
    isAdmin: boolean;
}

Automatic Type Generation

When running ferro serve, TypeScript types are automatically regenerated whenever you modify a file containing InertiaProps structs. Changes are debounced (500ms) to avoid excessive regeneration.

You'll see [types] Regenerated N type(s) in the console when types are updated.

To disable automatic regeneration:

ferro serve --skip-types

Note: Type watching is disabled in --backend-only mode since there's no frontend to update.

Custom Types

The type generator automatically discovers structs with #[derive(InertiaProps)]. For nested types that don't have this derive, you have two options.

Create manual TypeScript type files for complex domain types:

// frontend/src/types/theme-config.ts
export interface ThemeConfig {
  primaryColor?: string;
  fontFamily?: string;
  borderRadius?: number;
}

export interface BottomNavConfig {
  enabled: boolean;
  items: NavItem[];
}

Then import in your components:

import { ThemeConfig } from '@/types/theme-config';
import { DashboardProps } from '@/types/props';  // Auto-generated

interface Props extends DashboardProps {
  themeConfig: ThemeConfig;
}

Option 2: Add InertiaProps Derive

For shared types used in multiple props, add the derive:

#![allow(unused)]
fn main() {
#[derive(Serialize, InertiaProps)]
pub struct ThemeConfig {
    pub primary_color: Option<String>,
    pub font_family: Option<String>,
}
}

This will include ThemeConfig in the generated types.

Note: The generator only scans src/ directory for InertiaProps. Types in libraries or other locations need manual definitions.

Generated Type Utilities

The generated props.d.ts includes utility types:

// Arbitrary JSON values
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

// Validation errors
export type ValidationErrors = Record<string, string[]>;

Use these in your components:

import { JsonValue, ValidationErrors } from '@/types/props';

interface FormProps {
  errors: ValidationErrors | null;
  metadata: JsonValue;
}

Development vs Production

Development Mode

In development, Ferro serves the Vite dev server with HMR:

#![allow(unused)]
fn main() {
let config = InertiaConfig {
    development: true,
    vite_dev_server: "http://localhost:5173".to_string(),
    // ...
};
}

The rendered HTML includes:

<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main.tsx"></script>

Production Mode

In production, Ferro uses the built manifest:

#![allow(unused)]
fn main() {
let config = InertiaConfig {
    development: false,
    // ...
};
}

The rendered HTML includes hashed assets:

<script type="module" src="/build/assets/main-abc123.js"></script>
<link rel="stylesheet" href="/build/assets/main-def456.css">

JSON API Fallback

For testing or API clients that need raw JSON data from Inertia routes, enable JSON fallback:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Inertia};

#[handler]
pub async fn show(req: Request, post: Post) -> Response {
    Inertia::render_with_json_fallback(&req, "Posts/Show", ShowProps { post })
}
}

When enabled:

  • Requests with X-Inertia: true header → Normal Inertia JSON response
  • Requests with Accept: application/json (no X-Inertia) → Raw props as JSON
  • Browser requests → Full HTML page

This is useful for:

  • API testing with curl or Postman
  • Hybrid apps that sometimes need raw JSON
  • Debug tooling

Example with curl:

# Get raw JSON props
curl -H "Accept: application/json" http://localhost:3000/posts/1

# Get normal Inertia response
curl -H "X-Inertia: true" http://localhost:3000/posts/1

# Get HTML page
curl http://localhost:3000/posts/1

Note: This is opt-in per route. Consider security implications before enabling on routes that return sensitive data.

Example: Complete CRUD

Routes

#![allow(unused)]
fn main() {
use ferro::{get, post, put, delete};

pub fn routes() -> Vec<Route> {
    vec![
        get!("/posts", controllers::posts::index),
        get!("/posts/create", controllers::posts::create),
        post!("/posts", controllers::posts::store),
        get!("/posts/{post}", controllers::posts::show),
        get!("/posts/{post}/edit", controllers::posts::edit),
        put!("/posts/{post}", controllers::posts::update),
        delete!("/posts/{post}", controllers::posts::destroy),
    ]
}
}

Controller

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, redirect, Inertia, SavedInertiaContext, InertiaProps};

#[derive(InertiaProps)]
pub struct IndexProps {
    pub posts: Vec<Post>,
}

#[derive(InertiaProps)]
pub struct ShowProps {
    pub post: Post,
}

#[derive(InertiaProps)]
pub struct FormProps {
    pub post: Option<Post>,
    pub errors: Option<serde_json::Value>,
}

#[handler]
pub async fn index(req: Request) -> Response {
    let posts = Post::all().await?;
    Inertia::render(&req, "Posts/Index", IndexProps { posts })
}

#[handler]
pub async fn create(req: Request) -> Response {
    Inertia::render(&req, "Posts/Create", FormProps {
        post: None,
        errors: None,
    })
}

#[handler]
pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from_request(&req);
    let data: CreatePostInput = req.json().await?;

    match Post::create(&data).await {
        Ok(post) => redirect!(format!("/posts/{}", post.id)),
        Err(errors) => Inertia::render_ctx(&ctx, "Posts/Create", FormProps {
            post: None,
            errors: Some(errors.to_json()),
        }),
    }
}

#[handler]
pub async fn show(post: Post, req: Request) -> Response {
    Inertia::render(&req, "Posts/Show", ShowProps { post })
}

#[handler]
pub async fn edit(post: Post, req: Request) -> Response {
    Inertia::render(&req, "Posts/Edit", FormProps {
        post: Some(post),
        errors: None,
    })
}

#[handler]
pub async fn update(post: Post, req: Request) -> Response {
    let ctx = SavedInertiaContext::from_request(&req);
    let data: UpdatePostInput = req.json().await?;

    match post.update(&data).await {
        Ok(post) => redirect!(format!("/posts/{}", post.id)),
        Err(errors) => Inertia::render_ctx(&ctx, "Posts/Edit", FormProps {
            post: Some(post),
            errors: Some(errors.to_json()),
        }),
    }
}

#[handler]
pub async fn destroy(post: Post, _req: Request) -> Response {
    post.delete().await?;
    redirect!("/posts")
}
}

Redirects

For form submissions (POST, PUT, PATCH, DELETE) that should redirect after success, use Inertia::redirect():

#![allow(unused)]
fn main() {
use ferro::{handler, Inertia, Request, Response, Auth};

#[handler]
pub async fn login(req: Request) -> Response {
    // ... validation and auth logic ...

    Auth::login(user.id);
    Inertia::redirect(&req, "/dashboard")
}

#[handler]
pub async fn logout(req: Request) -> Response {
    Auth::logout();
    Inertia::redirect(&req, "/")
}
}

Why Not redirect!()?

The redirect!() macro doesn't have access to the request context, so it can't detect Inertia XHR requests. For non-Inertia routes (API endpoints, traditional forms), redirect!() works fine.

For Inertia pages, always use Inertia::redirect() which:

  • Detects Inertia XHR requests via the X-Inertia header
  • Uses 303 status for POST/PUT/PATCH/DELETE (forces GET on redirect)
  • Includes proper X-Inertia: true response header

With Saved Context

If you've consumed the request with req.input(), use the saved context:

#![allow(unused)]
fn main() {
use ferro::{handler, Inertia, Request, Response, SavedInertiaContext};

#[handler]
pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from(&req);
    let form: CreateForm = req.input().await?;

    // ... create record ...

    Inertia::redirect_ctx(&ctx, "/items")
}
}

Best Practices

  1. Use InertiaProps derive - Automatic camelCase conversion matches JavaScript conventions
  2. Save context before consuming request - Use SavedInertiaContext for validation flows
  3. Share common data via middleware - Auth, flash, CSRF in ShareInertiaData
  4. Organize pages in folders - Posts/Index.tsx, Posts/Show.tsx for clarity
  5. Use compile-time validation - inertia_response! macro catches typos early
  6. Handle version conflicts - Ensure smooth deployments with version checking
  7. Keep props minimal - Only send what the page needs
  8. Use partial reloads - Optimize updates by requesting only changed data
  9. Use Inertia::redirect() for form success - Ensures proper 303 status for Inertia XHR requests

Troubleshooting

Request Body Already Consumed

Symptom: Error when calling Inertia::render() after req.json() or req.input().

Cause: The request body was consumed before rendering. In Rust, request bodies can only be read once.

Solution: Use SavedInertiaContext to capture request metadata before consuming the body:

#![allow(unused)]
fn main() {
let ctx = SavedInertiaContext::from_request(&req);  // Save first
let data = req.json().await?;                        // Then consume
Inertia::render_ctx(&ctx, "Component", props)        // Use saved context
}

Validation Errors Not Displaying

Symptom: Form validation errors are lost after redirect.

Cause: Using redirect!() after validation failure instead of re-rendering with errors.

Solution: On validation failure, render the form again with errors. On success, redirect:

#![allow(unused)]
fn main() {
if errors.fails() {
    // Re-render form with errors (don't redirect)
    return Inertia::render_ctx(&ctx, "Form", FormProps {
        errors: Some(errors.to_json()),
        old: Some(data),
    });
}

// Only redirect on success
Inertia::redirect_ctx(&ctx, "/success")
}

Props Not Updating After Navigation

Symptom: Page shows stale data after Inertia navigation.

Cause: Browser caching or partial reload configuration issue.

Solution: Check that your handler returns fresh data and consider using router.reload() on the frontend to force a refresh:

import { router } from '@inertiajs/react';

// Force reload current page data
router.reload();

// Reload only specific props
router.reload({ only: ['items'] });

MCP Tools

Use these tools to inspect Inertia props structs and manage TypeScript type generation without running the CLI.

list_props

Returns all structs with #[derive(InertiaProps)] found in src/, including field names, Rust types, their TypeScript equivalents, and which Inertia components use each props struct. Use this to audit the props surface before adding new fields or debugging type mismatches.

inspect_props

Returns a detailed breakdown of a single props struct: source code, TypeScript interface preview, which handlers pass it to Inertia::render(), and a validation report comparing the Rust definition to any existing TypeScript interface in frontend/src/types/. Use this to catch mismatches between Rust and TypeScript before they cause runtime errors.

generate_types

Generates or regenerates frontend/src/types/inertia-props.ts from all InertiaProps structs found in the project. Supports a dry_run parameter to preview changes. Equivalent to ferro generate-types but runs as an MCP tool without requiring the CLI or a running server.

JSON-UI

Experimental: JSON-UI is functional and field-tested but the component schema and plugin interface may evolve. Pin your Ferro version in production.

JSON-UI is a server-driven UI system built into Ferro. You write JSON spec files that describe page structure; handlers supply data as serde_json::Value; the framework renders HTML. No frontend build step, no React, no Node.js required.

Architecture Overview

src/views/dashboard.json   (spec file — structure and layout)
          +
handler   (data assembly only — returns serde_json::Value)
          |
          v
JsonUi::render_file("views/dashboard.json", data)
          |
          v
Full HTML page with Tailwind CSS
  1. The spec file at src/views/*.json declares the element tree, layout, and expressions.
  2. The handler assembles data — no component building in Rust.
  3. JsonUi::render_file loads the spec, merges handler data, resolves expressions, and returns an HTML response.

Key Concepts

  • Spec file — A JSON file with "$schema": "ferro-json-ui/v2", a flat "elements" map, and a "root" key naming the entry element. See Getting Started.

  • Elements map — All elements are defined at the top level of "elements". Children reference sibling elements by ID, not by nesting objects.

  • Expressions{ "$data": "/key" } reads from handler data at render time. { "$template": "Hello {name}" } interpolates data into strings. See Expressions.

  • Layouts — The "layout" field controls page chrome: "dashboard" (sidebar), "app" (top nav), "auth" (centered card), or omit for minimal. See Layouts.

  • Actions — The "action" field on any element declares what happens on interaction: handler name, HTTP method, optional confirmation, and success/error outcomes. See Actions.

When to Use JSON-UI

JSON-UI is well suited for:

  • Admin panels and back-office dashboards
  • CRUD applications (list, create, edit, delete flows)
  • Internal tools and management interfaces
  • Rapid prototyping without a frontend build step
  • Server-rendered pages where client-side state is not needed

For rich interactive UIs or SPA behavior, the Inertia.js integration is the alternative. Both can coexist in the same application on different routes.

Quick Example

Spec file (src/views/users.json):

{
  "$schema": "ferro-json-ui/v2",
  "title": "Users",
  "layout": "app",
  "root": "users_table",
  "elements": {
    "users_table": {
      "type": "Table",
      "props": {
        "columns": [
          { "key": "name", "label": "Name" },
          { "key": "email", "label": "Email" }
        ],
        "data_path": "/users",
        "empty_message": "No users found"
      }
    }
  }
}

Handler (src/controllers/users.rs):

#![allow(unused)]
fn main() {
use ferro::{handler, JsonUi, Response};

#[handler]
pub async fn index() -> Response {
    let data = serde_json::json!({
        "users": [
            { "name": "Alice", "email": "alice@example.com" },
            { "name": "Bob",   "email": "bob@example.com" }
        ]
    });
    JsonUi::render_file("views/users.json", data)
}
}

Plugin System

JSON-UI supports plugin components that extend the built-in catalog with interactive widgets requiring client-side JS/CSS. Plugin components use the same {"type": "Map", ...} JSON syntax as built-in components.

JsonUiPlugin Trait

Each plugin implements the JsonUiPlugin trait:

#![allow(unused)]
fn main() {
use ferro_json_ui::plugin::{Asset, JsonUiPlugin};
use serde_json::Value;

pub struct ChartPlugin;

impl JsonUiPlugin for ChartPlugin {
    fn component_type(&self) -> &str {
        "Chart"
    }

    fn props_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "required": ["data"],
            "properties": {
                "data": { "type": "array" }
            }
        })
    }

    fn render(&self, props: &Value, _data: &Value) -> String {
        format!("<div class=\"chart\">{}</div>", props)
    }

    fn css_assets(&self) -> Vec<Asset> {
        vec![Asset::new("https://cdn.example.com/chart.css")]
    }

    fn js_assets(&self) -> Vec<Asset> {
        vec![Asset::new("https://cdn.example.com/chart.js")]
    }

    fn init_script(&self) -> Option<String> {
        Some("initCharts();".to_string())
    }
}
}

Asset Loading

Plugin assets are injected into the page automatically:

  • CSS assets go in <head> as <link> tags
  • JS assets go before </body> as <script> tags
  • Init scripts run after assets load as inline <script> blocks
  • Assets are deduplicated by URL when multiple instances of the same plugin appear on a page
  • SRI integrity hashes are supported via Asset::new(url).integrity("sha256-...")

Registering a Plugin

Register custom plugins at application startup:

#![allow(unused)]
fn main() {
use ferro_json_ui::plugin::register_plugin;

register_plugin(ChartPlugin);
}

Built-in plugins (like Map) are registered automatically and require no manual setup.

Map Component

The Map component renders interactive maps using Leaflet 1.9.4. Leaflet CSS and JS are loaded via CDN with SRI integrity verification.

Props

PropTypeRequiredDefaultDescription
center[f64, f64]No*Map center as [latitude, longitude]
zoomnumberNo13Zoom level (0–18)
heightstringNo"400px"CSS height of the map container
fit_boundsbooleanNoAuto-zoom to fit all markers. When true, center/zoom are ignored if markers exist
markersarrayNo[]Markers to display
tile_urlstringNoOpenStreetMapCustom tile layer URL template
attributionstringNoOSM attributionTile layer attribution text
max_zoomnumberNo19Maximum zoom level

*center is optional when fit_bounds is true and markers are provided.

MapMarker

FieldTypeRequiredDescription
latnumberYesLatitude
lngnumberYesLongitude
popupstringNoPlain text popup content
colorstringNoHex color for a colored CSS pin (e.g., "#3B82F6")
popup_htmlstringNoHTML popup content (alternative to popup)
hrefstringNoURL to navigate to on marker click

Basic Example

{
  "type": "Map",
  "props": {
    "center": [51.505, -0.09],
    "zoom": 13,
    "markers": [
      { "lat": 51.5, "lng": -0.09, "popup": "London" }
    ]
  }
}

Notes

  • Tabs and Modals: Maps inside hidden containers are handled automatically via IntersectionObserver.
  • Multiple maps: Each map container gets a unique ID; multiple maps on the same page work independently.
  • CSP requirements: Allow https://unpkg.com for scripts and https://*.tile.openstreetmap.org for tile images.

CLI Support

Scaffold spec files with the CLI:

ferro make:json-view UserIndex

The command uses AI-powered generation when an Anthropic API key is configured. It reads your models and routes to produce a complete spec file. Without an API key, it falls back to a static template.

Export the full JSON Schema for spec validation:

ferro json-ui:schema

See JSON Schema for details.

MCP Tools

Three MCP tools support JSON-UI development:

json_ui_catalog

Returns all available components (built-in + registered plugins) with their prop schemas, required vs optional fields, and example JSON. Use this to discover what components exist and look up exact prop names.

json_ui_inspect

Returns a parsed breakdown of an existing spec file: element tree, data paths referenced, actions and their resolved routes, and visibility rules. Use this to debug a spec that isn't rendering as expected.

json_ui_generate

Returns a complete spec file scaffolded from a model and intent description. Use this to rapidly prototype a new view from a model. Requires the Anthropic API key to be set.

Stripe Integration

ferro-stripe adds Stripe billing to Ferro applications. It covers two dimensions:

  • Platform subscriptions — the application charges tenants for plan tiers (Free/Pro/Enterprise)
  • Stripe Connect — tenants collect payments from their end users via connected Stripe accounts

Feature-gated behind the stripe feature in Cargo.toml.

Quick Start

Run the scaffold command to generate the boilerplate:

# Platform subscriptions only
ferro make:stripe

# Platform subscriptions + Connect support
ferro make:stripe --connect

This creates:

  • src/stripe/mod.rs — init function
  • src/stripe/webhook.rs — platform webhook handler
  • src/stripe/listeners.rs — subscription sync event listeners
  • src/stripe/connect_webhook.rs — Connect webhook handler (with --connect)

Set the required environment variables:

STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

# Optional — only for Connect
STRIPE_CONNECT_WEBHOOK_SECRET=whsec_connect_xxx
STRIPE_APPLICATION_FEE_PERCENT=2.5

Initialize Stripe in bootstrap.rs:

#![allow(unused)]
fn main() {
use crate::stripe;

pub fn register() {
    stripe::init();
}
}

Register the webhook routes in routes.rs:

#![allow(unused)]
fn main() {
use ferro::Router;
use crate::stripe::webhook::stripe_webhook;

pub fn routes() -> Router {
    Router::new()
        .post("/stripe/webhook", stripe_webhook)
}
}

Platform Subscriptions

Creating a Checkout Session

Redirect the tenant to a Stripe-hosted checkout page to select a plan:

#![allow(unused)]
fn main() {
use ferro::{CheckoutBuilder, LineItem, Mode, handler, HttpResponse, Request, Response};

#[handler]
pub async fn upgrade(req: Request) -> Response {
    let intent = CheckoutBuilder::new(Mode::Subscription)
        .line_item(LineItem {
            name: "Pro Plan".into(),
            description: None,
            unit_amount_cents: 1500,
            quantity: 1,
            currency: "usd".into(),
        })
        .success_url("https://app.example.com/billing/success")
        .cancel_url("https://app.example.com/billing/cancel")
        .idempotency_key(&format!("upgrade-{}", chrono::Utc::now().timestamp()))
        .create()
        .await
        .map_err(|e| HttpResponse::text(e.to_string()).status(500))?;

    Ok(HttpResponse::redirect(&intent.url))
}
}

Billing Portal

Redirect the tenant to Stripe's hosted portal for self-service plan management:

#![allow(unused)]
fn main() {
use ferro::{account, handler, HttpResponse, Request, Response};

#[handler]
pub async fn manage_billing(req: Request) -> Response {
    let customer_id = "cus_xxx";

    let url = account::billing_portal_url(
        customer_id,
        "https://app.example.com/settings",
    )
    .await
    .map_err(|e| HttpResponse::text(e.to_string()).status(500))?;

    Ok(HttpResponse::redirect(&url))
}
}

Subscription Lifecycle

Stripe subscription states map to SubscriptionStatus:

StatusMeaning
trialingTrial period active
activePaid and current
incompleteFirst invoice pending
incomplete_expiredFirst invoice expired
past_dueRenewal invoice failed
canceledSubscription ended
unpaidMultiple invoice failures
pausedCollection paused

SubscriptionInfo exposes three helper methods:

#![allow(unused)]
fn main() {
let sub = tenant.subscription.as_ref().expect("subscription not loaded");

sub.on_trial()        // true when status == trialing
sub.subscribed()      // true when active or trialing
sub.on_grace_period() // true when cancel_at_period_end && subscribed()
}

RequiresPlan Middleware

Gate routes by plan tier. Higher tiers satisfy lower requirements (enterprise > pro > free):

#![allow(unused)]
fn main() {
use ferro::{RequiresPlan, Router};
use crate::handlers::reports;

pub fn routes() -> Router {
    Router::new()
        .group("/reports", |r| {
            r.middleware(RequiresPlan::new("pro"))
             .get("/", reports::index)
        })
}
}

Returns 403 JSON when the plan requirement is not met:

{"error": "Plan does not meet requirement", "required_plan": "pro"}

Plan Hierarchy

The plan tier comparison is available in the framework's tenant module:

#![allow(unused)]
fn main() {
use ferro::tenant::subscription::plan_satisfies;

plan_satisfies("enterprise", "pro")   // true
plan_satisfies("pro", "free")         // true
plan_satisfies("free", "pro")         // false
plan_satisfies("custom", "custom")    // true — unknown plans match themselves
}

Stripe Connect

Connect Onboarding

Create an account link to start the Stripe Connect onboarding flow:

#![allow(unused)]
fn main() {
use ferro::{account, handler, HttpResponse, Request, Response};

#[handler]
pub async fn connect_onboarding(req: Request) -> Response {
    let account_id = "acct_xxx"; // stored on the tenant record

    let url = account::create_link(
        account_id,
        "https://app.example.com/connect/refresh",
        "https://app.example.com/connect/return",
    )
    .await
    .map_err(|e| HttpResponse::text(e.to_string()).status(500))?;

    Ok(HttpResponse::redirect(&url))
}
}

Destination Charges

Process a one-time payment on behalf of a connected account:

#![allow(unused)]
fn main() {
use ferro::{CheckoutBuilder, LineItem, Mode, handler, HttpResponse, Request, Response};

#[handler]
pub async fn pay(req: Request) -> Response {
    let connect_id = "acct_xxx";
    // <!-- TODO(140): reword narrative for capability-axis -->
    let intent = CheckoutBuilder::new(Mode::Payment)
        .destination(connect_id, Some(100)) // 100 cents application fee
        .line_item(LineItem {
            name: "Payment".into(),
            description: None,
            unit_amount_cents: 2000, // $20.00
            quantity: 1,
            currency: "usd".into(),
        })
        .success_url("https://app.example.com/pay/success")
        .cancel_url("https://app.example.com/pay/cancel")
        .idempotency_key(&format!("pay-{}", chrono::Utc::now().timestamp()))
        .create()
        .await
        .map_err(|e| HttpResponse::text(e.to_string()).status(500))?;

    Ok(HttpResponse::redirect(&intent.url))
}
}

Connect destination charges with a platform fee

The end-to-end Connect flow has four stages: create the connected account, link the seller through onboarding, persist the granted capabilities when the account.updated webhook reports them, and route a destination charge with a platform application fee derived from configuration.

  1. Create the account. Register a connected account for the seller and store its acct_xxx id on the tenant record.
  2. Link onboarding. Send the seller through Stripe-hosted onboarding with account::create_link(account_id, refresh_url, return_url) (see Connect Onboarding).
  3. Persist capabilities on account.updated. Stripe emits account.updated as the seller completes onboarding. Persist the charges_enabled / payouts_enabled capability flags from this event so the application only routes charges to accounts that can receive them. Register the handler on the Connect webhook endpoint (STRIPE_CONNECT_WEBHOOK_SECRET).
  4. Route the charge with a computed fee. Derive the platform fee from STRIPE_APPLICATION_FEE_PERCENT via StripeConfig::application_fee_for and feed it to CheckoutBuilder::destination:
#![allow(unused)]
fn main() {
use ferro::{CheckoutBuilder, LineItem, Mode, handler, HttpResponse, Request, Response};
use ferro_stripe::Stripe;

#[handler]
pub async fn pay_with_platform_fee(req: Request) -> Response {
    let account_id = "acct_xxx"; // persisted once account.updated reports charges_enabled
    let amount_cents = 2000; // $20.00

    // None when STRIPE_APPLICATION_FEE_PERCENT is unset or non-positive;
    // otherwise round(amount * percent / 100), clamped to [0, amount_cents].
    let fee = Stripe::config().application_fee_for(amount_cents);

    let intent = CheckoutBuilder::new(Mode::Payment)
        .destination(account_id, fee)
        .line_item(LineItem {
            name: "Payment".into(),
            description: None,
            unit_amount_cents: amount_cents,
            quantity: 1,
            currency: "usd".into(),
        })
        .success_url("https://app.example.com/pay/success")
        .cancel_url("https://app.example.com/pay/cancel")
        .idempotency_key(&format!("pay-{}", chrono::Utc::now().timestamp()))
        .create()
        .await
        .map_err(|e| HttpResponse::text(e.to_string()).status(500))?;

    Ok(HttpResponse::redirect(&intent.url))
}
}

application_fee_for returns None when no percentage is configured, which passes through to destination(account_id, None) — a destination charge with no platform fee. This mirrors the manual-capture flow: manual_capture() composes with destination(), so a deposit can be authorized against a connected account and the platform fee applied at capture time using the same application_fee_for computation.

Manual Capture

Manual capture authorizes card funds at checkout without charging them, then captures (charges) some or all of the authorized amount later, or cancels (releases) the hold. Useful for booking deposits where the final charge amount may differ from the initially authorized amount.

Authorize at checkout

Set manual_capture() on the builder to authorize funds without an immediate charge:

#![allow(unused)]
fn main() {
use ferro::{CheckoutBuilder, LineItem, Mode, handler, HttpResponse, Request, Response};
use ferro_stripe::payment_intent;

#[handler]
pub async fn book(req: Request) -> Response {
    let intent = CheckoutBuilder::new(Mode::Payment)
        .manual_capture()
        .line_item(LineItem {
            name: "Booking deposit".into(),
            description: None,
            unit_amount_cents: 5000, // $50.00 authorized
            quantity: 1,
            currency: "usd".into(),
        })
        .success_url("https://app.example.com/bookings/success")
        .cancel_url("https://app.example.com/bookings/cancel")
        .idempotency_key("booking-deposit-42")
        .create()
        .await
        .map_err(|e| HttpResponse::text(e.to_string()).status(500))?;

    Ok(HttpResponse::redirect(&intent.url))
}
}

manual_capture() is only valid with Mode::Payment. Calling it with Mode::Subscription returns Error::ManualCaptureRequiresPaymentMode from create() before any network call is made.

Capture and cancel

After the customer completes checkout, use the payment_intent module to resolve the hold:

#![allow(unused)]
fn main() {
use ferro_stripe::payment_intent;

// Full capture — charge the entire authorized amount.
payment_intent::capture(&payment_intent_id, None).await?;

// Partial capture — charge 2000 cents; Stripe auto-releases the remainder.
payment_intent::capture(&payment_intent_id, Some(2000)).await?;

// Cancel — release the hold without charging.
payment_intent::cancel(&payment_intent_id).await?;

// Retrieve — poll current authorization state.
payment_intent::retrieve(&payment_intent_id).await?;
}

Application-layer deduplication is required for capture retries. async-stripe 0.41 does not forward per-request idempotency keys to PaymentIntent::capture; a database unique constraint on the operation is the recommended guard against double-captures.

Webhook lifecycle

Two typed events track the manual capture lifecycle:

Stripe eventTyped eventMeaning
payment_intent.amount_capturable_updatedStripePaymentIntentAmountCapturableUpdatedFunds authorized and capturable (hold is live)
payment_intent.canceledStripePaymentIntentCanceledHold released (manually or by Stripe auto-expiry)

These events parse and dispatch the same way as all other typed ferro-stripe events — register handlers against them using SyncDispatcher or the queue-based path, identically to StripeCheckoutCompleted or StripeSubscriptionUpdated.

When capturing the full authorized amount, call capture(&payment_intent_id, None) rather than echoing amount_capturable_cents from a stored event — the event snapshot can be stale, while None always captures the current capturable amount.

Operational realities

  • Stripe holds a card authorization for approximately 7 days. Uncaptured PaymentIntents are auto-cancelled after this window expires, surfacing as a payment_intent.canceled event. The cancellation_reason field on StripePaymentIntentCanceled indicates automatic expiry when Stripe triggers the cancellation.
  • A partial capture charges the specified amount and Stripe automatically releases the remainder of the authorization.

Connect composition

manual_capture() composes with destination(). The authorization is created on the platform account; on capture, Stripe performs the transfer to the connected account per the destination-charge pattern. payment_intent::capture and payment_intent::cancel are platform-scoped — no connected-account header is required.

Correspondence with ferro-reservation

The authorize/capture/cancel triple maps directly to the ferro-reservation hold/commit/release vocabulary:

ferro-reservationStripe PaymentIntent
hold()Authorize at checkout (manual_capture() sets capture_method=manual)
commit()payment_intent::capture(id, amount)
release()payment_intent::cancel(id)

This is a documented semantic correspondence — a convention for pairing a reservation hold with a payment authorization. There is no compile-time dependency between the two crates.

Webhook Configuration

Two Webhook Endpoints

RoutePurposeSecret
POST /stripe/webhookPlatform eventsSTRIPE_WEBHOOK_SECRET
POST /stripe/connect/webhookConnect eventsSTRIPE_CONNECT_WEBHOOK_SECRET

Configure both endpoints in the Stripe Dashboard under Developers → Webhooks.

Signature Verification

Webhooks are verified with HMAC-SHA256. Raw body access is required — do not use JSON body parsers on the webhook route. The scaffold generates handlers that read the raw body string:

#![allow(unused)]
fn main() {
let body = req.body_string().await?;
ferro::verify_webhook(&body, &sig, &Stripe::config().webhook_secret)?;
}

Async Processing via ferro-queue

Webhook handlers return 200 OK immediately after signature verification. Processing runs in a background job to avoid Stripe's 30-second timeout:

HTTP request
  → verify signature
  → dispatch ProcessStripeWebhook job
  → return 200 OK

ferro-queue worker
  → ProcessStripeWebhook::handle()
  → dispatch ferro-events Event
  → your Listener::handle()
  → DB updates, cache invalidation

ProcessStripeWebhook dispatches the appropriate ferro-events event based on the Stripe event type:

Stripe eventferro-events Event
customer.subscription.updatedStripeSubscriptionUpdated
customer.subscription.deletedStripeSubscriptionDeleted
checkout.session.completedStripeCheckoutCompleted
invoice.paidStripeInvoicePaid
payment_intent.succeeded (Connect)StripeConnectPaymentSucceeded

Event Listeners for Subscription Sync

Register listeners in your event service provider to sync subscription state to the database:

#![allow(unused)]
fn main() {
use ferro::{async_trait, EventError, Listener};
use ferro::{StripeSubscriptionUpdated, StripeSubscriptionDeleted};

pub struct SyncSubscriptionPlan;

#[async_trait]
impl Listener<StripeSubscriptionUpdated> for SyncSubscriptionPlan {
    async fn handle(&self, event: &StripeSubscriptionUpdated) -> Result<(), EventError> {
        // Parse event.event_json, update tenant_billing table
        // Invalidate tenant cache so next request loads fresh state
        Ok(())
    }
}

#[async_trait]
impl Listener<StripeSubscriptionDeleted> for SyncSubscriptionPlan {
    async fn handle(&self, event: &StripeSubscriptionDeleted) -> Result<(), EventError> {
        // Mark subscription as canceled in tenant_billing
        Ok(())
    }
}
}

Idempotency

Implement ferro::ProcessedEventLog against your database and call try_mark_processed(&event.id) at the top of each webhook handler — returns Ok(false) when the event was already processed. Use ferro::MemoryProcessedLog in tests and single-process development only.

#![allow(unused)]
fn main() {
use ferro::{ProcessedEventLog, MemoryProcessedLog};
use std::sync::Arc;

// In tests:
let log = Arc::new(MemoryProcessedLog::default());

// In your webhook job (inject Arc<dyn ProcessedEventLog> via your DI container):
if !self.log.try_mark_processed(&event.id).await? {
    // Already processed — skip side effects.
    return Ok(());
}
}

For production, implement the trait against a processed_stripe_events table — see ferro_stripe::idempotency module docs for the recommended SQL schema.

TenantContext Enrichment

When the tenant billing table is populated, attach subscription state to TenantContext:

#![allow(unused)]
fn main() {
use ferro::tenant::subscription::SubscriptionInfo;
use ferro::TenantContext;

// In your DbTenantLookup closure:
let subscription = load_billing_from_db(tenant_id).await;
TenantContext {
    id: tenant.id,
    slug: tenant.slug.clone(),
    name: tenant.name.clone(),
    plan: subscription.as_ref().map(|s| s.plan.clone()),
    subscription,
}
}

SubscriptionInfo is now defined in ferro::tenant::subscription (framework-local type, not a Stripe-API wrapper). Fields:

FieldTypeDescription
stripe_subscription_idStringStripe subscription ID (sub_xxx)
planStringPlan name: free, pro, enterprise
statusSubscriptionStatusStripe status
trial_ends_atOption<DateTime<Utc>>Trial end timestamp
cancel_at_period_endboolScheduled for cancellation
current_period_endDateTime<Utc>Billing period end
stripe_connect_account_idOption<String>Connected account ID

Database Schema

The tenant_billing table stores subscription state per tenant:

CREATE TABLE tenant_billing (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id),
    stripe_customer_id        VARCHAR(255) NOT NULL,
    stripe_subscription_id    VARCHAR(255),
    plan                      VARCHAR(64) NOT NULL DEFAULT 'free',
    status                    VARCHAR(32) NOT NULL DEFAULT 'trialing',
    trial_ends_at             TIMESTAMPTZ,
    cancel_at_period_end      BOOLEAN NOT NULL DEFAULT FALSE,
    current_period_end        TIMESTAMPTZ,
    stripe_connect_account_id VARCHAR(255),
    created_at                TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at                TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tenant_billing_tenant_id ON tenant_billing(tenant_id);
CREATE INDEX idx_tenant_billing_stripe_customer ON tenant_billing(stripe_customer_id);

Testing

Enable test helpers in Cargo.toml:

[dev-dependencies]
ferro-stripe = { version = "*", features = ["test-helpers"] }

Mock Subscriptions

Construct SubscriptionInfo directly from ferro::tenant::subscription in tests:

#![allow(unused)]
fn main() {
use ferro::tenant::subscription::{SubscriptionInfo, SubscriptionStatus};

let active = SubscriptionInfo {
    stripe_subscription_id: "sub_test".into(),
    plan: "pro".into(),
    status: SubscriptionStatus::Active,
    trial_ends_at: None,
    cancel_at_period_end: false,
    current_period_end: chrono::Utc::now() + chrono::Duration::days(30),
    stripe_connect_account_id: None,
};

assert!(active.subscribed());
assert!(!active.on_trial());
}

Webhook Testing

#![allow(unused)]
fn main() {
use ferro_stripe::testing::{signed_webhook_payload, mock_checkout_completed_event};
use ferro_stripe::verify_webhook;

let event = mock_checkout_completed_event("cs_test_123", "cus_test_456");
let (sig, _ts) = signed_webhook_payload(&event, "whsec_test_secret");

let result = verify_webhook(&event, &sig, "whsec_test_secret");
assert!(result.is_ok());
}

Event fixture generators:

#![allow(unused)]
fn main() {
mock_checkout_completed_event(session_id, customer_id)
mock_subscription_updated_event(subscription_id, customer_id, status)
mock_subscription_deleted_event(subscription_id, customer_id)
mock_invoice_paid_event(invoice_id, customer_id)
}

Environment Variables

VariableRequiredDescription
STRIPE_SECRET_KEYYesStripe secret API key (sk_live_xxx or sk_test_xxx)
STRIPE_WEBHOOK_SECRETYesPlatform webhook signing secret (whsec_xxx)
STRIPE_CONNECT_WEBHOOK_SECRETNoConnect webhook signing secret
STRIPE_APPLICATION_FEE_PERCENTNoPlatform fee percentage for Connect charges (e.g. 2.5)

MCP Tools

Three MCP tools support Stripe integration development: configuration status, webhook listener discovery, and subscription schema inspection.

stripe_config_status

Reports which Stripe environment variables are present or missing, and whether the scaffold directory (src/stripe/) exists along with which files it contains. For Connect, it reports connect_webhook_secret_present (a boolean — the secret value is never returned) and application_fee_percent (the parsed number, or null when unset). Use this to diagnose missing configuration before debugging webhook failures or destination-charge fee wiring.

stripe_webhook_events

Scans src/stripe/listeners.rs for Listener<T> implementations and returns the Stripe event type and listener struct name for each. Use this to audit which Stripe events have listeners and which are unhandled.

stripe_subscription_info

Reads the tenant_billing migration file and returns the table schema: column names, types, nullability, defaults, and indexes. Use this to understand the subscription data shape without connecting to the database.

WhatsApp Business Integration

Ferro integrates with the WhatsApp Business Cloud API (Meta Graph API v23.0) via the ferro-whatsapp crate. The integration covers outbound messaging (text and template messages), inbound webhook processing with HMAC verification, sender identity routing, and message deduplication.

Prerequisites

  • A Meta Developer account with a WhatsApp Business app
  • A verified WhatsApp Business phone number
  • A permanent access token (system user token)

WhatsApp Business API access requires Meta app review for production use.

Installation

Add the whatsapp feature to your ferro-rs dependency:

[dependencies]
ferro-rs = { version = "0.1", features = ["whatsapp"] }

Generate the scaffold files:

ferro make:whatsapp

This creates three files in src/whatsapp/:

  • mod.rs — initialization function
  • webhook.rs — GET challenge verification and POST webhook handlers
  • listeners.rs — event listener stubs for inbound events

Configuration

Set the following environment variables:

VariableSourceRequired
WHATSAPP_APP_SECRETMeta Developer Dashboard → App Settings → Basic → App SecretYes
WHATSAPP_ACCESS_TOKENMeta Developer Dashboard → WhatsApp → API Setup → Permanent TokenYes
WHATSAPP_PHONE_NUMBER_IDMeta Developer Dashboard → WhatsApp → API Setup → Phone Number IDYes
WHATSAPP_VERIFY_TOKENA secret string you choose for webhook verificationYes
WHATSAPP_APP_SECRET=your_app_secret
WHATSAPP_ACCESS_TOKEN=your_permanent_token
WHATSAPP_PHONE_NUMBER_ID=123456789012345
WHATSAPP_VERIFY_TOKEN=my_secret_verify_token

Call init() from bootstrap.rs:

#![allow(unused)]
fn main() {
crate::whatsapp::init();
}

The generated src/whatsapp/mod.rs uses WhatsAppConfig::from_env() with an is_owner closure:

#![allow(unused)]
fn main() {
use ferro::WhatsApp;

pub fn init() {
    let config = ferro::WhatsAppConfig::from_env(Box::new(|phone| {
        // phone is E.164 without '+', e.g. "393401234567"
        phone == std::env::var("OWNER_PHONE").as_deref().unwrap_or("")
    }))
    .expect("WhatsApp configuration missing.");
    WhatsApp::init(config);
}
}

Sending Messages

Text Messages

#![allow(unused)]
fn main() {
use ferro::{WhatsApp, WhatsAppRawMessage, WhatsAppSendResult};

let result: WhatsAppSendResult = WhatsApp::send(
    "393401234567",
    WhatsAppRawMessage::Text {
        body: "Hello from Ferro!".to_string(),
    },
)
.await?;

// result.wamid is the WhatsApp message ID for delivery status correlation
println!("Sent with wamid: {}", result.wamid);
}

Note: WhatsAppRawMessage is the raw ferro_whatsapp::Message enum (re-exported under this name to avoid colliding with ferro_notifications::WhatsAppMessage, the notification-system wrapper). Use WhatsAppRawMessage when calling WhatsApp::send directly; use WhatsAppMessage (with its text()/template() builders) when implementing Notification::to_whatsapp for the notification dispatcher.

Template Messages

Template messages are required for outbound messages to contacts who have not messaged you in the last 24 hours. Templates must be approved by Meta before use.

#![allow(unused)]
fn main() {
use ferro::{WhatsApp, WhatsAppRawMessage};

WhatsApp::send(
    "393401234567",
    WhatsAppRawMessage::Template {
        name: "order_confirmation".to_string(),
        language: "it".to_string(),
        parameters: vec![
            serde_json::json!({"type": "text", "text": "ORD-12345"}),
            serde_json::json!({"type": "currency", "currency": {"fallback_value": "€29,90", "code": "EUR", "amount_1000": 29900}}),
        ],
    },
)
.await?;
}

Phone numbers are E.164 format without the + prefix (e.g., "393401234567" not "+39 340 123 4567").

Webhooks

Route Registration

Register both routes in src/routes.rs:

#![allow(unused)]
fn main() {
use crate::whatsapp::webhook::{whatsapp_webhook, whatsapp_webhook_verify};

get!("/whatsapp/webhook", whatsapp_webhook_verify)
post!("/whatsapp/webhook", whatsapp_webhook)
}

Webhook URL Configuration

In Meta Developer Dashboard → Your App → WhatsApp → Configuration:

  1. Set Callback URL to https://yourdomain.com/whatsapp/webhook
  2. Set Verify Token to the value of WHATSAPP_VERIFY_TOKEN
  3. Subscribe to messages and message_status webhook fields

Webhook Processing Flow

The generated src/whatsapp/webhook.rs follows this flow:

  1. GET /whatsapp/webhook — Meta sends a challenge to verify the endpoint. The handler checks the verify token and responds with hub.challenge as plain text.

  2. POST /whatsapp/webhook — Inbound messages and status updates arrive here. The handler:

    • Reads the raw body
    • Verifies the HMAC-SHA256 signature from x-hub-signature-256
    • Acknowledges immediately with {"received": true}
    • Dispatches a ProcessWhatsAppWebhook job to the queue for async processing

Always verify HMAC before parsing JSON — Meta signs the raw bytes, and JSON re-serialization can alter whitespace or Unicode escaping.

Event Handling

ProcessWhatsAppWebhook parses the Meta webhook envelope and dispatches typed ferro-events:

WhatsAppTextReceived

Emitted for each inbound text message:

#![allow(unused)]
fn main() {
use ferro::{async_trait, EventError, Listener, WhatsAppTextReceived};

pub struct HandleInboundMessage;

#[async_trait]
impl Listener<WhatsAppTextReceived> for HandleInboundMessage {
    async fn handle(&self, event: &WhatsAppTextReceived) -> Result<(), EventError> {
        println!("From: {:?}", event.sender_identity);
        println!("Text: {}", event.text);
        println!("Wamid: {}", event.wamid);
        // event.raw contains the full Meta JSON payload
        Ok(())
    }
}
}

WhatsAppTextReceived fields:

  • wamid: String — WhatsApp message ID
  • sender_identity: SenderIdentityOwner(phone) or Customer(phone)
  • text: String — message body
  • timestamp: chrono::DateTime<Utc> — message timestamp
  • raw: serde_json::Value — full Meta JSON payload

WhatsAppStatusUpdate

Emitted for delivery status updates (sent, delivered, read, failed):

#![allow(unused)]
fn main() {
use ferro::{async_trait, DeliveryStatus, EventError, Listener, WhatsAppStatusUpdate};

pub struct HandleDeliveryStatus;

#[async_trait]
impl Listener<WhatsAppStatusUpdate> for HandleDeliveryStatus {
    async fn handle(&self, event: &WhatsAppStatusUpdate) -> Result<(), EventError> {
        match event.status {
            DeliveryStatus::Delivered => {
                println!("Message {} delivered", event.wamid);
            }
            DeliveryStatus::Read => {
                println!("Message {} read", event.wamid);
            }
            _ => {}
        }
        Ok(())
    }
}
}

WhatsAppStatusUpdate fields:

  • wamid: String — correlates with SendResult.wamid from WhatsApp::send()
  • status: DeliveryStatusSent, Delivered, Read, Failed, or Unknown
  • timestamp: chrono::DateTime<Utc> — status update timestamp

Register listeners in bootstrap.rs:

#![allow(unused)]
fn main() {
use crate::whatsapp::listeners::{HandleDeliveryStatus, HandleInboundMessage};

ferro::register_listener::<WhatsAppTextReceived, HandleInboundMessage>();
ferro::register_listener::<WhatsAppStatusUpdate, HandleDeliveryStatus>();
}

Sender Identity

The is_owner closure in WhatsAppConfig classifies each incoming phone number as either SenderIdentity::Owner or SenderIdentity::Customer. Identity is resolved before the event is dispatched, so listeners receive pre-classified events.

Phone numbers arrive from Meta in E.164 format without the + prefix:

#![allow(unused)]
fn main() {
WhatsAppConfig::from_env(Box::new(|phone| {
    // phone is "393401234567", not "+393401234567"
    phone == "393401234567"
}))
}

For DB-backed owner lookups:

#![allow(unused)]
fn main() {
WhatsAppConfig::from_env(Box::new(|phone| {
    // sync check against cached owner list
    OWNER_PHONES.contains(phone)
}))
}

SenderIdentity carries the phone number:

#![allow(unused)]
fn main() {
match event.sender_identity {
    SenderIdentity::Owner(phone) => println!("Message from owner: {phone}"),
    SenderIdentity::Customer(phone) => println!("Message from customer: {phone}"),
}
}

Deduplication

Meta may deliver the same webhook multiple times. Use InMemoryDeduplicationStore to deduplicate by wamid before dispatching the job:

#![allow(unused)]
fn main() {
use ferro::{InMemoryDeduplicationStore, DeduplicationStore, ProcessWhatsAppWebhook, queue_dispatch};

static DEDUP: std::sync::OnceLock<InMemoryDeduplicationStore> = std::sync::OnceLock::new();

fn dedup_store() -> &'static InMemoryDeduplicationStore {
    DEDUP.get_or_init(InMemoryDeduplicationStore::new)
}

#[handler]
pub async fn whatsapp_webhook(req: Request) -> Response {
    // ... HMAC verification ...
    let payload: serde_json::Value = serde_json::from_str(&body)
        .map_err(|_| HttpResponse::text("Invalid JSON").status(400))?;

    // Deduplicate by wamid before queuing
    if let Some(wamid) = payload["entry"][0]["changes"][0]["value"]["messages"][0]["id"].as_str() {
        let is_duplicate = dedup_store()
            .check_and_insert(wamid)
            .await
            .unwrap_or(false);
        if is_duplicate {
            return Ok(HttpResponse::json(serde_json::json!({"received": true})));
        }
    }

    let job = ProcessWhatsAppWebhook { payload_json: body };
    queue_dispatch(job).await
        .map_err(|e| HttpResponse::text(format!("Queue error: {e}")).status(500))?;

    Ok(HttpResponse::json(serde_json::json!({"received": true})))
}
}

InMemoryDeduplicationStore uses a DashMap with 5-minute TTL auto-expiry, which covers all reasonable Meta retry windows. For cross-restart deduplication, implement the DeduplicationStore trait with a Redis-backed store.

Deduplication is the application's responsibility — ProcessWhatsAppWebhook does not check it internally.

MCP Tools

Two MCP tools are available for WhatsApp development assistance.

whatsapp_config_status

Reports which environment variables are present or missing, and whether the scaffold directory (src/whatsapp/) exists. Use this to diagnose configuration issues before debugging webhook delivery or message sending failures.

whatsapp_webhook_events

Scans src/whatsapp/listeners.rs for Listener<T> implementations and returns the event type and listener struct name for each. Use this to audit which WhatsApp events have listeners registered.

Not Supported in v1

  • Media messages (images, documents, audio)
  • Interactive messages (buttons, list messages)
  • Multi-phone-number support (multiple WhatsApp Business accounts)

These can be added as new Message enum variants in a future phase without breaking changes.

Themes

ferro-theme provides a semantic token system that gives JSON-UI applications consistent, customizable visual identities. A theme is a CSS file plus an optional JSON file — no Rust code is needed to create or modify one.

Overview

A theme consists of two files:

  • tokens.css — Tailwind v4 @theme block defining the 23 semantic token slots (colors, shapes, shadows, typography)
  • theme.json — partial JSON object overriding intent template layouts (optional; empty {} uses defaults)

Themes live in the themes/ directory at the project root:

themes/
  myapp/
    tokens.css
    theme.json

Quick Start

Scaffold a new theme:

ferro make:theme myapp

This creates themes/myapp/tokens.css with all 23 token slots pre-filled with defaults, and themes/myapp/theme.json as an empty object.

Activate the theme by setting it as the default in your middleware setup:

#![allow(unused)]
fn main() {
use ferro::{ThemeMiddleware, Theme};

let middleware = ThemeMiddleware::new()
    .default_theme(Theme::from_path("./themes/myapp").expect("theme directory not found"));
}

Edit themes/myapp/tokens.css to customize the visual identity:

@theme {
  --color-primary: oklch(55% 0.22 160);  /* green brand color */
  --color-accent: oklch(65% 0.18 300);   /* purple accent */
}

Process with Tailwind CLI before deploying:

npx tailwindcss -i themes/myapp/tokens.css -o public/themes/myapp.css

Token Reference

All 23 semantic token slots. Components use these class names — themes control the values.

Surface Tokens (6)

TokenDefault (light)Purpose
--color-backgroundoklch(100% 0 0)Page background
--color-surfaceoklch(97% 0 0)Section/panel background
--color-cardoklch(95% 0 0)Card component background
--color-borderoklch(90% 0 0)Borders and dividers
--color-textoklch(15% 0 0)Primary text
--color-text-mutedoklch(50% 0 0)Secondary/placeholder text

Role Tokens (8)

TokenDefault (light)Purpose
--color-primaryoklch(55% 0.2 250)Primary actions, links
--color-primary-foregroundoklch(100% 0 0)Text on primary backgrounds
--color-secondaryoklch(70% 0.05 250)Secondary actions
--color-secondary-foregroundoklch(15% 0 0)Text on secondary backgrounds
--color-accentoklch(65% 0.15 200)Highlights, badges
--color-destructiveoklch(55% 0.22 25)Delete, error states
--color-successoklch(55% 0.18 145)Success states
--color-warningoklch(70% 0.18 80)Warning states

Shape Tokens (4)

TokenDefaultPurpose
--radius-sm0.25remSmall elements (badges, tags)
--radius-md0.375remMedium elements (inputs, buttons)
--radius-lg0.5remLarge elements (cards, modals)
--radius-full9999pxPill-shaped elements

Shadow Tokens (3)

TokenPurpose
--shadow-smSubtle elevation (inputs, dropdowns)
--shadow-mdCard elevation
--shadow-lgModal/overlay elevation

Typography Tokens (2)

TokenDefaultPurpose
--font-family-sansui-sans-serif, system-ui, sans-serifBody and UI text
--font-family-monoui-monospace, monospaceCode, IDs, technical values

Dark Mode

Themes include automatic dark mode support via @media (prefers-color-scheme: dark).

The scaffolded tokens.css includes a dark mode block:

@media (prefers-color-scheme: dark) {
  @theme {
    --color-background: oklch(12% 0 0);
    --color-surface: oklch(17% 0 0);
    --color-text: oklch(95% 0 0);
    /* ... remaining dark overrides */
  }
}

To add manual dark mode toggle (e.g., user preference stored in a cookie), apply a data-theme="dark" attribute on the <html> element and scope the overrides with [data-theme="dark"]:

[data-theme="dark"] {
  --color-background: oklch(12% 0 0);
  --color-surface: oklch(17% 0 0);
  --color-text: oklch(95% 0 0);
}

Intent Templates

theme.json controls how JSON-UI intent layouts render. Leave it as {} to use the built-in layouts, or override specific intents.

Supported intent keys: browse, focus, collect, process, summarize, analyze, track.

Supported slot keys within each intent: title, body, fields, actions, relationships, pagination, metadata, stats.

Each intent has two modes: display (reading data) and input (forms/editing). Each mode specifies an ordered list of slots and an optional layout component.

Example — override Browse display to show title, fields, and pagination in a Table layout:

{
  "browse": {
    "display": {
      "slots": ["title", "fields", "pagination"],
      "layout": "Table"
    }
  }
}

Example — override Collect input to show fields and actions in a Form layout:

{
  "collect": {
    "input": {
      "slots": ["fields", "actions"],
      "layout": "Form"
    }
  }
}

Unspecified intents use the built-in renderer. Only override what you want to change.

ThemeMiddleware Setup

ThemeMiddleware resolves the active theme per request using a resolver chain. Multiple resolvers are tried in order; the first Some result wins.

#![allow(unused)]
fn main() {
use ferro::{ThemeMiddleware, HeaderThemeResolver};

let middleware = ThemeMiddleware::new()
    .resolver(HeaderThemeResolver::new("./themes"));
}

The middleware stores the resolved theme in task-local context. JSON-UI responses automatically include the theme CSS as an inline <style> tag in the HTML <head>. When no resolver matches, ThemeMiddleware falls back to the built-in default theme — it never returns an error.

Resolver Types

HeaderThemeResolver — selects theme from the X-Theme request header:

#![allow(unused)]
fn main() {
use ferro::HeaderThemeResolver;

HeaderThemeResolver::new("./themes")
// Request with `X-Theme: pro` header loads themes/pro/
}

TenantThemeResolver — selects theme based on TenantContext.plan:

#![allow(unused)]
fn main() {
use ferro::TenantThemeResolver;

TenantThemeResolver::new("./themes")
// Tenant with plan "enterprise" loads themes/enterprise/
}

DefaultResolver — always returns a specific theme:

#![allow(unused)]
fn main() {
use ferro::{DefaultResolver, Theme};

DefaultResolver::new(Theme::from_path("./themes/corporate").expect("theme directory not found"))
}

When no resolver matches, ThemeMiddleware falls back to the built-in default theme automatically.

Setting a Custom Default

Use .default_theme() to change the fallback theme (used when no resolver matches):

#![allow(unused)]
fn main() {
use ferro::{ThemeMiddleware, TenantThemeResolver, Theme};

let middleware = ThemeMiddleware::new()
    .resolver(TenantThemeResolver::new("./themes"))
    .default_theme(Theme::from_path("./themes/corporate").expect("theme directory not found"));
}

Multi-Tenant Themes

In multi-tenant applications, each tenant can have a distinct visual identity. TenantThemeResolver reads TenantContext.plan and uses it as the theme directory name.

TenantMiddleware must run before ThemeMiddleware so TenantContext is populated when the theme is resolved.

#![allow(unused)]
fn main() {
use ferro::{TenantMiddleware, ThemeMiddleware, TenantThemeResolver};

// Register TenantMiddleware first
let tenant_mw = TenantMiddleware::new().resolver(/* ... */);

// Then ThemeMiddleware — reads tenant.plan as theme name
let theme_mw = ThemeMiddleware::new()
    .resolver(TenantThemeResolver::new("./themes"));
}

A tenant with plan: "enterprise" loads themes/enterprise/. Tenants without a plan (or with a plan that doesn't match a theme directory) fall through to the next resolver or the default theme.

For Theme Creators

Authoring format vs. deployed format:

The tokens.css file uses the Tailwind v4 @theme authoring syntax, which is processed by the Tailwind CLI. Do not serve the raw tokens.css directly — run it through Tailwind first:

npx tailwindcss -i themes/mytheme/tokens.css -o public/themes/mytheme.css

Add this to your build pipeline or package.json scripts.

Partial overrides in theme.json:

theme.json only needs to specify the intents and modes you want to override. An empty {} is valid and means "use all built-in layouts." Intents not specified in theme.json use the framework's built-in renderer unchanged.

Publishing a theme:

A theme is just two static files. Publish them as an npm package, a GitHub repo, or any file distribution mechanism. Users add them to their themes/ directory and point ThemeMiddleware to the theme name.

Service Projections

Service Projections derive a JSON-UI layout automatically from the structure and semantics of your data. Define what your data IS — fields, types, meanings, and workflow states — and the framework infers the appropriate UI intent and generates a component tree.

Overview

The pipeline has three stages:

ServiceDef  →  derive_intents(&service_def)  →  IntentScore[]
                                                      ↓
                      Renderer.render(&service_def, &intents, &ctx)  →  Output
  1. ServiceDef — describe your service: field names, data types, semantic meanings, state machines, guards, and actions.
  2. derive_intents — analyzes the service definition and returns a ranked list of IntentScore values. The highest-scoring intent is the primary one.
  3. Renderer — takes the service definition, the ranked intents, and a render context, and produces output. The Renderer trait is modality-agnostic: each implementation declares its own Output and Context types. Two renderers ship today:
    • JsonUiRenderer (Output = Spec, Context = VisualContext) — produces a ferro-json-ui component tree for screens.
    • TextRenderer (Output = String, Context = BaseContext) — produces conversational text for non-visual channels (see Conversational-Text Rendering).

The same ServiceDef drives every renderer; the modality is chosen by which renderer and context you use.

Quick Start

Minimal example: a product service deriving its intent and rendering to JSON-UI.

#![allow(unused)]
fn main() {
use ferro::{
    DataType, FieldMeaning, ServiceDef,
    derive_intents, JsonUiRenderer, Renderer, VisualContext,
};

let product = ServiceDef::new("product")
    .display_name("Product")
    .field("id", DataType::Integer, FieldMeaning::Identifier)
    .field("name", DataType::String, FieldMeaning::EntityName)
    .field("price", DataType::Float, FieldMeaning::Money);

let intents = derive_intents(&product);
// intents[0] is the highest-confidence intent (Browse for a simple list-like service)

let renderer = JsonUiRenderer;
let result = renderer.render(&product, &intents, &VisualContext::default());
let spec = result.expect("rendering a valid service definition should not fail");
// spec.schema == "ferro-json-ui/v2"
// spec.elements is a flat ID-keyed map; spec.root names the root element
}

Core Concepts

ServiceDef Builder

ServiceDef is the entry point. Build it with a method chain:

#![allow(unused)]
fn main() {
use ferro::{
    ActionDef, DataType, FieldMeaning, GuardDef,
    ServiceDef, StateDef, StateMachine, Transition,
};

pub fn order_service() -> ServiceDef {
    ServiceDef::new("order")
        .display_name("Order")
        // Fields define the data shape
        .field("id", DataType::Integer, FieldMeaning::Identifier)
        .field("total", DataType::Float, FieldMeaning::Money)
        // Workflow states and transitions
        .state_machine(
            StateMachine::new("order_lifecycle")
                .initial("draft")
                .state(StateDef::new("draft"))
                .state(StateDef::new("completed").final_state())
                .transition(Transition::new("draft", "complete", "completed")),
        )
        // Guards control who can perform actions
        .guard(GuardDef::new("is_manager").display_name("Manager Approval Required"))
        // Actions are operations users can trigger
        .action(ActionDef::new("approve").precondition("is_manager"))
}
}

Builder methods:

MethodDescription
ServiceDef::new(name)Create a new service definition with a machine-readable name
.display_name(label)Human-readable label shown in the UI
.field(name, type, meaning)Add a field with its data type and semantic meaning
.state_machine(sm)Attach a workflow state machine
.guard(guard_def)Define a guard (permission or condition)
.action(action_def)Define an action users can trigger
.intent_hint(hint)Override or exclude derived intents (see Intent Overrides)

Fields and Meanings

Every field needs a DataType and a FieldMeaning. The data type describes the storage format; the meaning describes what the value represents semantically.

DataType:

VariantWhen to use
DataType::IntegerWhole numbers: IDs, counts, quantities
DataType::FloatDecimal numbers: prices, measurements, scores
DataType::StringText values: names, titles, codes, descriptions
DataType::BooleanTrue/false flags
DataType::DateCalendar date (no time)
DataType::DateTimeDate plus time
DataType::JsonStructured payloads stored as JSON
DataType::BinaryOpaque byte sequences
DataType::UuidUUID identifiers
DataType::EnumFixed set of values: status, category

FieldMeaning:

VariantWhen to use
FieldMeaning::IdentifierPrimary key or unique ID
FieldMeaning::ForeignKeyReference to another record's identifier
FieldMeaning::EntityNameDisplay name of the record
FieldMeaning::EmailEmail address
FieldMeaning::PhonePhone number
FieldMeaning::UrlWeb URL
FieldMeaning::ImageUrlImage URL or path
FieldMeaning::MoneyMonetary amount
FieldMeaning::PercentagePercentage value
FieldMeaning::QuantityAggregate count or numeric quantity
FieldMeaning::StatusCurrent state or lifecycle value
FieldMeaning::CategoryCategorical tag or grouping
FieldMeaning::BooleanYes/no flag
FieldMeaning::FreeTextLong descriptive text
FieldMeaning::CreatedAt / FieldMeaning::UpdatedAt / FieldMeaning::DateTimeTimestamp fields
FieldMeaning::SensitiveSensitive value treated as a password input
FieldMeaning::Custom(String)Domain-specific meaning not covered above

Render hints. FieldDef carries an optional render_hint: Option<RenderHint> consumed by non-visual renderers. It controls how a Url or ImageUrl field — whose value has no useful text form on its own — is presented. The visual renderer ignores it; None (the default) preserves existing behavior. Attach a hint with the field_with_hint builder:

#![allow(unused)]
fn main() {
use ferro::{DataType, FieldMeaning, RenderHint, ServiceDef};

let profile = ServiceDef::new("profile")
    .field("id", DataType::Integer, FieldMeaning::Identifier)
    // Substitute alt text in place of the raw image URL in text output
    .field_with_hint(
        "avatar",
        DataType::String,
        FieldMeaning::ImageUrl,
        RenderHint::AltText("User avatar".into()),
    )
    // Drop a navigational URL from non-visual output entirely
    .field_with_hint(
        "tracking_url",
        DataType::String,
        FieldMeaning::Url,
        RenderHint::Skip,
    );
}

To set a hint on a field built elsewhere, FieldDef::with_render_hint(hint) is the consuming-builder equivalent.

RenderHint variantEffect in non-visual output
RenderHint::AltText(String)Render the given string in place of the raw URL/image value
RenderHint::SkipOmit the field from non-visual output entirely
None (no hint)Url → a (link) label, ImageUrl → an (image) label; never the raw URL

Intent Derivation

derive_intents examines five structural signals to score and rank the seven intents:

Signal analyzers:

  1. Field count — many fields suggest a form (Collect); few fields suggest a list (Browse).
  2. Field meanings — presence of Money, Status, Quantity meanings shifts scores toward specific intents.
  3. State machines — a state machine with transitions strongly scores Process.
  4. Guards and actions — approval workflows score Track; rich action sets score Process.
  5. Naming patterns — service name patterns like "report", "summary", "dashboard" shift scores toward Summarize or Analyze.

The seven intents:

IntentStructural signal
Intent::BrowseList of records with identifier and name fields
Intent::FocusSingle record detail view
Intent::CollectInput form (many fields, writable)
Intent::ProcessWorkflow with state machine and transitions
Intent::SummarizeAggregated or summary-level data
Intent::AnalyzeMetric-heavy or analytical view
Intent::TrackAudit trail or progress tracking

derive_intents returns Vec<IntentScore>, sorted by confidence descending. Each IntentScore has:

#![allow(unused)]
fn main() {
// intents[0] is primary
let primary = &intents[0];
println!("Intent: {:?}", primary.intent);
println!("Confidence: {}", primary.confidence);
println!("Signals: {:?}", primary.matching_signals);
}

Intent Overrides

If the derived intent is not what you want, use IntentHint to force or exclude intents:

#![allow(unused)]
fn main() {
use ferro::{DataType, FieldMeaning, Intent, IntentHint, ServiceDef};

let service = ServiceDef::new("order_summary")
    .field("id", DataType::Integer, FieldMeaning::Identifier)
    .field("total", DataType::Float, FieldMeaning::Money)
    // Force Summarize even though signals might score Browse higher
    .intent_hint(IntentHint::Primary(Intent::Summarize))
    // Prevent Browse from appearing even as a fallback
    .intent_hint(IntentHint::Exclude(Intent::Browse));
}

Use IntentHint::Primary to promote a specific intent to the top. Use IntentHint::Exclude to prevent an intent from being selected at all. Multiple hints can be combined.

Rendering

JsonUiRenderer implements the Renderer trait. Use it with a VisualContext to control how the output is shaped.

#![allow(unused)]
fn main() {
use ferro::{JsonUiRenderer, VisualContext, RenderMode, Renderer};

// Display mode: read-only view of data
let display_ctx = VisualContext {
    intent_index: 0,              // use primary intent
    current_state: None,          // no workflow state active
    mode: RenderMode::Display,    // read-only layout
    templates: None,              // use default layouts
};

// Input mode: editable form
let input_ctx = VisualContext {
    intent_index: 0,
    current_state: Some("draft".to_string()), // current workflow state
    mode: RenderMode::Input,                  // form layout
    templates: None,
};

let renderer = JsonUiRenderer;
let spec = renderer.render(&service_def, &intents, &input_ctx).expect("rendering a valid service definition should not fail");
}

VisualContext fields:

FieldTypeDescription
intent_indexusizeIndex into the IntentScore list; 0 for primary intent
current_stateOption<String>Active state name from the state machine, if applicable
modeRenderModeRenderMode::Display for read-only; RenderMode::Input for forms
templatesOption<ThemeTemplates>Custom layout overrides; None uses defaults

RenderMode:

VariantOutput
RenderMode::DisplayRead-only component tree for viewing data
RenderMode::InputEditable form component tree for creating or updating data

Complete Example

An order management service with a workflow, a guard, and an action, rendered in input mode at the "draft" state:

#![allow(unused)]
fn main() {
use ferro::{
    ActionDef, DataType, FieldMeaning, GuardDef,
    JsonUiRenderer, VisualContext, RenderMode, Renderer,
    ServiceDef, StateDef, StateMachine, Transition,
    derive_intents,
};

let order = ServiceDef::new("order")
    .display_name("Order")
    .field("id", DataType::Integer, FieldMeaning::Identifier)
    .field("customer", DataType::String, FieldMeaning::EntityName)
    .field("total", DataType::Float, FieldMeaning::Money)
    .field("status", DataType::Enum, FieldMeaning::Status)
    .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
    .state_machine(
        StateMachine::new("lifecycle")
            .initial("draft")
            .state(StateDef::new("draft"))
            .state(StateDef::new("approved"))
            .state(StateDef::new("shipped"))
            .state(StateDef::new("completed").final_state())
            .transition(Transition::new("draft", "approve", "approved"))
            .transition(Transition::new("approved", "ship", "shipped"))
            .transition(Transition::new("shipped", "complete", "completed")),
    )
    .guard(GuardDef::new("is_manager").display_name("Manager Approval Required"))
    .action(ActionDef::new("approve").precondition("is_manager"));

let intents = derive_intents(&order);
// Process intent scores highest due to state machine + actions

let ctx = VisualContext {
    intent_index: 0,
    current_state: Some("draft".to_string()),
    mode: RenderMode::Input,
    templates: None,
};

let spec = JsonUiRenderer.render(&order, &intents, &ctx).expect("rendering a valid service definition should not fail");
// Produces a ferro-json-ui component tree with:
// - Fields rendered as form inputs
// - Available transitions ("approve") rendered as action buttons
// - Guard labels displayed on the action
}

Conversational-Text Rendering

TextRenderer is a non-visual Renderer that projects the same ServiceDef into plain text suitable for a conversational channel — a chat reply, a CLI summary, a notification body. It produces String output and takes a BaseContext instead of a VisualContext.

#![allow(unused)]
fn main() {
use ferro::{
    derive_intents, BaseContext, Renderer, TextRenderer, Verbosity,
    DataType, FieldMeaning, ServiceDef,
};

let product = ServiceDef::new("product")
    .display_name("Product")
    .field("id", DataType::Integer, FieldMeaning::Identifier)
    .field("name", DataType::String, FieldMeaning::EntityName)
    .field("price", DataType::Float, FieldMeaning::Money);

let intents = derive_intents(&product);
let text = TextRenderer
    .render(&product, &intents, &BaseContext::default())
    .expect("rendering a valid service definition should not fail");
// Product
// Fields:
//   - Name
//   - Price
}

BaseContext::default() renders the primary intent at full verbosity with no guard filtering — the equivalent of the visual renderer's defaults.

BaseContext fields

FieldTypeDescription
intent_indexusizeIndex into the IntentScore list; 0 for the primary intent
current_stateOption<String>Active workflow state, surfaced by Process/Track output
evaluated_guardsHashMap<String, bool>Guard-name → result. Filters action affordances (see below)
verbosityVerbosityVerbosity::Full (default) or Verbosity::Brief

BaseContext is also the modality-agnostic base the visual VisualContext embeds, so guard and verbosity context is shared across renderers.

Per-intent output

The renderer dispatches on the primary intent. Five intents map cleanly to text:

IntentOutput shape
BrowseEntity name + its identifying fields as a list
Collect"Fields to fill in" — the writable inputs, with (required) markers
ProcessCurrent state + the guard-passing actions available from it
SummarizeEntity name + a one-line "Key metrics" list
TrackCurrent state + the next reachable states

Focus and Analyze have no full text form (a media/navigational detail view and a time-series view, respectively). They render a defined fallback — the available fields plus a one-line note — rather than failing or fabricating data:

Profile
  - Avatar (image)
  - Website (link)
Note: This is a media/navigational view; full text representation is limited.

An empty intent slice returns ProjectionsError::NoIntents rather than emitting a placeholder label.

Guard filtering

In a Process render, an action is shown only when its guards pass. evaluated_guards maps each guard name to a boolean; an action is hidden only when one of its preconditions is explicitly false. An absent key renders the action (the guard is treated as not-yet-evaluated), so BaseContext::default() — an empty map — shows every action, matching the visual renderer.

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use ferro::{BaseContext, Renderer, TextRenderer, Verbosity, derive_intents};

// approval_workflow: actions submit, approve, reject, cancel
// approve and reject require the `is_approver` guard.
let intents = derive_intents(&approval_workflow);

// No guard context → every action is listed:
let all = TextRenderer.render(&approval_workflow, &intents, &BaseContext::default()).unwrap();
// ... Available actions: submit, approve, reject, cancel

// Caller is not an approver → approve and reject are filtered out:
let mut guards = HashMap::new();
guards.insert("is_approver".to_string(), false);
let ctx = BaseContext { evaluated_guards: guards, ..Default::default() };
let filtered = TextRenderer.render(&approval_workflow, &intents, &ctx).unwrap();
// ... Available actions: submit, cancel
}

This makes guard evaluation the consumer's responsibility — the renderer never tells a caller they can perform an action their guards would reject.

Verbosity

Verbosity::Full (the default) renders the complete per-intent view. Verbosity::Brief collapses it to a single line — useful for a notification or a quick acknowledgement:

#![allow(unused)]
fn main() {
use ferro::{BaseContext, Renderer, TextRenderer, Verbosity};

let ctx = BaseContext { verbosity: Verbosity::Brief, ..Default::default() };
let brief = TextRenderer.render(&approval_workflow, &intents, &ctx).unwrap();
// approval_workflow — Currently: draft. You can: submit, approve, reject, cancel.
}

Brief output still respects guard filtering, so it only ever lists permitted actions.

Reference

TypeDescription
ServiceDefBuilder for describing a service's data shape, workflow, and capabilities
DataTypeEnum of storage types for a field (Integer, String, Float, etc.)
FieldMeaningSemantic meaning of a field (Identifier, Money, Status, etc.)
StateMachineBuilder for workflow states and transitions
StateDefA single workflow state; call .final_state() to mark terminal states
TransitionA directed edge between two states, triggered by an action name
GuardDefA named permission or condition checked before an action is allowed
ActionDefA user-triggerable operation; optionally requires a guard via .precondition()
IntentEnum of seven structural intents: Browse, Focus, Collect, Process, Summarize, Analyze, Track
IntentScoreA ranked intent result with intent, confidence, and matching_signals
IntentHintOverride directive: Primary(intent) promotes, Exclude(intent) blocks
derive_intentsAnalyzes a ServiceDef and returns a confidence-ranked Vec<IntentScore>
JsonUiRendererImplements Renderer; converts ServiceDef + intents + context to JSON-UI (Output = Spec)
TextRendererImplements Renderer; converts ServiceDef + intents + BaseContext to conversational text (Output = String)
RendererModality-agnostic trait; one method render(def, intents, ctx) with associated Output/Context types
VisualContextVisual render parameters: intent index, current state, mode, template overrides (embeds BaseContext)
BaseContextModality-agnostic render parameters: intent index, current state, evaluated guards, verbosity
RenderModeDisplay for read-only output; Input for editable form output (visual only)
VerbosityFull (default, complete render) or Brief (single-line) for non-visual output
RenderHintOptional FieldDef hint for non-visual rendering of Url/ImageUrl: AltText(String) or Skip

Rendering a Projection Inside an App Shell

A projection Spec is a standalone spec rooted at a single content component (DataTable, KanbanBoard, or StatCard). The spec contains no surrounding dashboard chrome — no sidebar, navigation bar, or PageHeader wrapper. That surrounding structure is the consumer application's responsibility.

Two supported composition patterns:

Pattern A: Merge into an existing layout spec

Insert the projection root element into an existing layout spec's main-content children at handler time:

#![allow(unused)]
fn main() {
// Consumer handler pseudocode
let intents = derive_intents(&order_service);
let ctx = VisualContext { mode: RenderMode::Display, ..Default::default() };
let projection_spec = JsonUiRenderer.render(&order_service, &intents, &ctx)?;

// Load the dashboard layout spec and graft the projection root into it
let mut layout_spec = /* load or build dashboard layout spec */;
let root_id = projection_spec.root.clone();
if let Some(root_el) = projection_spec.elements.get(&root_id) {
    layout_spec.elements.insert(root_id.clone(), root_el.clone());
    // also insert any aux elements the projection added
    for (id, el) in &projection_spec.elements {
        if id != &root_id {
            layout_spec.elements.insert(id.clone(), el.clone());
        }
    }
    // wire the projection root into the layout's main-content children list
    if let Some(main_content) = layout_spec.elements.get_mut("main_content") {
        main_content.children.push(root_id);
    }
}
}

Pattern B: Return the projection spec at a known key

The handler returns both the data payload and the projection spec under a documented key. The dashboard layout template reads and embeds it:

#![allow(unused)]
fn main() {
// Handler response (projection spec returned alongside row data)
serde_json::json!({
    "data": {
        "order": order_rows
    },
    "projection": serde_json::to_value(&projection_spec)?
})
}

The layout template references projection at its documented key to render the content area.

No first-class layout context

A VisualContext.layout field for automatic app-shell selection is not provided in this release. The composition patterns above are the supported contract. Authorization of rendered actions, route existence, and tenant scoping remain the consumer application's responsibility — the renderer emits affordances but does not enforce access control.


Projection Content Binding

This section documents the URL and data-path conventions that link a projection Spec to the consumer application's route table and handler data. A consumer integrating a projection spec must implement routes and data shapes that match these conventions.

Action routes

ActionDef has no explicit route field. The renderer synthesizes action URLs from service and action names:

ContextURL patternNotes
Page-level action/{service.name}/{action.name}Emitted as a Button or DropdownMenu item
DataTable row action/{service.name}/{row_key}/{action.name}{row_key} is substituted per row at render time using DataTableProps.row_key (defaults to "id")

The consumer's route table must define handlers at these paths for the action affordances to be functional. Example for a service named order with an action named approve:

  • Page-level: POST /order/approve
  • Row-level: POST /order/{id}/approve

DataTable rows

DataTableProps.data_path points to the flat array of row objects in the handler response:

data_path: "/data/{service.name}"

Handler provides:

{
  "data": {
    "staff": [
      { "id": 1, "name": "Alice", "active": true },
      { "id": 2, "name": "Bob",   "active": false }
    ]
  }
}

KanbanBoard columns

KanbanBoardProps.data_path points to an array of KanbanColumnProps objects, one per state:

data_path: "/data/{service.name}/columns"

The /columns suffix distinguishes the column array from the flat item array at /data/{service.name}. Handler provides:

{
  "data": {
    "order": {
      "columns": [
        { "id": "draft",     "title": "Draft",     "count": 2, "children": [] },
        { "id": "submitted", "title": "Submitted", "count": 1, "children": [] },
        { "id": "done",      "title": "Done",      "count": 0, "children": [] }
      ]
    }
  }
}

The static columns in the emitted spec (derived from the service's state machine) serve as the schema reference and render fallback when data_path fails to resolve. The handler is responsible for grouping items by state and computing per-column counts. When data_path resolves, it takes precedence over the static columns.

StatCard value

StatCardProps.value_path points to the scalar value for the primary stat field:

value_path: "/data/{service.name}/{field.name}"

The renderer picks the first Money or Quantity readable field as the primary stat. Handler provides:

{
  "data": {
    "statistics": {
      "total_revenue": "€12,450"
    }
  }
}

The value_path field is resolved at render time via the same JSON-pointer mechanism as data_path on other components (see Data Binding). The static value string in the spec is the fallback when value_path is absent or fails to resolve.


MCP Tools

Five MCP tools support Service Projections development: listing, inspection, rendering, validation, and coverage analysis.

list_projections

  • Returns: All ServiceDef definitions found in the project, with service name, field count, detected intents, and whether a state machine is defined
  • When to use: Audit what services are defined; find the correct service name before calling inspect_projection

inspect_projection

  • Returns: Full ServiceDef breakdown: all fields with their DataType and FieldMeaning, state machine states and transitions, guards, actions, and the full ranked IntentScore list from derive_intents
  • When to use: Understand why a particular intent was derived; verify field meanings before rendering; debug unexpected component tree output

render_projection

  • Returns: The rendered JSON-UI component tree for a named service, given an intent index and render mode
  • When to use: Preview what the renderer produces without writing a handler; compare Display vs Input output; verify that state machine transitions appear correctly at a given current state

validate_projection

  • Returns: Validation results for a ServiceDef: missing required fields, unresolvable guards, unreachable states, and mismatched IntentHint directives
  • When to use: Catch definition errors before runtime; verify a service definition is well-formed after editing

projection_coverage

  • Returns: A summary of which models have corresponding ServiceDef projections and which do not, along with suggestions for field meanings based on column names
  • When to use: Identify models that could benefit from projection-based UIs; plan which services to define next

Live Read-Models

Not to be confused with ferro-projections (plural). That crate is the Service Projection abstraction (ServiceDef → IntentGraph → JsonUiRenderer) — it shapes how data renders as UI. This page covers ferro-projection (singular): the live read-model runtime that subscribes to domain events, maintains a materialized per-key state, and broadcasts deltas to WebSocket subscribers. The two abstractions are orthogonal; most apps will use both for different reasons.

ferro-projection is the live-read-model primitive. Capacity-constrained apps with live dashboards (operator views, real-time counters, queue depth panels, kanban boards) all need the same code: subscribe to events → load the current state → fold the event into state → persist → fan a delta to the subscribed clients. Without a typed kernel, every consumer hand-rolls this loop, gets the locking wrong, and ships races. ferro-projection provides the kernel.

The Anti-Pattern

Without a runtime, every consumer writes the same fragile code:

// BAD: load-check-write with manual broadcast — race-prone, easy to forget steps
let current: DashboardState = db
    .query("SELECT state FROM dashboards WHERE id = ?", id)
    .await?
    .unwrap_or_default();
let mut new_state = current.clone();
new_state.apply(&event);
db.execute("UPDATE dashboards SET state = ? WHERE id = ?", &new_state, id).await?;
broadcaster.send("dashboard-{id}", "delta", &new_state).await?;

Under concurrent load, two listeners can both read current = old, both .apply(&event), both UPDATE, and the second write clobbers the first. The fix is invariant: serialize per-key, persist and broadcast under one lock.

The Replacement

use ferro_projection::{Projection, ProjectionKey, ProjectionRuntime};
use ferro_events::Event;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

// Consumer event (already implements ferro_events::Event)
#[derive(Clone, Serialize, Deserialize)]
struct InventoryAdjusted { warehouse: String, sku: String, delta: i32 }

impl Event for InventoryAdjusted {
    fn name(&self) -> &'static str { "InventoryAdjusted" }
}

// Consumer projection state + delta
#[derive(Clone, Default, Serialize, Deserialize)]
struct WarehouseDashboard {
    totals: std::collections::HashMap<String, i64>,
}

#[derive(Clone, Serialize)]
struct WarehouseDelta { sku: String, new_total: i64 }

struct WarehouseProjection;

impl Projection for WarehouseProjection {
    type Event = InventoryAdjusted;
    type State = WarehouseDashboard;
    type Delta = WarehouseDelta;
    const NAME: &'static str = "inventory.dashboard";

    fn key(&self, event: &Self::Event) -> ProjectionKey {
        ProjectionKey::new(event.warehouse.clone())
    }

    fn apply(&self, state: &mut Self::State, event: &Self::Event) -> Self::Delta {
        let new_total = state.totals.entry(event.sku.clone()).or_insert(0);
        *new_total += event.delta as i64;
        WarehouseDelta { sku: event.sku.clone(), new_total: *new_total }
    }
}

// One-line wiring at application startup
let runtime = Arc::new(ProjectionRuntime::new(db.clone(), broadcaster.clone(), WarehouseProjection));
runtime.clone().register();

// Anywhere in the app:
InventoryAdjusted { warehouse: "a".into(), sku: "sku-1".into(), delta: 5 }
    .dispatch()
    .await?;

// Frontend subscribes to `projection.inventory.dashboard.a` and
// receives event `"delta"` with payload `{ "sku": "sku-1", "new_total": 5 }`.

Per-Key Serialization

 Event::dispatch() ─┐
                    │
      ProjectionListener<P> ──┐
                              │
                              ▼
 ┌── per-key Mutex (DashMap<String, Arc<Mutex<()>>>) ──┐
 │   1. load snapshot from projection_snapshots        │
 │   2. apply(&mut state, &event) → Delta              │
 │   3. upsert snapshot (state, version+1)             │
 │   4. broadcast on projection.{name}.{key}           │
 └─────────────────────────────────────────────────────┘
                              │
                              ▼
                WebSocket clients receive the delta

Same-key applies serialize through the per-key Mutex; different-key applies run in parallel through different DashMap shards.

The Projection Trait

pub trait Projection: Send + Sync + 'static {
    type Event: ferro_events::Event + Serialize + DeserializeOwned;
    type State: Clone + Default + Serialize + DeserializeOwned + Send + Sync + 'static;
    type Delta: Serialize + Clone + Send + Sync + 'static;

    const NAME: &'static str;

    fn key(&self, event: &Self::Event) -> ProjectionKey;
    fn apply(&self, state: &mut Self::State, event: &Self::Event) -> Self::Delta;

    // Defaulted:
    fn snapshot_interval(&self) -> u32 { 100 }
    fn broadcast_event_name(&self) -> &'static str { "delta" }
}
  • NAME is a dotted-namespace constant: "inventory.dashboard", "checkout.cart", "orders.recent". Same convention as ferro-audit's action namespace.
  • State: Default is required so a fresh key initializes from State::default() on first apply. If a state model has no sensible default, return an empty or zero variant from Default.
  • apply is synchronous. It runs inside the per-key Mutex; an async apply would let the lock cross await boundaries and serialize unrelated work. Heavy work (HTTP fetches, additional DB queries) happens before dispatch or after receiving the broadcast delta on the client side.

Multi-tenancy: bake the tenant identifier into the key string ("tenant-7:warehouse-a"); the runtime does not auto-scope by tenant.

Constructing the Runtime

let runtime = ProjectionRuntime::new(
    db_connection,
    broadcaster_arc,
    my_projection,
);

The runtime owns the database connection, the broadcaster handle, the projection impl, and the per-key Mutex registry. Wrap in Arc for sharing across tokio tasks; register requires Arc<Self>.

Two Entry Points

MethodUse
Arc<Runtime>::register()Auto-wires a listener into ferro_events::global_dispatcher(). Every P::Event::dispatch().await flows through the projection. The one-line wiring.
runtime.apply_event(&event).awaitManual entry point — bypass the global dispatcher. For tests, replay scripts, custom dispatchers.

Both paths share the same per-key serialization, persistence, and broadcast logic. Mixing both in the same runtime is safe — the manual path does not interfere with the registered listener.

The Read Path

// Returns Result<Option<State>, ProjectionError>
let maybe_state = runtime.read(&ProjectionKey::new("warehouse-a")).await?;

// Returns Result<State, ProjectionError> with StateNotFound on miss
let state = runtime.read_required(&ProjectionKey::new("warehouse-a")).await?;

read does NOT acquire the per-key Mutex. Concurrent read + apply_event is safe; the SQL upsert is atomic at the DB level so readers see either the pre- or post-upsert state, never a torn read.

The Rebuild Path

let events: Vec<InventoryAdjusted> = audit_log_replay_for_key().collect();
let state = runtime
    .rebuild(&ProjectionKey::new("warehouse-a"), events)
    .await?;

rebuild discards the existing snapshot for the key, folds the supplied event sequence through State::default(), persists the final state, and broadcasts ONE "rebuild" frame carrying the full final state (overriding the default broadcast_event_name). Clients reset their local state on receipt of a "rebuild" frame.

Empty iterator wipes the snapshot row and returns State::default() with no insert and no broadcast.

Broadcast Channel Contract

  • Channel: format!("projection.{}.{}", P::NAME, key.as_str()) — example: "projection.inventory.dashboard.warehouse-a".
  • Event name: from P::broadcast_event_name(), default "delta". Override to "dashboard_updated", "cart_changed", etc., if the frontend dispatches on event name.
  • Payload: raw JSON-serialized P::Delta. No envelope, no wrapping object — frontends receive the delta directly.
  • Rebuild frame: event name "rebuild", payload is the full final state (not a delta).

Operational Footguns

  1. Broadcast failure does NOT roll back state. If Broadcast::send fails (no subscribers, network error), the snapshot row is already persisted; the runtime logs at tracing::warn! and returns ProjectionError::Broadcast. Subscribers reconcile by re-reading the snapshot via runtime.read(...).
  2. Single-instance assumption. v0 assumes a single application instance owns each projection's listener. Multi-instance deployments must elect a single projection-runner node or accept last-writer-wins behavior on concurrent applies to the same key from different nodes.
  3. register is not idempotent on Arc identity. Calling Arc<ProjectionRuntime<P>>::register() twice registers two listeners — both fire on each dispatch (same semantic as registering a listener twice in any event bus). Register once at app startup.

Worked Example: Reservation Counts Dashboard

A live counter dashboard that folds ferro_reservation::ReservationEvent into per-resource_kind {held, committed, released} counters:

use ferro_projection::{Projection, ProjectionKey, ProjectionRuntime};
use ferro_reservation::{ReleaseReason, ReservationEvent};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Clone, Default, Serialize, Deserialize)]
struct ReservationCounters {
    held: u32,
    committed: u32,
    released: u32,
}

#[derive(Clone, Serialize)]
struct CountersDelta {
    held: u32,
    committed: u32,
    released: u32,
}

struct ReservationCountProjection;

impl Projection for ReservationCountProjection {
    type Event = ReservationEvent;
    type State = ReservationCounters;
    type Delta = CountersDelta;
    const NAME: &'static str = "reservations.counters";

    fn key(&self, event: &Self::Event) -> ProjectionKey {
        let rk = match event {
            ReservationEvent::Held { resource_kind, .. }
            | ReservationEvent::Committed { resource_kind, .. }
            | ReservationEvent::Released { resource_kind, .. }
            | ReservationEvent::Expired { resource_kind, .. } => resource_kind,
        };
        ProjectionKey::new(rk.clone())
    }

    fn apply(&self, state: &mut Self::State, event: &Self::Event) -> Self::Delta {
        match event {
            ReservationEvent::Held { .. } => state.held += 1,
            ReservationEvent::Committed { .. } => state.committed += 1,
            ReservationEvent::Released { .. } | ReservationEvent::Expired { .. } => {
                state.released += 1
            }
        }
        CountersDelta {
            held: state.held,
            committed: state.committed,
            released: state.released,
        }
    }
}

// App startup
let runtime = Arc::new(ProjectionRuntime::new(
    db.clone(),
    broadcaster.clone(),
    ReservationCountProjection,
));
runtime.clone().register();

// Every reservation transition (hold/commit/release/expire) emitted
// by ferro-reservation now flows into per-resource_kind counter state
// and a delta on `projection.reservations.counters.{resource_kind}`.

Each call to Kernel::hold, Kernel::commit, or Kernel::release dispatches a ReservationEvent; the projection folds it; a counter-update delta lands on the subscribed WebSocket channel; the operator dashboard updates in real time.

Schema

The migration creates a single projection_snapshots table:

projection_snapshots
├── projection_name VARCHAR NOT NULL     -- P::NAME ("inventory.dashboard")
├── key             VARCHAR NOT NULL     -- ProjectionKey.as_str()
├── state           JSON NOT NULL        -- serialized P::State
├── version         BIGINT NOT NULL      -- monotonic counter
├── updated_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
└── PRIMARY KEY (projection_name, key)   -- composite PK

Register the migration in your Migrator:

impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(ferro_projection::CreateProjectionSnapshotsTable),
            // ... your app migrations
        ]
    }
}

Errors

ProjectionError is the single error enum:

VariantWhen
Db(#[from] sea_orm::DbErr)underlying database error
Json(#[from] serde_json::Error)State serialization failure
Broadcast(String)broadcast publish failed (state already persisted)
Events(String)event-bus error from ferro_events::Error
StateNotFound { name, key }read_required hit a missing key

Display prefix is "projection: …" for grep-friendliness across the workspace.

AI & Confirmation

Ferro provides two AI primitives in the ferro-ai crate, accessible via the ai feature flag:

  • Classification — structured LLM output with provider abstraction and retry logic
  • Confirmation — gate destructive actions behind explicit user confirmation with TTL expiry

Setup

Add the ai feature to your ferro-rs dependency:

[dependencies]
ferro-rs = { version = "0.1", features = ["ai"] }

Set ANTHROPIC_API_KEY in your .env or environment before using the AnthropicProvider.


AI Classification

Classification turns unstructured user input into typed, schema-validated Rust structs by calling an LLM with a JSON Schema constraint.

Basic Usage

Define an output struct that implements serde::Deserialize. Include a confidence: f64 field to enable threshold enforcement:

#![allow(unused)]
fn main() {
use ferro::{Classifier, ClassifierConfig, AnthropicProvider};
use serde::Deserialize;
use std::sync::Arc;

#[derive(Deserialize)]
struct CommandIntent {
    action: String,
    target: Option<String>,
    confidence: f64,
}

async fn classify_command(text: &str) -> ferro::AiError {
    let provider = AnthropicProvider::from_env()?;
    let classifier = Classifier::<CommandIntent>::new(
        Arc::new(provider),
        ClassifierConfig::default(),
    );

    let schema = serde_json::json!({
        "type": "object",
        "properties": {
            "action": { "type": "string", "enum": ["delete", "update", "list"] },
            "target": { "type": "string" },
            "confidence": { "type": "number" }
        },
        "required": ["action", "confidence"]
    });

    let result = classifier
        .classify("You classify user commands.", text, &schema)
        .await?;

    println!("action: {}", result.value.action);
    println!("confidence: {:?}", result.confidence);
    Ok(())
}
}

Schema Generation

For complex output types, use schemars to derive the schema automatically. Add it to your Cargo.toml:

schemars = "1"
#![allow(unused)]
fn main() {
use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Deserialize, JsonSchema)]
struct IntentClassification {
    intent: String,
    confidence: f64,
    parameters: std::collections::HashMap<String, String>,
}

let schema = schemars::schema_for!(IntentClassification);
let schema_value = serde_json::to_value(&schema).expect("schema serialization is infallible");
}

Configuration

ClassifierConfig controls model selection, token limits, retry behavior, and confidence thresholds:

#![allow(unused)]
fn main() {
use ferro::ClassifierConfig;
use std::time::Duration;

let config = ClassifierConfig {
    model: "claude-opus-4-6".to_string(),
    max_tokens: 2048,
    max_retries: 2,
    retry_delay: Duration::from_secs(2),
    confidence_threshold: 0.8,
};
}
FieldDefaultDescription
modelclaude-sonnet-4-6Model ID passed to the provider
max_tokens1024Maximum response tokens
max_retries1Additional retry attempts on transient errors
retry_delay1sDelay between retries
confidence_threshold0.7Minimum confidence required (set to 0.0 to disable)

Custom Providers

Implement the ClassificationProvider trait to use any LLM backend:

#![allow(unused)]
fn main() {
use ferro::{ClassificationProvider, ClassifierConfig};
use async_trait::async_trait;

struct MyProvider;

#[async_trait]
impl ClassificationProvider for MyProvider {
    async fn classify_raw(
        &self,
        system_prompt: &str,
        user_prompt: &str,
        schema: &serde_json::Value,
        config: &ClassifierConfig,
    ) -> Result<serde_json::Value, ferro::AiError> {
        // Call your LLM API here, return JSON matching schema
        Ok(serde_json::json!({"action": "list"}))
    }
}
}

Error Handling

#![allow(unused)]
fn main() {
use ferro::AiError;

match classifier.classify(system, user, &schema).await {
    Ok(result) => { /* use result.value */ }
    Err(AiError::LowConfidence { best_guess, confidence }) => {
        // Response was below confidence_threshold
        eprintln!("Low confidence: {confidence}");
    }
    Err(AiError::Provider(msg)) => {
        // HTTP error from the provider (permanent: 4xx, transient: 5xx)
        eprintln!("Provider error: {msg}");
    }
    Err(AiError::Timeout) => {
        // Request timed out after all retry attempts
    }
    Err(e) => eprintln!("Other error: {e}"),
}
}

Retry behavior:

  • Transient errors (429, 500, 503, 529) are retried up to max_retries additional times
  • Permanent errors (400, 401, 403, 404, 422) are not retried
  • LowConfidence is returned immediately without retrying

WhatsApp Command Example

#![allow(unused)]
fn main() {
#[derive(Deserialize, JsonSchema)]
struct WhatsAppIntent {
    intent: String, // "add_expense", "list_expenses", "help"
    amount: Option<f64>,
    category: Option<String>,
    confidence: f64,
}

let classifier = Classifier::<WhatsAppIntent>::new(
    Arc::new(AnthropicProvider::from_env()?),
    ClassifierConfig {
        confidence_threshold: 0.75,
        ..Default::default()
    },
);

let schema = serde_json::to_value(schemars::schema_for!(WhatsAppIntent)).expect("schema serialization is infallible");
let result = classifier
    .classify(
        "You classify expense management commands from WhatsApp messages. \
         Extract intent, amount (if present), and category.",
        "spent 15 on coffee",
        &schema,
    )
    .await?;

// result.value.intent == "add_expense"
// result.value.amount == Some(15.0)
// result.value.category == Some("coffee")
}

Confirmation

The confirmation primitive gates destructive actions behind explicit user acknowledgement. It uses an in-memory store with per-action TTL expiry and integrates with the Ferro event system.

Basic Usage

#![allow(unused)]
fn main() {
use ferro::{InMemoryConfirmationStore, ConfirmationStore};
use std::time::Duration;

// Create store (typically as a shared Arc in your app state)
let store = std::sync::Arc::new(InMemoryConfirmationStore::new());

// Request confirmation — stores the action payload and starts TTL timer
let key = format!("delete:expense:{}:{}", tenant_id, expense_id);
let payload = serde_json::json!({ "expense_id": expense_id, "amount": 42.50 });
store.request_confirmation(&key, payload, Duration::from_secs(300)).await?;

// User confirms (e.g., replies "yes" or taps a button)
let confirmed_payload = store.confirm(&key).await?;

// Or user cancels
store.reject(&key).await?;
}

Key Design

Use composite string keys to scope confirmations by context:

"scope:user:action:id"

Examples:

  • "expense:user-42:delete:expense-7"
  • "subscription:user-42:cancel"
  • "batch:user-42:import:file-123"

This prevents cross-user confirmation attacks and keeps keys self-documenting.

TTL and Auto-Expiry

When the TTL expires before confirmation or rejection, the store automatically:

  1. Removes the pending action
  2. Dispatches a ConfirmationExpired event via ferro_events::dispatch

Listen for this event to notify users:

#![allow(unused)]
fn main() {
use ferro::{Listener, ConfirmationExpired, EventError};
use async_trait::async_trait;

pub struct NotifyExpired;

#[async_trait]
impl Listener<ConfirmationExpired> for NotifyExpired {
    async fn handle(&self, event: &ConfirmationExpired) -> Result<(), EventError> {
        // event.key — the confirmation key that expired
        // event.payload — the original payload
        println!("Confirmation expired for: {}", event.key);
        // Send notification to user
        Ok(())
    }
}
}

Register the listener in your bootstrap.rs:

#![allow(unused)]
fn main() {
ferro::dispatch_event(ConfirmationExpired { key, payload });
}

Delete Expense Flow

#![allow(unused)]
fn main() {
#[handler]
pub async fn request_delete(req: Request, store: Arc<InMemoryConfirmationStore>) -> Response {
    let expense_id: i64 = req.param("id")?;
    let tenant_id = current_tenant().map(|t| t.id).unwrap_or(0);

    let key = format!("expense:{}:delete:{}", tenant_id, expense_id);
    let payload = serde_json::json!({ "expense_id": expense_id });

    store
        .request_confirmation(&key, payload, Duration::from_secs(300))
        .await?;

    Ok(json!({ "status": "pending", "message": "Reply 'confirm' to delete this expense." }))
}

#[handler]
pub async fn confirm_delete(req: Request, store: Arc<InMemoryConfirmationStore>) -> Response {
    let expense_id: i64 = req.param("id")?;
    let tenant_id = current_tenant().map(|t| t.id).unwrap_or(0);

    let key = format!("expense:{}:delete:{}", tenant_id, expense_id);
    let payload = store.confirm(&key).await?;

    // payload contains the original data — perform the deletion
    let expense_id: i64 = payload["expense_id"].as_i64().unwrap_or(0);
    Expense::delete_by_id(expense_id).exec(&DB.get().ok_or_else(|| HttpResponse::internal_server_error())?).await?;

    Ok(json!({ "status": "deleted" }))
}
}

Listing Pending Actions

#![allow(unused)]
fn main() {
let pending = store.list_pending().await;
// Returns Vec<PendingActionInfo> with key, payload, and expires_at
for action in pending {
    println!("{}: expires at {:?}", action.key, action.expires_at);
}
}

MCP Tools

Two MCP tools are available for debugging AI features during development.

test_classifier

Test a classification prompt against the Anthropic API without writing handler code:

  • When to use: Iterating on prompts, verifying schema compliance, debugging output shape
  • Requires: ANTHROPIC_API_KEY in environment or .env
  • Note: Makes a real API call. Costs tokens.
  • Returns: { success, result, model, error }

list_pending_confirmations

Scan source code for request_confirmation call sites:

  • When to use: Auditing which handlers use confirmation, understanding confirmation flows
  • Note: Confirmation state is in-memory — this tool scans source files, not runtime state
  • Returns: File paths and line numbers where request_confirmation is called

ai_scaffold

Generate a ferro_projections::ServiceDef from a natural-language description, using the project's live introspection (models, routes, schema, existing projections) as context.

  • When to use: Starting a new service; getting a typed ServiceDef to inspect or pass to a renderer.
  • Returns: A ServiceDef JSON object — the same shape ferro ai:make produces. Does NOT write files; use the ferro ai:make CLI command to write src/projections/<name>.rs.
  • Note: Makes a real LLM call (costs tokens, governed by FERRO_AI_MAX_TOKENS_PER_COMMAND). Requires FERRO_AI_PROVIDER, FERRO_AI_API_KEY, FERRO_AI_MODEL.
  • Combine with: list_projections (avoid name collisions), inspect_projection, ai_explain.

ai_explain

Explain a route, model, or service projection in projection-framed vocabulary.

  • When to use: Understanding how a service/route/model maps onto the projection/intent model before modifying or rendering it.
  • Returns: Structured projection JSON (Intent hints, field meanings, actions, state machine) with zero LLM tokens when the target resolves to a ServiceDef; an LLM-generated { "prose": ... } explanation when only a route or model matches.
  • Note: The structured branch spends no tokens; the prose fallback makes a real LLM call.
  • Combine with: inspect_projection, list_projections, ai_scaffold.

Deployments

ferro-deployments provides immutable deployment rows, atomic pointer promotion, and an artifact storage abstraction. Each deployment is an append-only database record. A per-owner pointer row tracks the active and previous deployment IDs, enabling atomic promotion and single-step rollback without custom SQL in the consumer.

Artifact shape is opaque — the crate stores and retrieves raw bytes. Static site bundles, JSON-UI spec bundles, SSR manifests, and any other byte sequence all fit without crate-level assumptions about content type.

Setup

Migration

Register both migration helpers in your application's Migrator before your own migrations:

#![allow(unused)]
fn main() {
use ferro_deployments::{CreateDeploymentsTable, CreateDeploymentPointersTable};
use sea_orm_migration::prelude::*;

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(CreateDeploymentsTable),
            Box::new(CreateDeploymentPointersTable),
            // ... your own migrations
        ]
    }
}
}

CreateDeploymentsTable creates the deployments table. CreateDeploymentPointersTable creates the deployment_pointers table. Both use the SeaORM SchemaManager DDL builder and are portable across SQLite and Postgres.

Environment Variables

# Optional: wildcard subdomain domain for preview URLs.
# When set, preview_url() returns "https://{identifier}.{domain}/".
DEPLOYMENT_PREVIEW_DOMAIN=preview.example.com

Lifecycle API

The Deployments handle wraps a DatabaseConnection and exposes the full lifecycle:

#![allow(unused)]
fn main() {
use ferro_deployments::Deployments;

let deps = Deployments::new(db);
}

Creating a deployment

#![allow(unused)]
fn main() {
// owner_key scopes the deployment (e.g. tenant slug, project name).
// source_ref is optional (branch name, commit SHA, tag).
let d = deps.create("project:my-app", Some("sha-abc123")).await?;
// d.status == DeploymentStatus::Building
// d.identifier is a UUID v4 string, stable across retries.
}

Transitioning to ready

After the artifact is built and stored, record the artifact location and byte size:

#![allow(unused)]
fn main() {
deps.mark_ready(d.id, "deployments/42/", 98304).await?;
}

Marking a build as failed

#![allow(unused)]
fn main() {
deps.mark_failed(d.id, "compiler error: ...").await?;
}

The error string is emitted via tracing::warn — there is no error column in the schema.

Fetching deployments

#![allow(unused)]
fn main() {
// By primary key.
let deployment = deps.get(id).await?;

// All deployments for an owner, newest first.
let all = deps.list("project:my-app").await?;

// Currently active deployment (None when no pointer row exists yet).
let active = deps.active("project:my-app").await?;
}

Atomic Promote Model

promote flips the active pointer in a single INSERT … ON CONFLICT DO UPDATE … RETURNING statement. No separate SELECT-then-UPDATE — the atomic upsert preserves the previous pointer in the same operation.

#![allow(unused)]
fn main() {
// Returns Some(previous_id) or None on first promotion.
let previous_id = deps.promote("project:my-app", d.id).await?;
}

Promote enforces two guards:

  • Error::NotReady — the deployment is not in ready status.
  • Error::ArtifactDeletedartifact_deleted_at is set; the artifact was garbage-collected.

Rollback

Rollback is promote-of-previous: the pointer row's previous_deployment_id is read and promoted. All guards apply — the previous deployment must still be ready with its artifact intact.

#![allow(unused)]
fn main() {
// Promotes the previous deployment back to active.
deps.rollback("project:my-app").await?;
}

Returns Error::NoPreviousDeployment when the pointer row has no previous_deployment_id (first deployment, or previous was cleared).

Artifact Storage

DeploymentStorage is an async trait abstracting five prefix-scoped operations. All object keys are scoped to deployments/{deployment_id}/ — each deployment gets an isolated key namespace.

#![allow(unused)]
fn main() {
pub trait DeploymentStorage: Send + Sync {
    async fn store(&self, deployment_id: i64, path: &str, bytes: Bytes) -> Result<(), Error>;
    async fn retrieve(&self, deployment_id: i64, path: &str) -> Result<Bytes, Error>;
    async fn remove(&self, deployment_id: i64, path: &str) -> Result<(), Error>;
    async fn list(&self, deployment_id: i64) -> Result<Vec<String>, Error>;
    async fn remove_all(&self, deployment_id: i64) -> Result<(), Error>;
}
}

Default implementation

StorageDeploymentStorage delegates to a ferro_storage::Disk:

#![allow(unused)]
fn main() {
use ferro_deployments::StorageDeploymentStorage;
use ferro_storage::{DiskConfig, Storage, StorageConfig};

let config = StorageConfig::from_env();
let disk = Storage::with_storage_config(config).disk("default")?;
let storage = StorageDeploymentStorage::new(disk);
}

For testing, use the memory driver:

#![allow(unused)]
fn main() {
let config = StorageConfig::new("mem").disk("mem", DiskConfig::memory());
let disk = Storage::with_storage_config(config).disk("mem").unwrap();
let storage = StorageDeploymentStorage::new(disk);
}

Storing and retrieving an artifact

#![allow(unused)]
fn main() {
use ferro_deployments::DeploymentStorage;
use bytes::Bytes;

let spec = Bytes::from(serde_json::to_vec(&my_spec)?);
storage.store(d.id, "spec.json", spec.clone()).await?;

// Later, or in the serving path:
let retrieved = storage.retrieve(d.id, "spec.json").await?;
}

Preview URLs

preview_url formats a wildcard-subdomain URL for a deployment. It reads the domain exclusively from DeploymentConfig.preview_domain (DEPLOYMENT_PREVIEW_DOMAIN env var) — no domain is hardcoded.

#![allow(unused)]
fn main() {
use ferro_deployments::{DeploymentConfig, preview_url};

let config = DeploymentConfig::from_env();
// Pass the deployment identifier string directly.
if let Some(url) = preview_url(&config, &deployment.identifier) {
    println!("Preview: {url}");
    // "https://{identifier}.preview.example.com/"
}
}

Preview URLs are publicly addressable by design. The subdomain identifier is not an access-control token and does not restrict who can fetch the URL. The consumer application owns authorization for preview routes.

Error Reference

VariantMeaning
Error::Db(DbErr)Database operation failed
Error::NotFound { id }No deployment row with the given id
Error::NotReady { id }Promote rejected — deployment is not in ready status
Error::ArtifactDeleted { id }Promote rejected — artifact was garbage-collected
Error::NoPreviousDeployment { owner_key }Rollback rejected — no previous pointer
Error::Storage(StorageError)Underlying storage operation failed

Asset Pipeline

ferro-assets provides a composable, content-type-aware asset pipeline for publish-time optimization over an in-memory file set.

A Pipeline runs over a heterogeneous Asset collection (HTML, CSS, JS, images, and any other files such as JSON-UI spec bundles). Each transform declares the content types it accepts; every other file passes through byte-for-byte unchanged — the passthrough guarantee. This makes the same pipeline safe for mixed artifact sets without any per-file branching in the consumer.

ferro-assets uses only pure-Rust codecs. No C system packages (libvips, libavif, libwebp) are required; cargo build adds nothing to the production image.

Quick Start

use ferro_assets::{Asset, Pipeline};
use ferro_assets::transforms::{
    HtmlMinify, CssMinify, JsMinify,
    ImageTranscode, ResponsiveImages,
    InjectBeforeTag, ReplaceTokens,
};
use std::collections::HashMap;

let pipeline = Pipeline::new()
    .add(HtmlMinify::new())
    .add(CssMinify::new())
    .add(JsMinify::new())
    .add(ImageTranscode::new())
    .add(ResponsiveImages::new())
    .add(InjectBeforeTag::new("</body>", r#"<script src="/sdk.js"></script>"#))
    .add(ReplaceTokens::new(HashMap::new()));

// pipeline.run() is synchronous (blocking). Wrap in spawn_blocking
// when calling from an async context.
let result = tokio::task::spawn_blocking(move || pipeline.run(assets)).await??;

The spawn_blocking wrapper is required: pipeline.run() is CPU-bound synchronous work. Calling it directly on the async executor stalls every concurrent HTTP request for the duration of the pipeline.

Asset and ContentType Model

An Asset is an in-memory file with a logical path, a content type, and its bytes:

use ferro_assets::{Asset, ContentType};
use bytes::Bytes;

// ContentType is inferred from the path extension.
let html  = Asset::new("index.html",  Bytes::from(html_bytes));
let css   = Asset::new("app.css",     Bytes::from(css_bytes));
let image = Asset::new("hero.jpg",    Bytes::from(jpeg_bytes));
let spec  = Asset::new("spec.json",   Bytes::from(json_bytes));
// spec has ContentType::Other — no built-in transform touches it.

// Override content type explicitly when needed.
let asset = Asset::new("file", bytes).with_content_type(ContentType::Js);

infer_content_type maps path extensions to ContentType variants:

Extension(s)ContentType
.html, .htmHtml
.cssCss
.js, .mjsJs
.jpg, .jpegJpeg
.pngPng
.avifAvif
anything elseOther (passthrough)

Asset.path is a logical artifact keyferro-assets never reads or writes files. If the consumer uses a path to write output to disk, it must sanitize it against path traversal before doing so.

Built-in Transforms

HtmlMinify

Collapses redundant whitespace in visible body text using lol_html. The content of <script> and <style> elements is treated as opaque — whitespace inside those elements is never touched. Template literals, multi-line strings, and inline JSON blobs inside <script> survive the transform byte-correct.

use ferro_assets::transforms::HtmlMinify;

let transform = HtmlMinify::new();

CssMinify

Minifies CSS using lightningcss (pinned to =1.0.0-alpha.71 — this alpha version pins a stable API; a relaxed semver range would silently pick up breaking alpha bumps).

use ferro_assets::transforms::CssMinify;

let transform = CssMinify::new();

JsMinify

Minifies JavaScript using swc compress+mangle. The transform operates on JS files only; JSON and other text files that happen to contain JavaScript-like content are not touched because their ContentType is Other.

use ferro_assets::transforms::JsMinify;

let transform = JsMinify::new();

ImageTranscode

Transcodes JPEG, PNG, and AVIF source images into AVIF and JPEG variants at configurable responsive widths. Variants are added to the asset set alongside the original source (the original is retained as the JPEG fallback for <img src>).

Default configuration:

  • Widths: [480, 768, 1200, 1920] — only widths ≤ source.width() are emitted (no upscaling).
  • Max concurrent encodes: 2 — bounds peak memory on small instances.
  • AVIF quality: 70.0, AVIF speed: 4 (faster than the slowest setting without quality loss).
  • JPEG quality: 80.
use ferro_assets::transforms::ImageTranscode;

let transform = ImageTranscode::new()
    .with_widths(vec![640, 1280])
    .with_max_concurrent(4)
    .with_avif_quality(75.0)
    .with_jpeg_quality(85);

Variant naming is deterministic: {stem}-{width}w.avif and {stem}-{width}w.jpg (e.g. assets/hero-768w.avif). ResponsiveImages discovers these by name — run ImageTranscode before ResponsiveImages in the pipeline.

ResponsiveImages

Rewrites every <img src="…"> element in HTML files to a <picture> element referencing the AVIF and JPEG variants already present in the asset set. Relies on the deterministic variant naming from ImageTranscode. Run after ImageTranscode.

use ferro_assets::transforms::ResponsiveImages;

let transform = ResponsiveImages::new();

Example output (for <img src="assets/hero.jpg">):

<picture>
  <source type="image/avif" srcset="assets/hero-480w.avif 480w, assets/hero-768w.avif 768w, assets/hero-1200w.avif 1200w">
  <img src="assets/hero.jpg" ...>
</picture>

InjectBeforeTag

Inserts a literal snippet immediately before a given closing tag in every HTML file. The primary use is injecting an SDK <script> before </body>.

use ferro_assets::transforms::InjectBeforeTag;

let transform = InjectBeforeTag::new("</body>", r#"<script src="/sdk.js"></script>"#);

The tag name is validated — only alphanumeric and hyphen characters are accepted. An invalid tag name is a no-op (the transform returns the asset unchanged).

ReplaceTokens

Performs raw byte substitution of %%TOKEN%%-style placeholders across every file, regardless of content type. Tokens can appear in HTML attributes, inline scripts, CSS custom properties, or JSON values — the transform operates on raw bytes, not a parsed DOM.

use ferro_assets::transforms::ReplaceTokens;
use std::collections::HashMap;

let tokens = HashMap::from([
    ("%%CDN_URL%%".to_string(),  "https://cdn.example.com".to_string()),
    ("%%APP_VERSION%%".to_string(), "1.0.3".to_string()),
]);
let transform = ReplaceTokens::new(tokens);

Substitution is literal with no evaluation or recursion. The caller is responsible for sanitizing token values before constructing the map — values are written verbatim into the output bytes.

Writing Custom Transforms

Implement Transform and use map_matching to gate work by content type:

use ferro_assets::{Asset, ContentType, Error, Transform, map_matching};

pub struct StripHtmlComments;

impl Transform for StripHtmlComments {
    fn run(&self, assets: Vec<Asset>) -> Result<Vec<Asset>, Error> {
        map_matching(assets, &[ContentType::Html], |a| {
            let out = remove_comments(&a.bytes)
                .map_err(|e| Error::transform("strip_html_comments", &a.path, e))?;
            Ok(Asset { bytes: out.into(), ..a })
        })
    }
}

map_matching iterates the asset set: files in accepted are passed to the closure; everything else passes through unchanged. The collection short-circuits on the first Err — no partial output is produced.

For transforms that operate on the whole asset set (e.g. cross-referencing variant names), implement run directly without map_matching:

impl Transform for MyBatchTransform {
    fn run(&self, assets: Vec<Asset>) -> Result<Vec<Asset>, Error> {
        // process the full set and return a new Vec<Asset>
        Ok(assets)
    }
}

Error Reference

VariantMeaning
Error::Transform { transform, path, cause }A transform failed on a specific asset. transform names the stage (e.g. "html_minify"), path is the logical asset path, cause is the underlying message.
Error::Setup(msg)Thread pool or initialisation failure (e.g. rayon ThreadPoolBuilder error).

Getting Started with JSON-UI

JSON-UI is a server-driven UI system where you write JSON spec files. Handlers supply data as serde_json::Value; the framework loads the spec, resolves expressions against that data, and renders an HTML page. No frontend toolchain is required.

Prerequisites

  • An existing Ferro application
  • No additional dependencies — JSON-UI is built into the framework

Step 1 — Create the spec file

Create src/views/dashboard.json in your application:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Dashboard",
  "layout": "dashboard",
  "root": "welcome",
  "elements": {
    "welcome": {
      "type": "Card",
      "props": {
        "title": "Welcome"
      },
      "children": ["orders_stat"]
    },
    "orders_stat": {
      "type": "StatCard",
      "props": {
        "label": "Orders Today",
        "value": { "$data": "/orders_today" }
      }
    }
  }
}

Key points:

  • "root" is the ID of the top-level element — the entry point when rendering begins.
  • "elements" is a flat map. Each key is an element ID; each value describes one component.
  • "children" is an array of element IDs, not nested objects. The renderer looks up each ID in "elements" to render child components.
  • { "$data": "/orders_today" } is a data expression — it reads the orders_today field from handler data at render time.

Step 2 — Write the handler

Create src/controllers/dashboard.rs:

#![allow(unused)]
fn main() {
use ferro::{handler, JsonUi, Response};

#[handler]
pub async fn index() -> Response {
    let data = serde_json::json!({
        "orders_today": 42
    });
    JsonUi::render_file("views/dashboard.json", data)
}
}

The handler assembles data as serde_json::json!({...}) and passes it to render_file. No component building happens in Rust — the spec file defines the structure; the handler defines the data.

Step 3 — Register the route

Add the route in src/routes.rs:

#![allow(unused)]
fn main() {
get!("/dashboard", controllers::dashboard::index).name("dashboard.index");
}

Step 4 — Run the app

ferro serve

Visit http://localhost:3000/dashboard. You will see a Card containing a StatCard showing "42".

Data binding

The { "$data": "/path" } expression reads from handler data using a slash-separated JSON Pointer path. The leading / is followed by a key in the data object.

Example: { "$data": "/orders_today" } reads data.orders_today.

For nested data:

{
  "$schema": "ferro-json-ui/v2",
  "title": "User Profile",
  "root": "profile_card",
  "elements": {
    "profile_card": {
      "type": "Card",
      "props": {
        "title": { "$data": "/user/name" }
      },
      "children": ["email_field"]
    },
    "email_field": {
      "type": "Text",
      "props": {
        "content": { "$data": "/user/email" }
      }
    }
  }
}

Handler:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show() -> Response {
    let data = serde_json::json!({
        "user": {
            "name": "Alice",
            "email": "alice@example.com"
        }
    });
    JsonUi::render_file("views/profile.json", data)
}
}

Layouts

The "layout" field in the spec controls page structure. Available built-in layouts:

ValueDescription
"dashboard"Sidebar navigation with header
"app"Top navigation bar
"auth"Centered card, used for login / register pages
"" or omitMinimal default — no navigation chrome

Set the layout in the spec root:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Settings",
  "layout": "app",
  "root": "settings_card",
  "elements": {
    "settings_card": {
      "type": "Card",
      "props": { "title": "Application Settings" }
    }
  }
}

Next Steps

  • Components — Reference for all built-in component types and their props
  • Actions — Navigation, form submission, confirmations, and outcomes
  • Data Binding & Visibility — Expressions and conditional rendering
  • Layouts — Page structure and custom layouts
  • Plugins — Extend the component catalog with custom interactive components

Components

Every component in a JSON-UI spec is referenced by its "type" string in a flat element map. For the full spec format and workflow, see Getting Started.

Each element follows this shape:

"element_id": {
  "type": "ComponentTypeName",
  "props": {
    "prop_name": "prop_value"
  },
  "children": ["child_id"],
  "action": { "handler": "route.name", "method": "POST" },
  "visible": { "path": "/data/status", "operator": "eq", "value": "active" }
}

The sections below document every built-in component: its props table (with JSON types) and a complete element example.


Component Overview

CategoryComponents
LayoutCard, Grid, Tabs, Separator, Modal, Skeleton, Collapsible, FormSection
Data DisplayText, DataTable, Table, DescriptionList, Badge, Avatar, Progress, Breadcrumb, Pagination, StatCard, Image, CalendarCell
FormsForm, Input, Select, Checkbox, CheckboxList, CheckboxGroup, Switch, Button, ButtonGroup, DropdownMenu
FeedbackAlert, Toast, EmptyState
NavigationSidebar, Header, PageHeader, NotificationDropdown
ActionActionCard
OnboardingChecklist
CommerceProductTile
KanbanKanbanBoard, KanbanColumn
ExtensibleRawHtml, Plugin (see Plugins)

Shared Enum Values

Several props accept fixed-string enum values. The valid strings are listed here; each component section references these by name.

size"xs" | "sm" | "default" | "lg"

button_variant"default" | "secondary" | "destructive" | "outline" | "ghost" | "link"

alert_variant"info" | "success" | "warning" | "error"

badge_variant"default" | "secondary" | "destructive" | "outline"

column_format"date" | "date_time" | "currency" | "boolean"

text_element"p" | "h1" | "h2" | "h3" | "span" | "div" | "section"

toast_variant"info" | "success" | "warning" | "error"

input_type"text" | "email" | "password" | "number" | "textarea" | "hidden" | "date" | "time" | "url" | "tel" | "search"

orientation"horizontal" | "vertical"

icon_position"left" | "right"

sort_direction"asc" | "desc"

form_max_width"default" | "narrow" | "wide"

gap_size"none" | "sm" | "md" (default) | "lg" | "xl"

action_card_variant"default" | "setup" | "danger"


Layout Components

Card

Container with title, optional description, nested children, and footer.

PropTypeDescription
titlestringCard heading
descriptionstring | nullSecondary text below the title
subtitlestring | nullMuted secondary identifier rendered between title and description (e.g. staff name beneath customer name)
badgestring | nullSmall Badge-styled pill rendered to the right of the title (Secondary variant chrome) for status indicators, counters, or countdown labels

Children are element IDs listed in the "children" array on the element, not in props.

"user_card": {
  "type": "Card",
  "props": {
    "title": "User Details",
    "description": "Account information"
  },
  "children": ["name_text", "email_text"]
}

Optional subtitle and badge slots add a muted secondary identifier and a Badge-styled pill respectively. Vertical stacking: title → subtitle → description.

"booking_card": {
  "type": "Card",
  "props": {
    "title": "Booking #1",
    "subtitle": "Marco Rossi",
    "description": "Pending email confirmation",
    "badge": "Scade tra 9m"
  },
  "children": []
}

Children semantics: On container components (Card, Form, Grid, etc.) children is an array of element ID strings that reference entries in the spec's flat elements map. The elements themselves are siblings at the top level — not nested.

{
  "$schema": "ferro-json-ui/v2",
  "root": "card_main",
  "elements": {
    "card_main": {
      "type": "Card",
      "props": { "title": "Welcome" },
      "children": ["heading", "form_login"]
    },
    "heading": {
      "type": "Text",
      "props": { "content": "Sign in", "element": "h2" }
    },
    "form_login": {
      "type": "Form",
      "props": { "max_width": "sm" },
      "children": ["email_input", "submit_btn"],
      "action": { "handler": "auth.login", "method": "POST" }
    },
    "email_input": {
      "type": "Input",
      "props": { "field": "email", "label": "Email", "input_type": "email" }
    },
    "submit_btn": {
      "type": "Button",
      "props": { "label": "Sign in", "button_type": "submit" }
    }
  }
}

All elements — card_main, heading, form_login, email_input, submit_btn — are siblings in the elements map. The tree structure is expressed purely through children ID references.

Variant

Card accepts an optional variant prop controlling chrome and padding.

card_variant"bordered" (default) | "elevated"

ValueClasses appliedPaddingTypical use
"bordered"border border-border bg-card shadow-sm overflow-visiblep-4Dashboard cards in dense layouts
"elevated"bg-card shadow-md overflow-visible (no border)p-8Auth pages, error pages, standalone marketing cards

variant defaults to "bordered" when omitted.

"auth_card": {
  "type": "Card",
  "props": {
    "title": "Sign in",
    "variant": "elevated"
  },
  "children": ["login_form"]
}

Grid

Responsive grid layout for arranging child elements in columns.

PropTypeDescription
columnsnumber | nullNumber of columns (default: 2)
gapgap_size | nullGap between items: "none", "xs", "sm", "md", "lg", "xl"
"stats_grid": {
  "type": "Grid",
  "props": {
    "columns": 3,
    "gap": "md"
  },
  "children": ["revenue_stat", "orders_stat", "users_stat"]
}

Visibility

visible is an element-level field that lives on every JSON-UI element. It is not a GridProps prop. When the visibility condition evaluates false against the spec's data payload, the Grid and all of its children are absent from the rendered DOM — the entire subtree is omitted (no hidden attribute, no empty wrapper).

"staff_chips_row": {
  "type": "Grid",
  "props": { "columns": 1, "gap": "sm" },
  "children": ["staff_chip"],
  "visible": { "path": "/has_staff", "operator": "eq", "value": true }
}

Identical semantics apply to every other v2 component — Card, Form, Button, Badge, and all plugin components. The visibility check runs once per element in the walker before component dispatch (ferro-json-ui/src/render/mod.rs element-level visibility check), so there is no per-component scope shifting and no component-specific visibility behavior.

Tabs

Tabbed content with multiple panels.

PropTypeDescription
default_tabstringValue of the initially active tab
tabsarrayTab definitions

Each object in tabs:

FieldTypeDescription
valuestringTab identifier (matches default_tab)
labelstringTab label text
childrenarray of stringsElement IDs shown when the tab is active
"settings_tabs": {
  "type": "Tabs",
  "props": {
    "default_tab": "general",
    "tabs": [
      { "value": "general", "label": "General", "children": ["general_form"] },
      { "value": "security", "label": "Security", "children": ["security_form"] }
    ]
  }
}

Separator

Visual divider between content sections.

PropTypeDescription
orientationorientation | null"horizontal" (default) or "vertical"
"divider": {
  "type": "Separator",
  "props": {}
}

Dialog overlay with title, body children, footer children, and a trigger button label.

PropTypeDescription
titlestringModal heading
descriptionstring | nullModal description text
trigger_labelstring | nullLabel for the button that opens the modal

Children of the modal body go in the element "children" array. Footer children use a "footer_children" prop listing element IDs.

"delete_modal": {
  "type": "Modal",
  "props": {
    "title": "Delete Item",
    "description": "This action cannot be undone.",
    "trigger_label": "Delete"
  },
  "children": ["confirm_text"],
  "action": { "handler": "items.destroy", "method": "DELETE" }
}

Skeleton

Loading placeholder with configurable dimensions.

PropTypeDescription
widthstring | nullCSS width (e.g., "100%", "200px")
heightstring | nullCSS height (e.g., "40px")
roundedboolean | nullUse rounded corners
"loading_placeholder": {
  "type": "Skeleton",
  "props": {
    "width": "100%",
    "height": "40px",
    "rounded": true
  }
}

Collapsible

An expandable/collapsible section with a trigger label.

PropTypeDescription
triggerstringLabel for the toggle
openboolean | nullInitially open when true
"advanced_section": {
  "type": "Collapsible",
  "props": {
    "trigger": "Advanced Options",
    "open": false
  },
  "children": ["timeout_input", "retry_input"]
}

FormSection

Groups form fields under a section heading with an optional description.

PropTypeDescription
titlestringSection heading
descriptionstring | nullSection description
"billing_section": {
  "type": "FormSection",
  "props": {
    "title": "Billing Information",
    "description": "Used for invoice generation."
  },
  "children": ["address_input", "city_input", "postal_input"]
}

Data Display Components

Text

Renders text content with a semantic HTML element.

PropTypeDescription
contentstringText content
elementtext_element | nullHTML element: "p" (default), "h1", "h2", "h3", "span", "div", "section"
"page_heading": {
  "type": "Text",
  "props": {
    "content": "Welcome to the dashboard",
    "element": "h1"
  }
}

Content can use a $template expression to interpolate data:

"greeting": {
  "type": "Text",
  "props": {
    "content": { "$template": "Welcome, {/user/name}!" },
    "element": "h2"
  }
}

DataTable

Data-bound table with column definitions, row actions, and sorting. Rows are loaded from the spec's data via data_path.

PropTypeDescription
columnsarrayColumn definitions (see below)
data_pathstringJSON Pointer to the row data array (e.g., "/orders")
row_actionsarray | nullActions available per row
empty_messagestring | nullMessage when no data is present
sortableboolean | nullEnable column sorting
sort_columnstring | nullCurrently sorted column key
sort_directionsort_direction | null"asc" or "desc"

Each column object:

FieldTypeDescription
keystringData field key in the row object
labelstringColumn header text
formatcolumn_format | nullDisplay format
"users_table": {
  "type": "DataTable",
  "props": {
    "data_path": "/users",
    "columns": [
      { "key": "name", "label": "Name" },
      { "key": "email", "label": "Email" },
      { "key": "created_at", "label": "Created", "format": "date" }
    ],
    "row_actions": [
      { "handler": "users.edit", "method": "GET" },
      { "handler": "users.destroy", "method": "DELETE", "confirm": { "message": "Delete this user?" } }
    ],
    "empty_message": "No users found.",
    "sortable": true
  }
}

Table

Simple table without a data binding path. Use DataTable for data-bound tables; use Table for static content.

PropTypeDescription
columnsarrayColumn definitions (same structure as DataTable)
rowsarrayStatic row objects (key-value maps)
"static_table": {
  "type": "Table",
  "props": {
    "columns": [
      { "key": "plan", "label": "Plan" },
      { "key": "price", "label": "Price", "format": "currency" }
    ],
    "rows": [
      { "plan": "Starter", "price": "9.00" },
      { "plan": "Pro", "price": "29.00" }
    ]
  }
}

DescriptionList

Key-value pairs displayed as a description list.

PropTypeDescription
itemsarrayDescription items (see below)
columnsnumber | nullNumber of columns for layout

Each item object:

FieldTypeDescription
labelstringItem label
valuestringItem value
formatcolumn_format | nullDisplay format
"user_info": {
  "type": "DescriptionList",
  "props": {
    "columns": 2,
    "items": [
      { "label": "Name", "value": { "$data": "/user/name" } },
      { "label": "Joined", "value": { "$data": "/user/created_at" }, "format": "date" },
      { "label": "Active", "value": { "$data": "/user/active" }, "format": "boolean" }
    ]
  }
}

Dynamic items via data_path

data_path (optional) takes precedence over items when set. It resolves to a JSON array decoded as Vec<DescriptionItem> ({ "label": string, "value": string, "format"?: string }). Falls back to items when the path is missing.

"document_details": {
  "type": "DescriptionList",
  "props": {
    "columns": 2,
    "data_path": "/document/fields"
  }
}

Handler data: { "document": { "fields": [{ "label": "Author", "value": "Alice" }, { "label": "Created", "value": "2026-05-17", "format": "date" }] } }.

Badge

Small label with variant-based styling.

PropTypeDescription
labelstringBadge text
variantbadge_variant | nullVisual style (default: "default")
"status_badge": {
  "type": "Badge",
  "props": {
    "label": "Active",
    "variant": "default"
  }
}

Avatar

User avatar with image, fallback initials, and size.

PropTypeDescription
altstringAlt text (required for accessibility)
srcstring | nullImage URL
fallbackstring | nullFallback initials when no image
sizesize | null"xs", "sm", "default", "lg"
"user_avatar": {
  "type": "Avatar",
  "props": {
    "alt": "Alice Johnson",
    "src": { "$data": "/user/avatar_url" },
    "fallback": "AJ",
    "size": "lg"
  }
}

Progress

Progress bar with a percentage value.

PropTypeDescription
valuenumberPercentage value (0-100)
maxnumber | nullMaximum value
labelstring | nullLabel text above the bar
"upload_progress": {
  "type": "Progress",
  "props": {
    "value": 75,
    "max": 100,
    "label": "Uploading..."
  }
}

Navigation breadcrumb trail.

PropTypeDescription
itemsarrayBreadcrumb items (see below)

Each item object:

FieldTypeDescription
labelstringBreadcrumb text
urlstring | nullLink URL (omit for the current page)
"breadcrumbs": {
  "type": "Breadcrumb",
  "props": {
    "items": [
      { "label": "Home", "url": "/" },
      { "label": "Users", "url": "/users" },
      { "label": "Edit User" }
    ]
  }
}

Pagination

Page navigation for paginated data.

PropTypeDescription
current_pagenumberCurrent page number
per_pagenumberItems per page
totalnumberTotal item count
base_urlstring | nullBase URL for page links
"users_pagination": {
  "type": "Pagination",
  "props": {
    "current_page": { "$data": "/meta/page" },
    "per_page": 25,
    "total": { "$data": "/meta/total" },
    "base_url": "/users"
  }
}

StatCard

Metric card for dashboards. Displays a label and value, with an optional SSE target for live updates.

PropTypeDescription
labelstringMetric label (e.g., "Total Revenue")
valuestringCurrent metric value (e.g., "€12,345")
iconstring | nullIcon name
subtitlestring | nullSecondary text below the value
sse_targetstring | nullSSE event key for live value updates
"revenue_stat": {
  "type": "StatCard",
  "props": {
    "label": "Total Revenue",
    "value": { "$data": "/stats/revenue_formatted" },
    "icon": "currency-euro",
    "subtitle": "This month",
    "sse_target": "revenue_total"
  }
}

When sse_target is set and the server emits a Server-Sent Event with a matching key, the runtime updates the displayed value in place:

event: live-value
data: {"target": "revenue_total", "value": "€13,210"}

Image

Renders an <img> element.

PropTypeDescription
srcstringImage URL
altstringAlt text
widthnumber | nullCSS width in pixels
heightnumber | nullCSS height in pixels
classstring | nullAdditional CSS classes
"hero_image": {
  "type": "Image",
  "props": {
    "src": "/images/hero.jpg",
    "alt": "Dashboard hero",
    "width": 1200,
    "height": 400
  }
}

Dynamic source via data_path

data_path (optional) takes precedence over src when set. It is a JSON Pointer resolved against handler data at render time; the resolved value is used as the <img src> attribute. Falls back to the static src value when the path is missing or resolves to a non-string.

"product_image": {
  "type": "Image",
  "props": {
    "src": "/images/placeholder.jpg",
    "alt": "Product image",
    "data_path": "/product/image_url"
  }
}

Handler data: { "product": { "image_url": "/uploads/product-42.jpg" } } → rendered src is /uploads/product-42.jpg.

CalendarCell

Renders a single day cell in a month grid. Intended for use inside a custom calendar layout; not a standalone page component.

PropTypeDescription
daynumberDay of month (1–31)
is_todayboolean | nullHighlights the cell as today (default: false)
is_current_monthboolean | nullDims the cell when outside the current month (default: false)
event_countnumber | nullEvent indicator dot count (default: 0)
dot_colorsarray | nullPer-event Tailwind color classes (e.g. "bg-blue-500"). When non-empty, colored dots replace plain primary dots.
"day_14": {
  "type": "CalendarCell",
  "props": {
    "day": 14,
    "is_today": true,
    "is_current_month": true,
    "event_count": 3,
    "dot_colors": ["bg-blue-500", "bg-green-500", "bg-red-500"]
  }
}

Form Components

Form

Form container with an action binding. Field components go in the element "children" array.

PropTypeDescription
methodstring | nullHTTP method override ("GET", "POST", "PUT", "PATCH", "DELETE")
max_widthform_max_width | nullMax form width: "sm", "md", "lg", "xl", "full"

The submit action is set on the element's "action" field, not in props.

"create_form": {
  "type": "Form",
  "props": {
    "max_width": "md"
  },
  "children": ["name_input", "email_input", "submit_btn"],
  "action": { "handler": "users.store", "method": "POST" }
}

Input

Text input field with type, label, validation error, and optional data binding.

PropTypeDescription
fieldstringForm field name
labelstringInput label
input_typeinput_type | nullInput type (default: "text")
placeholderstring | nullPlaceholder text
requiredboolean | nullMark as required
disabledboolean | nullDisable the field
errorstring | nullValidation error message
descriptionstring | nullHelp text below the input
default_valuestring | nullPre-filled static value
data_pathstring | nullJSON Pointer for pre-filling from handler data
stepstring | nullHTML step attribute for number inputs (e.g., "0.01")

data_path is a plain string JSON Pointer (not a $data expression). The renderer reads the value from the spec data at that pointer and pre-fills the field.

"email_input": {
  "type": "Input",
  "props": {
    "field": "email",
    "label": "Email Address",
    "input_type": "email",
    "placeholder": "user@example.com",
    "required": true,
    "description": "Your work email",
    "data_path": "/user/email"
  }
}

Select

Dropdown select field with options and optional data binding.

PropTypeDescription
fieldstringForm field name
labelstringSelect label
optionsarrayOption objects: { "value": string, "label": string }
placeholderstring | nullPlaceholder text
requiredboolean | nullMark as required
disabledboolean | nullDisable the field
errorstring | nullValidation error message
descriptionstring | nullHelp text below the select
default_valuestring | nullPre-selected static value
data_pathstring | nullJSON Pointer for pre-selecting from handler data
"role_select": {
  "type": "Select",
  "props": {
    "field": "role",
    "label": "Role",
    "placeholder": "Select a role",
    "required": true,
    "data_path": "/user/role",
    "options": [
      { "value": "admin", "label": "Administrator" },
      { "value": "editor", "label": "Editor" },
      { "value": "viewer", "label": "Viewer" }
    ]
  }
}

Checkbox

Boolean checkbox field.

PropTypeDescription
fieldstringForm field name
labelstringCheckbox label
descriptionstring | nullHelp text below the checkbox
checkedboolean | nullDefault checked state
data_pathstring | nullJSON Pointer for pre-filling from handler data
requiredboolean | nullMark as required
disabledboolean | nullDisable the field
errorstring | nullValidation error message
"terms_checkbox": {
  "type": "Checkbox",
  "props": {
    "field": "terms",
    "label": "Accept Terms of Service",
    "description": "You must accept to continue.",
    "required": true
  }
}

Switch

State-flip toggle. Use Switch when the semantic is "flip this state" (on/off, open/closed, enabled/disabled) — distinct from Checkbox, which expresses a binary choice within a set of options. The renderer emits role="switch" and aria-checked so browsers and assistive technology recognize the toggle affordance.

PropTypeDescription
fieldstringForm field name
labelstringSwitch label
descriptionstring | nullHelp text below the switch
checkedboolean | nullDefault checked state
data_pathstring | nullJSON Pointer for pre-filling from handler data
requiredboolean | nullMark as required
disabledboolean | nullDisable the field
compactboolean | nullScale the toggle down (scale-75) for use in dense grid layouts
errorstring | nullValidation error message
actionAction | nullWhen present, wraps the switch in a <form> and auto-submits on change
"day_open_switch": {
  "type": "Switch",
  "props": {
    "field": "day_1_is_open",
    "label": "Aperto",
    "data_path": "/schedule/day_1_is_open",
    "compact": true,
    "action": { "handler": "schedule.toggle_day", "method": "POST", "url": "/schedule/toggle" }
  }
}

Substitution: Checkbox styled as switch

For consumers who do not need state-flip semantics and prefer to compose from Checkbox primitives, render a Checkbox and apply the Tailwind utility classes that yield a switch appearance (rounded-full track, translated indicator, etc.). Switch remains the recommended path when the semantic is "flip this state" — its dedicated rendering emits the role="switch" ARIA marker and the visual affordance browsers and assistive technology recognize.

There is no variant: "switch" prop on Checkbox today. The substitution is purely visual via custom class hooks, not an API-level feature.

CheckboxList

A group of checkboxes sharing a single form field name. Each checked option submits as field=value. Supports both static option lists and data-driven options resolved from handler data.

PropTypeDescription
fieldstringForm field name; each selected checkbox submits as field=value
optionsarray | nullStatic option list: [{ "value": string, "label": string }]
options_pathstring | nullJSON Pointer to a data array of { "value", "label" } objects (used when options is empty)
selected_pathstring | nullJSON Pointer to a string[] of pre-selected values
labelstring | nullGroup label
descriptionstring | nullHelp text below the group
disabledboolean | nullDisable all checkboxes
errorstring | nullValidation error message

options_path and selected_path are plain JSON Pointer strings, not $data expressions.

"services_list": {
  "type": "CheckboxList",
  "props": {
    "field": "services",
    "label": "Choose Services",
    "options_path": "/available_services",
    "selected_path": "/user/selected_services"
  }
}

CheckboxGroup

An alias for CheckboxList. Accepts identical props and produces identical HTML output — a <fieldset> with one <input type="checkbox"> per option, each with name="field" for form submission. Use whichever name reads more clearly in a given spec; there is no behavioral difference.

"copy_targets": {
  "type": "CheckboxGroup",
  "props": {
    "field": "copy_to",
    "label": "Copia su",
    "options": [
      { "value": "tue", "label": "Martedì" },
      { "value": "wed", "label": "Mercoledì" },
      { "value": "thu", "label": "Giovedì" }
    ]
  }
}

Each checked option submits as copy_to=<value>. When multiple options are checked, the browser sends repeated copy_to parameters (standard HTML multi-value form semantics).

Substitution: composing from Checkbox primitives

As an alternative, the same array-submit semantics can be composed directly from individual Checkbox elements whose field ends in []. The [] suffix causes the browser to collect all checked values under a single array key:

"copy_tue": {
  "type": "Checkbox",
  "props": { "field": "copy_to[]", "label": "Martedì", "value": "tue" }
},
"copy_wed": {
  "type": "Checkbox",
  "props": { "field": "copy_to[]", "label": "Mercoledì", "value": "wed" }
},
"copy_thu": {
  "type": "Checkbox",
  "props": { "field": "copy_to[]", "label": "Giovedì", "value": "thu" }
}

Each checked input submits as copy_to[]=<value>, which most server frameworks decode as an array under the key copy_to.

When to use CheckboxGroup: data-driven multi-select where the option list comes from handler data (options_path) or is defined once statically. Compact and concise.

When to compose from Checkbox: per-option conditional visibility ("visible" rules), per-option custom layout inside a Grid or FormSection, or per-option data_path binding. The explicit form is more verbose but gives full control over each item's placement and visibility.

Button

Interactive button. Attach the click action on the element's "action" field.

PropTypeDescription
labelstringButton label
variantbutton_variant | nullVisual style (default: "default")
sizesize | nullButton size (default: "default")
disabledboolean | nullDisable the button
iconstring | nullIcon name
icon_positionicon_position | null"left" (default) or "right"
button_typestring | nullHTML button type: "button", "submit", "reset"
"save_btn": {
  "type": "Button",
  "props": {
    "label": "Save Changes",
    "variant": "default",
    "size": "default",
    "icon": "save",
    "icon_position": "left"
  },
  "action": { "handler": "profile.update", "method": "PUT" }
}

ButtonGroup

A horizontal group of buttons rendered together.

PropTypeDescription
buttonsarrayButton definitions (same props as Button, plus "action")
"filter_group": {
  "type": "ButtonGroup",
  "props": {
    "buttons": [
      { "label": "All", "variant": "default" },
      { "label": "Active", "variant": "outline" },
      { "label": "Archived", "variant": "outline" }
    ]
  }
}

A button that opens a dropdown with action items. Useful for per-row table actions.

PropTypeDescription
labelstringTrigger button label
actionsarrayAction items (see below)

Each action object:

FieldTypeDescription
labelstringMenu item text
handlerstringRoute handler name
methodstringHTTP method
variantstring | null"destructive" for danger actions
"row_actions": {
  "type": "DropdownMenu",
  "props": {
    "label": "Actions",
    "actions": [
      { "label": "View Details", "handler": "orders.show", "method": "GET" },
      { "label": "Delete", "handler": "orders.destroy", "method": "DELETE", "variant": "destructive" }
    ]
  }
}

Feedback Components

Alert

Alert message with variant-based styling and optional title.

PropTypeDescription
messagestringAlert message content
variantalert_variant | nullVisual style (default: "info")
titlestring | nullAlert title
"trial_warning": {
  "type": "Alert",
  "props": {
    "message": "Your trial expires in 3 days.",
    "variant": "warning",
    "title": "Trial Ending"
  }
}

Toast

Declarative notification rendered as an overlay by the JS runtime. When a Toast element is in the spec, the runtime displays it on page load and dismisses it after the timeout.

PropTypeDescription
messagestringToast message content
varianttoast_variant | nullVisual style (default: "info")
timeoutnumber | nullSeconds before auto-dismiss (default: 5)
dismissibleboolean | nullAllow manual dismiss (default: true)
"save_toast": {
  "type": "Toast",
  "props": {
    "message": "Changes saved successfully.",
    "variant": "success",
    "timeout": 3,
    "dismissible": true
  }
}

EmptyState

Displayed when a list or table has no data. Provides a call-to-action.

PropTypeDescription
titlestringEmpty state heading
descriptionstring | nullSupporting text
action_labelstring | nullCTA button label
iconstring | nullIcon name

Pair with an element "action" for the CTA navigation.

"no_orders": {
  "type": "EmptyState",
  "props": {
    "title": "No orders yet",
    "description": "Create your first order to get started.",
    "action_label": "New Order",
    "icon": "shopping-bag"
  },
  "action": { "handler": "orders.create", "method": "GET" }
}

Sidebar navigation shell with fixed top items, grouped items, and fixed bottom items. Typically used inside the dashboard layout.

PropTypeDescription
fixed_toparray | nullItems pinned at the top (e.g., logo/home)
groupsarray | nullCollapsible navigation groups
fixed_bottomarray | nullItems pinned at the bottom (e.g., settings, logout)

Navigation item object:

FieldTypeDescription
labelstringLink text
hrefstringLink URL
iconstring | nullIcon name
activeboolean | nullMark as current page

Navigation group object:

FieldTypeDescription
labelstringGroup heading
collapsedboolean | nullStart collapsed
itemsarrayNavigation items in this group
"sidebar": {
  "type": "Sidebar",
  "props": {
    "fixed_top": [
      { "label": "Dashboard", "href": "/", "icon": "home", "active": true }
    ],
    "groups": [
      {
        "label": "Management",
        "collapsed": false,
        "items": [
          { "label": "Users", "href": "/users", "icon": "users" },
          { "label": "Orders", "href": "/orders", "icon": "shopping-bag" }
        ]
      }
    ],
    "fixed_bottom": [
      { "label": "Settings", "href": "/settings", "icon": "cog" }
    ]
  }
}

Application header with business name, user info, notification count, and logout link. Typically used inside the dashboard layout.

PropTypeDescription
business_namestringApplication name
notification_countnumber | nullUnread notification count
user_namestring | nullCurrent user's name
user_avatarstring | nullCurrent user's avatar URL
logout_urlstring | nullLogout link URL
"app_header": {
  "type": "Header",
  "props": {
    "business_name": "My App",
    "notification_count": { "$data": "/notifications/unread" },
    "user_name": { "$data": "/auth/user/name" },
    "logout_url": "/logout"
  }
}

Page-level header with a title, optional subtitle, optional breadcrumb, and optional action buttons.

PropTypeDescription
titlestringPage title
breadcrumbarray | nullBreadcrumb items (same shape as Breadcrumb items)
actionsarray | nullElement IDs of action button elements rendered to the right of the title
"page_header": {
  "type": "PageHeader",
  "props": {
    "title": "Orders",
    "breadcrumb": [
      { "label": "Home", "url": "/" },
      { "label": "Orders" }
    ],
    "actions": ["new_order_btn"]
  }
}

actions — lax acceptance

actions accepts any of the following forms, all of which deserialize to an empty or populated list:

Wire valueResult
omittedempty list
nullempty list
"" (empty string)empty list
["btn_id", ...]list of element IDs

Controllers that pass "" or omit the field when there are no actions do not need a special-case branch — all lax forms produce an empty list.

NotificationDropdown

A dropdown list of notification items, typically rendered inside a Header.

PropTypeDescription
notificationsarrayNotification items (see below)
empty_textstring | nullText when list is empty

Each notification object:

FieldTypeDescription
textstringNotification message
iconstring | nullIcon name
timestampstring | nullHuman-readable time string
readboolean | nullWhether the notification has been read
action_urlstring | nullURL to navigate to on click
"notifications": {
  "type": "NotificationDropdown",
  "props": {
    "empty_text": "No new notifications",
    "notifications": [
      {
        "icon": "bell",
        "text": "New order received",
        "timestamp": "5 minutes ago",
        "read": false,
        "action_url": "/orders/123"
      },
      {
        "text": "Payment processed",
        "timestamp": "1 hour ago",
        "read": true
      }
    ]
  }
}

Action Components

ActionCard

A card that acts as a clickable action item.

PropTypeDescription
titlestringCard heading
descriptionstring | nullSupporting text
iconstring | nullIcon name
variantaction_card_variant | nullVisual style: "default", "outline", "ghost"
"create_product": {
  "type": "ActionCard",
  "props": {
    "title": "Add Product",
    "description": "Create a new product listing.",
    "icon": "plus",
    "variant": "outline"
  },
  "action": { "handler": "products.create", "method": "GET" }
}

Onboarding Components

Checklist

Step-by-step onboarding checklist with optional server-side state persistence.

PropTypeDescription
titlestringChecklist heading
itemsarrayChecklist items (see below)
dismissibleboolean | nullAllow dismissal (default: true)
dismiss_labelstring | nullCustom dismiss button label
data_keystring | nullServer-side state persistence key

Each item object:

FieldTypeDescription
labelstringStep description
checkedboolean | nullWhether this step is complete
hrefstring | nullLink to complete the step
"setup_checklist": {
  "type": "Checklist",
  "props": {
    "title": "Get Started",
    "dismissible": true,
    "dismiss_label": "Done",
    "data_key": "onboarding_checklist",
    "items": [
      { "label": "Create your account", "checked": true },
      { "label": "Set up billing", "checked": false, "href": "/billing" },
      { "label": "Invite your team", "checked": false, "href": "/team/invite" }
    ]
  }
}

Commerce Components

ProductTile

Product display card with image, title, price, and optional action.

PropTypeDescription
titlestringProduct name
pricestringFormatted price string (e.g., "€29.00")
descriptionstring | nullProduct description
image_urlstring | nullProduct image URL
badgestring | nullBadge text (e.g., "New", "Sale")
action_labelstring | nullAction button label
"product_tile": {
  "type": "ProductTile",
  "props": {
    "title": { "$data": "/product/name" },
    "price": { "$data": "/product/price_formatted" },
    "description": { "$data": "/product/description" },
    "image_url": { "$data": "/product/image_url" },
    "badge": "New",
    "action_label": "Add to Cart"
  },
  "action": { "handler": "cart.add", "method": "POST" }
}

Kanban Components

KanbanBoard

Kanban board with fixed lanes. On mobile, lanes switch to tabs.

A kanban is fixed lanes plus items sorted into them by a status field. columns is structure (lane id + title) and is always rendered — an empty lane still shows its header and a zero count. Card content is data-bound: items_path resolves a flat array of entity objects, each bucketed into the lane whose id equals the item's group_by value, then rendered as a card via the card_* / row_* bindings. This is the same prescribed-card + field-key convention used by DataTable and MediaCardGrid.

PropTypeDescription
columnsarray | nullLane structure — KanbanColumnProps objects (id + title). Always rendered.
items_pathstring | nullJSON Pointer to a flat array of entity objects to bucket into lanes.
group_bystring | nullField on each item selecting its lane: column.id == item[group_by].
card_title_keystring | nullItem field whose value becomes the card title.
card_description_keystring | nullItem field whose value becomes the card subtitle.
row_actionsarray | nullPer-card dropdown actions. {row_key} / {id} interpolate from the item.
row_keystring | nullItem field used for {row_key} substitution in action URLs (defaults to id).
mobile_default_columnstring | nullLane id selected by default on mobile tab view.
empty_labelstring | nullPlaceholder text shown inside empty lanes.
"order_board": {
  "type": "KanbanBoard",
  "props": {
    "columns": [
      { "id": "pending",    "title": "Pending" },
      { "id": "processing", "title": "Processing" },
      { "id": "done",       "title": "Done" }
    ],
    "items_path": "/data/order",
    "group_by": "status",
    "card_title_key": "name",
    "card_description_key": "total"
  }
}

Handler data — a flat array; the renderer buckets by status, so handlers need no per-lane grouping:

{
  "data": {
    "order": [
      { "id": 1, "name": "#1", "total": "€ 16,00", "status": "pending" },
      { "id": 2, "name": "#2", "total": "€ 40,00", "status": "done" }
    ]
  }
}

For fully-custom card structure (badges, nested elements) rather than the prescribed title/description card, template the cards with the $each directive inside a fixed KanbanColumn instead.

KanbanColumn

A single column in a KanbanBoard.

PropTypeDescription
titlestringColumn heading
data_pathstringJSON Pointer to the card data array
countnumber | nullBadge count shown in the column header
empty_messagestring | nullMessage when the column has no cards
"pending_col": {
  "type": "KanbanColumn",
  "props": {
    "title": "Pending",
    "data_path": "/orders/pending",
    "count": { "$data": "/orders/pending_count" },
    "empty_message": "No pending orders"
  },
  "children": ["pending_card_template"]
}

Extensible Components

RawHtml

Server-injected HTML island for narrow HTML-fragment use cases: status pills, badge decorations, link wrappers, and similar one-off markup that does not warrant a first-class plugin.

PropTypeDescription
htmlstringServer-constructed HTML emitted verbatim into the response
"status_pill": {
  "type": "RawHtml",
  "props": {
    "html": "<span class=\"pill pill-green\">Active</span>"
  }
}

Trust boundary. html is emitted verbatim with no sanitization. The consumer is responsible for ensuring the value is safe before embedding it in the spec. For untrusted input (e.g., user-supplied content), run it through a sanitizer such as ammonia in the handler before assigning it to html. This mirrors the discipline required by RichTextEditor.

For richer widgets that are interactive, need asset injection (CSS/JS bundles), or are reused across multiple pages, use the first-class plugin system instead — see plugins.md.

For plugin components (third-party or custom types not in the built-in catalog), see Plugins.


StreamText

Connects to a server-sent-events endpoint and renders token-by-token output as plain text. Tokens are appended as text nodes — no HTML interpretation.

PropTypeDescription
sse_urlstringURL of the SSE endpoint that streams tokens
placeholderstring?Text shown inside the content area before the first token arrives
loading_textstring?Status indicator shown while the stream is open
"response_area": {
  "type": "StreamText",
  "props": {
    "sse_url": "/ai/generate",
    "placeholder": "Response will appear here…",
    "loading_text": "Generating…"
  }
}

Server contract. The SSE endpoint must emit event: done when the stream is complete:

#![allow(unused)]
fn main() {
tx.send(SseEvent::new().event("done").data("")).await.ok();
}

Without event: done, the browser's EventSource auto-reconnects after the connection closes, causing the component to re-fetch the endpoint in a loop.

Security. Tokens are appended as plain text nodes — innerHTML is never called. Streamed content cannot inject HTML or execute scripts regardless of its content.


Inline view/edit pattern

An inline view/edit page is built from a Form element whose children include both read-only display items and editable inputs, each toggled by a visible condition on a query parameter.

This pattern requires no Rust code to distinguish view and edit modes — the spec handles it entirely through visible rules on query.mode.

{
  "$schema": "ferro-json-ui/v2",
  "title": "Profile",
  "layout": "dashboard",
  "root": "profile_card",
  "elements": {
    "profile_card": {
      "type": "Card",
      "props": { "title": "Profile" },
      "children": ["edit_btn", "profile_form"]
    },
    "edit_btn": {
      "type": "Button",
      "props": { "label": "Edit", "variant": "outline" },
      "action": { "url": "?mode=edit" },
      "visible": { "ne": ["query.mode", "edit"] }
    },
    "profile_form": {
      "type": "Form",
      "props": { "max_width": "md" },
      "children": [
        "name_view", "name_edit",
        "email_view", "email_edit",
        "save_btn"
      ],
      "action": { "handler": "profile.update", "method": "POST" }
    },
    "name_view": {
      "type": "DescriptionList",
      "props": {
        "items": [{ "label": "Name", "value": { "$data": "/user/name" } }]
      },
      "visible": { "ne": ["query.mode", "edit"] }
    },
    "name_edit": {
      "type": "Input",
      "props": {
        "field": "name",
        "label": "Name",
        "data_path": "/user/name"
      },
      "visible": { "eq": ["query.mode", "edit"] }
    },
    "email_view": {
      "type": "DescriptionList",
      "props": {
        "items": [{ "label": "Email", "value": { "$data": "/user/email" } }]
      },
      "visible": { "ne": ["query.mode", "edit"] }
    },
    "email_edit": {
      "type": "Input",
      "props": {
        "field": "email",
        "label": "Email Address",
        "input_type": "email",
        "data_path": "/user/email"
      },
      "visible": { "eq": ["query.mode", "edit"] }
    },
    "save_btn": {
      "type": "Button",
      "props": { "label": "Save", "button_type": "submit" },
      "visible": { "eq": ["query.mode", "edit"] }
    }
  }
}

The visible condition { "eq": ["query.mode", "edit"] } shows the element only when ?mode=edit is present in the URL. The inverse { "ne": ["query.mode", "edit"] } shows the element in all other cases (view mode). See Data Binding & Visibility for the full visible condition reference.

Actions

Actions connect UI elements to Ferro handlers for navigation, form submission, and destructive operations. Actions are declared as an "action" field on any element in the "elements" map.

How Actions Work

Every interactive element can carry an "action" field alongside its "type" and "props". Actions reference handler names (e.g., "users.store") instead of raw URLs. The framework resolves handler names to URLs at render time using the route registry.

  • GET actions render as links (navigation)
  • Non-GET actions (POST, PUT, PATCH, DELETE) render as form submissions
  • Actions can require confirmation before executing
  • "on_success" and "on_error" control what happens after the server responds

Basic Action Example

"delete_btn": {
  "type": "Button",
  "props": {
    "label": "Delete",
    "variant": "destructive"
  },
  "action": {
    "handler": "items.destroy",
    "method": "DELETE"
  }
}

The "action" object lives directly on the element. "handler" is the route name; "method" is the HTTP verb.

HTTP Methods

Available methods: "GET", "POST", "PUT", "PATCH", "DELETE".

"method" defaults to "POST" when omitted.

"save_btn": {
  "type": "Button",
  "props": { "label": "Save" },
  "action": {
    "handler": "items.update",
    "method": "PUT"
  }
}

Confirmation Dialogs

Add a "confirm" object to show a confirmation dialog before the action executes:

"delete_btn": {
  "type": "Button",
  "props": {
    "label": "Delete",
    "variant": "destructive"
  },
  "action": {
    "handler": "items.destroy",
    "method": "DELETE",
    "confirm": {
      "title": "Delete this item?",
      "variant": "danger"
    }
  }
}

The "confirm" object fields:

FieldTypeDescription
"title"stringDialog heading text
"message"string (optional)Additional detail text
"variant"string"default" or "danger"

Standard confirmation (no destructive styling):

"action": {
  "handler": "items.store",
  "method": "POST",
  "confirm": {
    "title": "Save changes?"
  }
}

Action Outcomes

The "on_success" and "on_error" fields control behavior after the server responds. Each is an object with a "type" discriminator.

Redirect

Navigate to a URL after success:

"action": {
  "handler": "items.store",
  "method": "POST",
  "on_success": {
    "type": "redirect",
    "url": "/items"
  }
}

Reload

Reload the current page:

"action": {
  "handler": "settings.update",
  "method": "PUT",
  "on_success": {
    "type": "reload"
  }
}

Notify

Show a notification toast:

"action": {
  "handler": "items.store",
  "method": "POST",
  "on_success": {
    "type": "notify",
    "message": "Item created",
    "variant": "success"
  }
}

Notification variants: "success", "info", "warning", "error".

Show errors

Display validation errors returned from the handler on corresponding form fields:

"action": {
  "handler": "items.store",
  "method": "POST",
  "on_error": {
    "type": "show_errors"
  }
}

Combined example

"action": {
  "handler": "items.store",
  "method": "POST",
  "on_success": {
    "type": "redirect",
    "url": "/items"
  },
  "on_error": {
    "type": "show_errors"
  }
}

Form Actions

The Form element uses "action" as its submit action. The entire form submits to the handler when the user clicks the submit button.

Complete form element example:

"create_form": {
  "type": "Form",
  "props": {},
  "action": {
    "handler": "items.store",
    "method": "POST",
    "on_success": {
      "type": "redirect",
      "url": "/items"
    },
    "on_error": {
      "type": "show_errors"
    }
  },
  "children": ["name_input", "description_input", "submit_btn"]
}

With form fields as sibling elements:

"elements": {
  "create_form": {
    "type": "Form",
    "props": {},
    "action": {
      "handler": "items.store",
      "method": "POST",
      "on_success": { "type": "redirect", "url": "/items" },
      "on_error": { "type": "show_errors" }
    },
    "children": ["name_input", "submit_btn"]
  },
  "name_input": {
    "type": "Input",
    "props": {
      "field": "name",
      "label": "Name",
      "input_type": "text",
      "required": true
    }
  },
  "submit_btn": {
    "type": "Button",
    "props": { "label": "Create" }
  }
}

GET actions render as links. Use "method": "GET" on any element to make it a navigation link:

"view_btn": {
  "type": "Button",
  "props": { "label": "View Details" },
  "action": {
    "handler": "items.show",
    "method": "GET"
  }
}

Data Binding

In JSON-UI, data flows from the handler into spec elements through two expression shapes placed as prop values. The handler provides a plain JSON object; expressions read from it at render time.

Handler Data Shape

The handler assembles data and passes it as the second argument to JsonUi::render_file:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request) -> Response {
    let orders = Order::find_all(&req.db()).await?;
    let stats = Stats::load(&req.db()).await?;

    JsonUi::render_file(
        "src/views/orders.json",
        serde_json::json!({
            "orders": orders,
            "stats": {
                "total": stats.total,
                "revenue": stats.revenue_formatted
            },
            "user": {
                "name": req.auth_user().name,
                "role": req.auth_user().role
            }
        }),
    )
}
}

render_file loads the spec file (with caching), merges the handler data into spec.data, resolves all expressions, and returns the rendered HTML response. The handler contains no component-building code — only data assembly.

Expressions

Expressions are JSON objects with a single recognized key. They appear as prop values inside elements. There are two expression types: $data and $template.

$data — type-preserving extraction

Format:

{ "$data": "/json/pointer/path" }

$data extracts the value at the given JSON Pointer path from the spec's data context. The resolved value preserves its original type — a string stays a string, a number stays a number, a boolean stays a boolean, an array stays an array.

Missing paths resolve to null.

Example — extract a number:

"revenue_stat": {
  "type": "StatCard",
  "props": {
    "label": "Total Revenue",
    "value": { "$data": "/stats/revenue" }
  }
}

If the data is { "stats": { "revenue": 12345 } }, the value prop resolves to 12345 (number).

Example — extract a string:

"user_badge": {
  "type": "Badge",
  "props": {
    "label": { "$data": "/user/role" }
  }
}

With data { "user": { "role": "admin" } }, label resolves to "admin" (string).

Example — extract an array:

"product_list": {
  "type": "DataTable",
  "props": {
    "data_path": "/products"
  }
}

Note: data paths in DataTable (data_path), KanbanBoard (items_path), Input, Select, Checkbox, and Switch (data_path) are plain string JSON Pointers — not $data expressions. The component reads rows, items, or pre-fills values from that path at render time, but the path itself is literal.

$template — string interpolation

Format:

{ "$template": "literal text {/path} more text" }

$template produces a string by substituting {/path} placeholders with values from the data context. Each placeholder uses JSON Pointer syntax. The result is always a string regardless of what the placeholders resolve to.

Missing placeholders substitute as "" (empty string). To emit a literal { or } character, escape it with a backslash: \{ and \}.

Example — greeting message:

"welcome_text": {
  "type": "Text",
  "props": {
    "content": { "$template": "Welcome, {/user/name}!" },
    "element": "h2"
  }
}

With data { "user": { "name": "Alice" } }, content resolves to "Welcome, Alice!".

Example — formatted label:

"order_heading": {
  "type": "Text",
  "props": {
    "content": { "$template": "Order #{/order/id} — {/order/status}" },
    "element": "h3"
  }
}

With data { "order": { "id": 1042, "status": "pending" } }, content resolves to "Order #1042 — pending".

Where Expressions Apply

Expressions are resolved in element.props values only. They are not resolved in:

  • spec.title — literal string
  • spec.layout — literal string
  • spec.data — the data source itself
  • element.children — always a list of element ID strings
  • element.action — handler name and method are literal
  • element.visible — visibility condition fields are literal

Expressions can appear at any depth inside a props object or array, but not as keys — only as values.

Complete Example

A handler + spec showing $data, $template, and a plain data_path:

Handler (src/controllers/payments.rs):

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request) -> Response {
    let payments = Payment::find_all(&req.db()).await?;
    let total = payments.iter().map(|p| p.amount).sum::<f64>();

    JsonUi::render_file(
        "src/views/payments.json",
        serde_json::json!({
            "payments": payments,
            "stats": {
                "total": format!("€{:.2}", total),
                "count": payments.len()
            },
            "user": {
                "name": req.auth_user().name
            }
        }),
    )
}
}

Spec (src/views/payments.json):

{
  "$schema": "ferro-json-ui/v2",
  "title": "Payments",
  "layout": "dashboard",
  "root": "page_header",
  "elements": {
    "page_header": {
      "type": "PageHeader",
      "props": {
        "title": "Payments",
        "description": { "$template": "Welcome, {/user/name}" }
      },
      "children": ["stats_grid"]
    },
    "stats_grid": {
      "type": "Grid",
      "props": { "columns": 2, "gap": "md" },
      "children": ["total_stat", "count_stat"]
    },
    "total_stat": {
      "type": "StatCard",
      "props": {
        "label": "Total",
        "value": { "$data": "/stats/total" }
      }
    },
    "count_stat": {
      "type": "StatCard",
      "props": {
        "label": "Payments",
        "value": { "$data": "/stats/count" }
      }
    },
    "payments_table": {
      "type": "DataTable",
      "props": {
        "data_path": "/payments",
        "columns": [
          { "key": "date", "label": "Date", "format": "date" },
          { "key": "description", "label": "Description" },
          { "key": "amount", "label": "Amount", "format": "currency" },
          { "key": "status", "label": "Status" }
        ],
        "empty_message": "No payments recorded."
      }
    }
  }
}

$data and $template resolve at render time from the handler data. data_path in DataTable is a plain string pointer the component uses to read rows from the same data.

Single-Pass Guarantee

If a $data expression resolves to a string value that looks like {"$data": "/another/path"}, the inner expression is not re-resolved. This is intentional — it prevents injection. Expressions are evaluated in a single pass.

Hard Cap — What Does Not Exist

The expression language is intentionally minimal. The following do not exist and will not be added:

  • $if — no conditional rendering in expressions
  • $for — no loops in expressions
  • $state — no client-side state
  • $bind — no two-way binding
  • $map — no array transformation in expressions

Conditional logic belongs in the Rust handler. Use the visible condition on elements for simple show/hide logic based on data values (see Visibility). Complex branching is handled server-side before render_file is called — shape the data differently, or call render_file with a different spec path.

Visibility Conditions

Elements can be conditionally shown or hidden with the "visible" field. Visibility is not an expression — it is a separate condition checked against data paths during render.

"admin_panel": {
  "type": "Card",
  "props": { "title": "Admin Tools" },
  "visible": { "field": "/user/role", "op": "eq", "value": "admin" }
}

For the full visibility operator reference, see Visibility.

data_path Reference

data_path is a plain JSON-pointer string (not a $data expression). It is supported by three component families and follows a consistent resolution convention: the path is walked against the full handler data object, and the component reads whatever value resides there.

DataTable — row array

data_path: "/data/{service.name}"

Resolves to an array of row objects. Each row is one <tr>; column key fields project as cell text.

"staff_table": {
  "type": "DataTable",
  "props": {
    "data_path": "/data/staff",
    "columns": [
      { "key": "name",   "label": "Name" },
      { "key": "active", "label": "Active", "format": "boolean" }
    ]
  }
}

Handler provides { "data": { "staff": [ ... ] } }.

KanbanBoard — fixed lanes, flat item array

items_path: "/data/{service.name}"
group_by:   "<status field>"

A kanban is fixed lanes plus items sorted into them by a status field. The spec's columns array (lane id + title, derived from the service's state machine under the projection renderer) is structure and is always rendered. items_path resolves the same flat entity array DataTable reads, and the renderer buckets each item into the lane whose id equals the item's group_by value — so handlers stay flat and need no per-lane grouping.

"order_kanban": {
  "type": "KanbanBoard",
  "props": {
    "columns": [
      { "id": "draft",     "title": "Draft" },
      { "id": "submitted", "title": "Submitted" }
    ],
    "items_path": "/data/order",
    "group_by": "status",
    "card_title_key": "name"
  }
}

Handler provides the flat array (same shape as the DataTable data path):

{
  "data": {
    "order": [
      { "id": 1, "name": "#1", "status": "draft" },
      { "id": 2, "name": "#2", "status": "submitted" }
    ]
  }
}

StatCard — scalar value

StatCardProps.value_path resolves to a single scalar (string or number):

value_path: "/data/{service.name}/{field.name}"
"revenue_stat": {
  "type": "StatCard",
  "props": {
    "label": "Total Revenue",
    "value": "",
    "value_path": "/data/statistics/total_revenue"
  }
}

Handler provides { "data": { "statistics": { "total_revenue": "€12,450" } } }. The static value string is the fallback when value_path is absent or fails to resolve.

Form Validation

Server-rendered form validation in ferro pairs three primitives: a ValidationError value built during request handling, the _flash.old._validation_errors session key, and the JSON-UI form-control prop error. This page covers the four authoring patterns: the blessed JsonUi::render_validation_error path, the manual $data binding escape hatch, the flash round-trip on POST→GET, and the cross-field validation summary.

All form-control components (Input, Select, Input { input_type: "textarea" }, Checkbox, CheckboxList, Switch) accept an error prop of type Option<String>. When set, the renderer emits a destructive-tone class chain on the control and an inline error paragraph below the field:

<p id="err-{field}" class="text-sm text-destructive">{error}</p>

The paragraph carries id="err-{field}" so the control's aria-describedby="err-{field}" pairing announces the error to assistive technology.

Blessed Path: JsonUi::render_validation_error

Most handlers hand the framework a ValidationError value and let it plumb messages onto matching fields automatically. The framework matches by the field prop on each form-control element.

GET handler — re-render after a validation failure:

#![allow(unused)]
fn main() {
use ferro::{JsonUi, session};
use ferro_json_ui::{Element, Spec};
use std::collections::HashMap;

#[handler]
pub async fn show(req: Request) -> Response {
    let spec = build_spec(&req);
    let data = serde_json::json!({});

    let errors: HashMap<String, Vec<String>> = session()
        .and_then(|s| s.get("_flash.old._validation_errors"))
        .unwrap_or_default();
    JsonUi::render_with_errors(&spec, &data, &errors)
}
}

The handler reads the validation errors map from the session flash (written by the POST handler — see Flash Round-Trip below) and passes it explicitly to render_with_errors. The framework then matches field names against the spec's form-control elements and populates each matching error prop. When no errors were flashed, unwrap_or_default() produces an empty map and the form renders without error state.

POST handler — detect and redirect on failure:

#![allow(unused)]
fn main() {
#[handler]
pub async fn update(req: Request) -> Response {
    let data = req.input().await?;

    let mut validator = Validator::new(&data);
    validator.rules("email", rules![required(), email()]);

    if let Err(e) = validator.validate() {
        return e
            .with_old_input(&data)
            .redirect_back(req.header("referer"));
    }

    // persist and redirect to success path
}
}

redirect_back persists the ValidationError into _flash.new._validation_errors. The session middleware ages the flash on the next request, making it readable as _flash.old._validation_errors in the GET handler.

Escape Hatch: Manual $data Binding

When the blessed path's field-keyed match does not fit — for example, a cross-field validation key, a composite key, or an error displayed adjacent to a field with a different name — pass the error string through handler data and reference it from the spec via a $data expression.

Handler:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show(req: Request) -> Response {
    let mut data = serde_json::Map::new();
    data.insert(
        "overage_threshold_error".to_string(),
        serde_json::json!(req.validation_error("overage_threshold")),
    );
    let data = serde_json::Value::Object(data);

    let spec = Spec::builder()
        .element(
            "field_overage_threshold",
            Element::new("Input")
                .prop("field", "overage_threshold")
                .prop("label", "Overage threshold")
                .prop(
                    "error",
                    serde_json::json!({"$data": "/overage_threshold_error"}),
                ),
        )
        .build()?;

    JsonUi::render(&spec, &data)
}
}

JsonUi::render merges the runtime data argument into spec.data before resolving $data expressions, so the binding resolves whether the value originates from the spec's embedded data or from the handler-supplied data object.

The $data path must resolve to a string or null. When it resolves to null (no error), the renderer omits the error paragraph.

Use this path when:

  • The validation key differs from the form-control field prop.
  • The same error message should appear next to a different field than the one that produced it.
  • You need to construct the error key dynamically (e.g., {form_id}_{field}_error).

Flash Round-Trip on POST → GET

ValidationError::with_old_input(&data).redirect_back(req.header("referer")) stores both the error map and the submitted form values in the session flash. On the following request, the session middleware promotes _flash.new to _flash.old, making the values readable as req.old("field") and req.validation_error("field").

Restore submitted form values on GET re-render using default_value:

#![allow(unused)]
fn main() {
Element::new("Input")
    .prop("field", "email")
    .prop("label", "Email")
    .prop(
        "default_value",
        serde_json::json!(
            req.old("email").or_else(|| Some(record.email.clone()))
        ),
    )
}

req.old("field") takes precedence — it restores what the user typed on a failed submission. The database value is the fallback for the first GET (before any submission).

req.old returns Option<String>. req.validation_error returns Option<String> containing the first error message for the field. Both read from _flash.old without clearing the key, so multiple calls in the same handler are safe.

When using render_validation_error, the framework plumbs the full error map automatically. When using the manual $data escape hatch, call req.validation_error("field") explicitly for each field.

Cross-Field Validation Summary

A top-of-page summary banner is useful when the form has many fields and individual error paragraphs may not be immediately visible after scrolling. Render a summary element conditionally before building the spec:

#![allow(unused)]
fn main() {
let mut builder = Spec::builder();
let mut root_children: Vec<String> = Vec::new();

if req.has_validation_errors() {
    builder = builder.element(
        "validation_summary",
        Element::new("Alert")
            .prop("variant", "error")
            .prop("message", "Some fields need attention."),
    );
    root_children.push("validation_summary".to_string());
}

// ... add form fields to builder and root_children ...
}

req.has_validation_errors() and req.validation_error("field") both read from _flash.old._validation_errors. They cannot disagree within a single handler invocation. The session middleware advances _flash.new to _flash.old once at request boundaries — not during the handler — so the key remains stable across all reads.

If you observe has_validation_errors() returning false while validation_error("field") returns Some, audit the handler for a helper that calls a session method consuming the flash key between the two reads.

Layouts

Layouts wrap JSON-UI pages with consistent navigation, headers, and page structure.

How Layouts Work

The "layout" field in a spec file selects the HTML shell used to wrap the rendered elements. At render time the framework looks up the layout by name and wraps the component output in a full HTML page — nav chrome, sidebars, header, or a bare container, depending on the layout chosen.

Omitting the field (or leaving it empty) uses the minimal default shell with no navigation.

Selecting a Layout in a Spec File

Set "layout" at the top level of the spec:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Dashboard",
  "layout": "dashboard",
  "root": "main_card",
  "elements": {
    "main_card": {
      "type": "Card",
      "props": { "title": "Welcome" }
    }
  }
}

Built-in Layouts

Layout nameDescription
"dashboard"Sidebar navigation, sticky header, main content area. For admin panels.
"app"Top navigation bar, full-width main area. For app pages.
"auth"Centered card, no navigation chrome. For login and register forms.
(omit)Minimal default shell. No navigation chrome.

"dashboard" layout

{
  "$schema": "ferro-json-ui/v2",
  "title": "Orders",
  "layout": "dashboard",
  "root": "orders_card",
  "elements": {
    "orders_card": {
      "type": "Card",
      "props": { "title": "Orders" },
      "children": ["orders_table"]
    },
    "orders_table": {
      "type": "DataTable",
      "props": {
        "columns": [
          { "key": "id", "label": "#" },
          { "key": "customer", "label": "Customer" },
          { "key": "total", "label": "Total" }
        ],
        "data_path": "/orders"
      }
    }
  }
}

"app" layout

{
  "$schema": "ferro-json-ui/v2",
  "title": "Profile",
  "layout": "app",
  "root": "profile_card",
  "elements": {
    "profile_card": {
      "type": "Card",
      "props": { "title": "Your Profile" }
    }
  }
}

"auth" layout

{
  "$schema": "ferro-json-ui/v2",
  "title": "Sign In",
  "layout": "auth",
  "root": "login_form",
  "elements": {
    "login_form": {
      "type": "Form",
      "props": {
        "action": "/login",
        "method": "POST",
        "fields": [
          { "name": "email", "type": "email", "label": "Email" },
          { "name": "password", "type": "password", "label": "Password" }
        ],
        "submit_label": "Sign In"
      }
    }
  }
}

Default (no layout field)

{
  "$schema": "ferro-json-ui/v2",
  "title": "Report",
  "root": "report_card",
  "elements": {
    "report_card": {
      "type": "Card",
      "props": { "title": "Monthly Report" }
    }
  }
}

Custom Layouts

Implement the Layout trait and register the layout at application startup. After registration, the layout name is available in any spec file.

Implementing the trait

#![allow(unused)]
fn main() {
use ferro_json_ui::{Layout, LayoutContext};

pub struct MyLayout;

impl Layout for MyLayout {
    fn render(&self, ctx: &LayoutContext) -> String {
        format!(
            r#"<!DOCTYPE html>
<html>
<head>
    <title>{title}</title>
    {head}
</head>
<body class="{body_class}">
    <header>My App</header>
    <main>{content}</main>
    {scripts}
</body>
</html>"#,
            title = ctx.title,
            head = ctx.head,
            body_class = ctx.body_class,
            content = ctx.content,
            scripts = ctx.scripts,
        )
    }
}
}

Registering in app bootstrap

#![allow(unused)]
fn main() {
use ferro_json_ui::register_layout;

// In src/bootstrap.rs or main.rs, before the server starts:
register_layout("my-layout", MyLayout);
}

After registration, use the name in any spec file:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Custom Page",
  "layout": "my-layout",
  "root": "root_element",
  "elements": {
    "root_element": {
      "type": "Card",
      "props": { "title": "Custom layout example" }
    }
  }
}

Registering a name that already exists replaces the previous layout. Registration order does not matter as long as registration completes before the first request is served.

LayoutContext Fields

Custom layout implementations receive a LayoutContext with all data needed to produce a complete HTML page:

FieldTypeDescription
title&strPage title from the spec "title" field
content&strRendered element HTML fragment
head&strAdditional <head> content (CSS links, meta tags)
body_class&strCSS classes for the <body> element
scripts&strJS assets and init scripts for plugins, placed before </body>

Always include ctx.scripts in custom layouts — it carries plugin JS assets injected automatically by the render pipeline.

Plugins

Plugins extend the JSON-UI component catalog with custom or third-party components that ship their own JavaScript and CSS assets.

When to use RawHtml instead

For a one-off HTML fragment (status pill, badge, link decoration), the RawHtml component is the lowest-friction option — see components.md. RawHtml is a single-field primitive (html: String) emitted verbatim into the response.

Choose a first-class JsonUiPlugin (the rest of this guide) when:

  • The widget is interactive (forms, OAuth flows, dynamic state)
  • The widget needs asset injection (CSS/JS bundles)
  • The widget is reused across multiple pages and benefits from explicit registration with a type name

Every plugin has its own type name (e.g. "StripeConnectStatus", "Map") that the spec references directly under "type". There is no generic plugin-dispatch indirection — register the plugin with its name, then specs reference that name; see the type-name registration section below.

What Plugins Are

The 41 built-in components cover most server-driven UI patterns. Plugins fill the gap for components that require rich client-side behavior: interactive maps, chart libraries, rich text editors, video players, calendar widgets, and similar.

A plugin is a Rust struct implementing the JsonUiPlugin trait. It declares:

  • A unique component type name (e.g., "Map")
  • A JSON Schema for its props (used by MCP and agents for discovery)
  • A render function that produces an HTML string from props
  • CSS and JS asset declarations collected once per page and deduplicated

Using a Built-in Plugin in a Spec File

Plugin components appear in a spec file exactly like any other element — just set "type" to the plugin's registered name:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Locations",
  "layout": "dashboard",
  "root": "map_view",
  "elements": {
    "map_view": {
      "type": "Map",
      "props": {
        "center": [51.505, -0.09],
        "zoom": 13,
        "height": "400px",
        "markers": [
          { "lat": 51.5, "lng": -0.09, "popup": "London" }
        ]
      }
    }
  }
}

No Rust code is needed to use a registered plugin — the type name in the spec is sufficient.

How Assets Are Injected

When rendering a spec that contains plugin elements, the framework:

  1. Renders all elements in the spec
  2. Collects the plugin type names encountered
  3. Calls each plugin's css_assets() and js_assets() methods
  4. Deduplicates assets by URL (two Map elements on the same page load Leaflet once)
  5. Injects CSS <link> tags into <head> automatically
  6. Injects JS <script> tags before </body> automatically

No manual <link> or <script> tags are needed. Asset injection is automatic.

Writing a Custom Plugin

Implement JsonUiPlugin and register the plugin at application startup.

Trait implementation

#![allow(unused)]
fn main() {
use ferro_json_ui::{JsonUiPlugin, Asset};

pub struct ChartPlugin;

impl JsonUiPlugin for ChartPlugin {
    fn component_type(&self) -> &str {
        "Chart"
    }

    fn props_schema(&self) -> serde_json::Value {
        serde_json::json!({
            "type": "object",
            "required": ["data_path"],
            "properties": {
                "data_path": { "type": "string" },
                "type": { "type": "string", "enum": ["bar", "line", "pie"], "default": "bar" },
                "height": { "type": "string", "default": "300px" }
            }
        })
    }

    fn render(&self, props: &serde_json::Value, _data: &serde_json::Value) -> String {
        // `props` — the element's props object from the spec (already expression-resolved).
        // `data`  — the full spec data payload from the handler (`spec.data`).
        //           Use this to read per-request values not passed explicitly in props.
        let config = serde_json::to_string(props).unwrap_or_default();
        format!(r#"<canvas data-ferro-chart='{}'></canvas>"#, config)
    }

    fn css_assets(&self) -> Vec<Asset> {
        vec![]
    }

    fn js_assets(&self) -> Vec<Asset> {
        vec![
            Asset::new("https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js")
        ]
    }

    fn init_script(&self) -> Option<String> {
        Some(r#"
document.querySelectorAll('[data-ferro-chart]').forEach(function(canvas) {
    var cfg = JSON.parse(canvas.getAttribute('data-ferro-chart'));
    // initialize Chart.js with cfg
});
"#.to_string())
    }
}

// `init_script()` is emitted once per page regardless of how many instances of the
// plugin appear in the spec. Use a `querySelectorAll` loop (as above) so the script
// initializes every instance. The script is injected inline before `</body>`,
// after all `js_assets()` `<script>` tags have been emitted.
}

Registering in app bootstrap

#![allow(unused)]
fn main() {
use ferro_json_ui::register_plugin;

// In src/bootstrap.rs or main.rs, before the server starts:
register_plugin(ChartPlugin);
}

After registration, use the plugin in a spec file by setting "type" to the registered name:

"revenue_chart": {
  "type": "Chart",
  "props": {
    "data_path": "/revenue_by_month",
    "type": "bar",
    "height": "300px"
  }
}

A complete spec using the custom plugin:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Revenue",
  "layout": "dashboard",
  "root": "revenue_chart",
  "elements": {
    "revenue_chart": {
      "type": "Chart",
      "props": {
        "data_path": "/revenue_by_month",
        "type": "bar",
        "height": "300px"
      }
    }
  }
}

Built-in Plugins

Map (Leaflet-based)

Component type: "Map"

Renders an interactive map using Leaflet 1.9.4. Requires internet access for the OpenStreetMap tile CDN.

Props:

PropTypeRequiredDefaultDescription
center[lat, lng]NoMap center coordinates. Optional when fit_bounds is true
zoomnumberNo13Initial zoom level (0–18)
heightstringNo"400px"CSS height of the map container
fit_boundsbooleanNofalseAuto-zoom to fit all markers; overrides center/zoom
markersarrayNo[]Markers to place on the map
tile_urlstringNoOpenStreetMapCustom tile layer URL template
attributionstringNoOSM creditTile layer attribution string
max_zoomnumberNo19Maximum zoom level

Marker object fields:

FieldTypeRequiredDescription
latnumberYesLatitude
lngnumberYesLongitude
popupstringNoPlain text popup on click
popup_htmlstringNoHTML popup content (takes priority over popup)
colorstringNoHex color for the marker pin (e.g., "#3B82F6")
hrefstringNoURL to navigate to on marker click

Complete example with multiple markers:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Offices",
  "layout": "dashboard",
  "root": "office_map",
  "elements": {
    "office_map": {
      "type": "Map",
      "props": {
        "fit_bounds": true,
        "height": "500px",
        "markers": [
          {
            "lat": 51.505,
            "lng": -0.09,
            "popup": "London HQ",
            "color": "#3B82F6"
          },
          {
            "lat": 48.8566,
            "lng": 2.3522,
            "popup": "Paris Office",
            "href": "/offices/paris"
          }
        ]
      }
    }
  }
}

Assets loaded automatically: Leaflet CSS (<head>) and Leaflet JS (</body>), both from unpkg CDN with SRI hashes.

RichTextEditor (Quill-based)

Component type: "RichTextEditor"

Renders an interactive rich text editor backed by Quill 2.0.3. The editor container stores its content in a companion hidden input field; the hidden input is submitted with the form on POST.

Props:

PropTypeRequiredDefaultDescription
fieldstringYesForm field name for the hidden input
labelstringYesLabel text above the editor
default_valuestring | nullNo""Initial HTML content (static)
data_pathstring | nullNoJSON Pointer to pre-fill the editor from handler data
errorstring | nullNoValidation error message displayed below the editor

data_path takes precedence over default_value when both are set.

Security: The editor produces user-controlled HTML. Content sanitization on form submit is the application's responsibility; the framework does not sanitize submitted values.

Spec example:

"description_editor": {
  "type": "RichTextEditor",
  "props": {
    "field": "description",
    "label": "Description",
    "data_path": "/document/description"
  }
}

In a form:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Edit Document",
  "layout": "dashboard",
  "root": "edit_card",
  "elements": {
    "edit_card": {
      "type": "Card",
      "props": { "title": "Edit Document" },
      "children": ["doc_form"]
    },
    "doc_form": {
      "type": "Form",
      "props": { "max_width": "lg" },
      "children": ["title_input", "description_editor", "submit_btn"],
      "action": { "handler": "documents.update", "method": "POST" }
    },
    "title_input": {
      "type": "Input",
      "props": { "field": "title", "label": "Title", "data_path": "/document/title" }
    },
    "description_editor": {
      "type": "RichTextEditor",
      "props": {
        "field": "description",
        "label": "Description",
        "data_path": "/document/description"
      }
    },
    "submit_btn": {
      "type": "Button",
      "props": { "label": "Save", "button_type": "submit" }
    }
  }
}

Assets loaded automatically: Quill Snow CSS (<head>) and Quill JS (</body>), both from jsDelivr CDN. SRI hashes are pending verification before production use — see ferro-json-ui/src/plugins/rich_text_editor.rs for the TODO marker.


Catalog Discoverability

Plugin components registered in the global registry are automatically surfaced by the json_ui_catalog MCP tool, which agents use to discover available components:

mcp__ferro__json_ui_catalog({})

The response includes a plugin_components section listing each registered plugin, its props schema, and a usage example. This means agents authoring specs do not need to read plugin source code — the catalog provides the same discovery surface as built-in components.

The built-in plugins (Map, RichTextEditor) are pre-registered by ferro_json_ui::global_plugin_registry() at framework startup. Custom plugins registered via register_plugin(MyPlugin) at application startup are included in the same catalog response.

Runtime Primitives

ferro-json-ui ships a small JavaScript runtime that is inlined into every page rendered by DefaultLayout and DashboardLayout. Most of the runtime is internal: it powers behaviors emitted by components (popover menus, tabs, dismissible toasts, sidebar, modals) and the attributes those components use are implementation details.

A small subset of the runtime is a public contract: DOM attributes the runtime recognizes on hand-authored or component-output HTML. This page documents that subset.

data-lazy-hero

Opts a <video> element into intersection-driven preload promotion. When the element approaches the viewport, the runtime sets preload="auto" and calls <video>.load() so the first frame is ready by the time the user reaches the element.

Contract

AttributeRequiredDefaultDescription
data-lazy-heroyesOpt-in marker. The element must also have preload="none".
data-lazy-hero-marginno200px 0pxPer-element rootMargin for the IntersectionObserver. Any CSS-margin shorthand the IntersectionObserver constructor accepts.
data-lazy-hero-promotedno (runtime sets it)absentIdempotency marker. The runtime sets this to "1" after promotion; re-running the primitive on the same element is a no-op.

Selector

The runtime matches video[preload="none"][data-lazy-hero]:not([data-lazy-hero-promoted]). Three consequences:

  • <video> without preload="none" is ignored. The runtime does not override an author-tuned preload value.
  • Non-<video> elements with data-lazy-hero are ignored. The promote action (flip preload, call .load()) is video-specific.
  • Already-promoted elements are excluded by the :not(...) clause.

Usage

<!-- Above-the-fold: load eagerly -->
<video preload="auto" poster="/posters/hero-0.jpg" muted playsinline>
  <source src="/assets/hero-0.mp4" type="video/mp4">
</video>

<!-- Below-the-fold: lazy-promote on viewport approach (default 200px lead) -->
<video preload="none" data-lazy-hero poster="/posters/hero-1.jpg" muted playsinline>
  <source src="/assets/hero-1.mp4" type="video/mp4">
</video>

<!-- Below-the-fold with a larger lead time (slower-loading hero) -->
<video preload="none" data-lazy-hero data-lazy-hero-margin="400px 0px"
       poster="/posters/hero-2.jpg" muted playsinline>
  <source src="/assets/hero-2.mp4" type="video/mp4">
</video>

Observer cardinality

Elements are grouped by their resolved data-lazy-hero-margin value. The runtime constructs one IntersectionObserver per distinct margin value, with all elements sharing that value fanned out to it. A page where every hero uses the default has exactly one observer; a page mixing defaults with one override has two observers.

Browser support

The primitive depends on the IntersectionObserver API. On environments without it (rare; some legacy embedded WebViews, certain test harnesses with minimal DOM polyfills), the runtime silently no-ops and the videos behave exactly as authored.

Lifecycle

The runtime scans the DOM once, when DOMContentLoaded fires. Elements inserted into the DOM after that point are not observed. Pages that render heroes server-side as part of the initial HTML — the intended use case — are unaffected.

Performance, not access control

data-lazy-hero defers a fetch the browser would otherwise issue at page load. It does not prevent a fetch. Once the element approaches the viewport, the runtime initiates the fetch. Do not use this attribute to gate paid content or otherwise restrict resource access — that is an access-control concern, not a performance concern.

Spec Construction

JSON-UI specs are flat element maps. There are four ways to construct one. Pick by the shape of the data, not by precedent.

The four strategies are not interchangeable. Each one matches a specific data shape, and choosing the wrong one tends to produce code that is either more verbose than necessary or that cannot express the case at hand. The decision rubric below maps data shape to strategy so the choice can be made by inspection.

Decision rubric

Your data shapeUseWhere it lives
Static — the page does not depend on per-request dataJSON file + JsonUi::render_filesrc/views/{module}/{view}.json
Homogeneous iteration — N elements with uniform shape, data-driven countJSON file + $each directivesrc/views/{module}/{view}.json
Conditional emission — single template, branches on a runtime flagJSON file + $if directivesrc/views/{module}/{view}.json
Heterogeneous runtime construction — element graph cannot be expressed declarativelyRust + SpecBuilder::element_nestedcontroller handler

Read the rubric top-down. The first row whose "data shape" description matches is the one to use. The four sections below show one worked example per quadrant.

Static spec

The page structure does not depend on per-request data. Props may still bind to data via $data, but the set of elements and the parent/child graph is fixed.

JSON file (src/views/dashboard/payments.json):

{
  "$schema": "ferro-json-ui/v2",
  "title": "Payments",
  "layout": "dashboard",
  "root": "page_header",
  "elements": {
    "page_header": {
      "type": "PageHeader",
      "props": { "title": "Payments" }
    }
  }
}

Rust handler:

#![allow(unused)]
fn main() {
use ferro::{handler, JsonUi, Response};

#[handler]
pub async fn index() -> Response {
    JsonUi::render_file("src/views/dashboard/payments.json", serde_json::json!({}))
}
}

When the page has no per-request structure (the elements and their relationships are fixed), the static form is the default. Adding directives or moving to SpecBuilder for a static page introduces machinery without changing the output.

Homogeneous iteration — $each

N elements share one structure; the count and the per-element data come from a runtime array. The $each directive instantiates one element per row in a data array, with auto-suffixed IDs ({id}-0, {id}-1, ...) that prevent collisions.

JSON file (orders kanban):

{
  "$schema": "ferro-json-ui/v2",
  "title": "Orders",
  "layout": "dashboard",
  "root": "kanban_board",
  "elements": {
    "kanban_board": {
      "type": "KanbanBoard",
      "props": { "columns": { "$data": "/columns" } },
      "children": ["order_card"]
    },
    "order_card": {
      "type": "Card",
      "$each": { "path": "/orders", "as": "order" },
      "props": {
        "title": { "$template": "#{/order/order_number} — {/order/total_display}" },
        "description": { "$data": "/order/customer_name" }
      }
    }
  }
}

At resolve time, ferro replaces order_card with order_card-0, order_card-1, ..., one per row in data.orders. The parent's children list is rewritten to reference the new clone IDs.

See ./expressions.md#each for the full directive reference (fields, validation, correlated children, limitations).

Conditional emission — $if

A single element template branches on a runtime flag. The $if directive removes the element from the spec entirely when the predicate is false — no DOM is emitted at all.

JSON file fragment:

"btn_advance": {
  "type": "Button",
  "$if": { "path": "/can_advance", "operator": "eq", "value": true },
  "props": { "label": "Advance" }
}

$if is distinct from the visible prop on elements. The difference matters:

  • $if is evaluated at resolve time. A falsy predicate removes the element from Spec.elements. No HTML is emitted.
  • visible is evaluated at render time. A falsy condition renders the element with hidden semantics. The HTML is present but suppressed visually.

Use $if when the element should not exist in the response (security-sensitive actions, structural omission, eliminating wasted bytes). Use visible when client-side toggling needs the DOM available.

See ./expressions.md#if for the full predicate syntax, validation rules, and composition with $each.

Heterogeneous runtime construction — SpecBuilder

The element graph is computed from complex domain state and cannot be expressed declaratively. The graph shape itself depends on runtime data, not just per-element props or counts.

The SpecBuilder nested-element API constructs the flat element map from nested Rust types. Child IDs are auto-generated by structural position (root-0, root-1, ...).

#![allow(unused)]
fn main() {
use ferro::json_ui::{Spec, SpecBuilder, Element};

let spec: Spec = SpecBuilder::new()
    .title("Order detail")
    .layout("dashboard")
    .element_nested("root", Element::new("Card")
        .prop("title", "Order #1042")
        .child_nested(Element::new("Text").prop("content", "Status: confirmed"))
        .child_nested(Element::new("Button").prop("label", "Advance")))
    .build()?;
}

The existing flat SpecBuilder::element(id, builder) API remains available for cases where explicit IDs are preferred. The nested form is sugar over construction; the runtime Spec type is the same flat element map either way.

Reach for SpecBuilder only after confirming the case cannot be expressed with $each and $if. Most controller code that hand-builds heterogeneous shapes is doing one of two things that the directives already cover: iterating a list (use $each) or branching on a flag (use $if). Truly heterogeneous runtime construction — a graph whose nesting structure depends on runtime values, not just per-element data — is the only case where Rust construction is the right tool.

Namespace: element-level vs prop-level directives

Directives split into two namespaces, distinguished by where they appear in the JSON.

Element-level directives appear as keys on Element JSON objects, alongside type, props, children. They are resolved by expand_directives before render.

  • $each — iterate, instantiating one element per row.
  • $if — conditional emission, removing the element when falsy.

Prop-level directives appear inside props values. They are resolved by resolve_expressions after expansion.

  • $data — type-preserving extraction from spec.data.
  • $template — string interpolation with {/path} placeholders.

There is no $template element. Element-level templating is covered by $each; the $template keyword is reserved for prop-level string interpolation. A separate $template element would be a parallel mechanism without additional expressive power, and it would collide semantically with the existing prop-level $template directive.

Composition rules

  • $each and $if can co-occur on the same element. $if is evaluated first; if false, the element is removed and $each produces no clones.
  • Sibling templates with the same {path, as} pair are correlated by index. The i-th clone of one sibling references the i-th clone of the other. Different {path, as} pairs at the same level are rejected as MismatchedEach at validation time.
  • Nested $each deeper than direct-children siblings is rejected as NestedEach at validation time. If a nested-iteration case appears, file an issue with the data shape.
  • Reserved as names: data, root, _root, _each, this, self. Using any of these as the loop variable is rejected as EachAsReservedName.

Nesting depth limit

The maximum allowed nesting depth from the root element is controlled by MAX_NESTING_DEPTH, currently set to 5. Specs that exceed depth 5 fail validation with SpecError::DepthExceeded.

The limit accommodates dashboard layouts at depth 4 (root → grid → card → badge) with one level of headroom. A typical deep layout — root → grid → card → row → atom — sits exactly at depth 5.

If a layout exceeds depth 5, flatten with Element.children ID references instead of physical nesting. Most layouts that appear to require deep nesting can be restructured by promoting inner containers to named top-level elements and wiring them via explicit children IDs.

Spec.title binding

The title field accepts either a literal string or a {"$data": "/path"} binding. Bindings are resolved against spec.data at render time via JSON Pointer. When the path is missing or the resolved value is not a string, the title falls back to "Ferro".

Literal title:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Orders",
  "layout": "dashboard",
  "root": "page_header",
  "elements": { ... }
}

Binding title (resolved from handler data):

{
  "$schema": "ferro-json-ui/v2",
  "title": { "$data": "/page_title" },
  "layout": "dashboard",
  "root": "page_header",
  "elements": { ... }
}

With handler data { "page_title": "Order #1042" }, the rendered <title> is Order #1042. The $data path follows JSON Pointer syntax (/key/nested).

Note: title is the only top-level spec field that accepts a binding expression. layout, root, and $schema are always literal strings.

Decision examples

The following concrete cases illustrate how the rubric maps to real controllers.

Case 1 — Settings page with a fixed list of switches. The set of switches is hardcoded; their state comes from the database. Static spec — the elements are fixed; $data binds the switch values.

Case 2 — Order kanban with N order cards. The card structure is uniform; the count comes from the orders query. Homogeneous iteration — $each over /orders.

Case 3 — Order detail with an "Advance" button that only appears when the order is advanceable. A single button template; one runtime flag controls emission. Conditional emission — $if with { path: "/can_advance", operator: "eq", value: true }.

Case 4 — Form whose field set depends on the chosen product type. The field set varies in nesting structure per product type, not just per-field props. The selected type determines which subgroups exist, in what order, and at what depth. Heterogeneous runtime construction — SpecBuilder::element_nested in the handler.

Expressions

Expressions are JSON objects placed as prop values inside elements. They are resolved at render time by the framework against the handler data. There are exactly two expression types: $data and $template.

Expressions appear only inside element.props. They are not resolved elsewhere in the spec.

$data — Type-Preserving Extraction

Format:

{ "$data": "/json/pointer/path" }

$data extracts the value at the given JSON Pointer path from the spec's data context. The resolved value replaces the entire expression object and preserves its original type.

Missing paths resolve to null.

Examples:

{ "$data": "/user/name" }      // "Alice"  (string preserved)
{ "$data": "/order/total" }    // 99.50    (number preserved)
{ "$data": "/flags/active" }   // true     (boolean preserved)
{ "$data": "/items" }          // [...]    (array preserved)
{ "$data": "/missing" }        // null     (path not found)

In a complete element:

"total_card": {
  "type": "StatCard",
  "props": {
    "label": "Total Revenue",
    "value": { "$data": "/stats/revenue" }
  }
}

With data { "stats": { "revenue": 12345 } }, the value prop resolves to 12345 (number).

$template — String Interpolation

Format:

{ "$template": "text {/path} more text" }

$template produces a string by substituting {/path} placeholders with values from the data context. Each placeholder uses JSON Pointer syntax. The result is always a string regardless of what the placeholders resolve to.

Missing placeholders substitute as "" (empty string). To emit a literal { or } character, escape it with a backslash: \{ and \}.

Examples:

{ "$template": "Hello, {/user/name}!" }          // "Hello, Alice!"
{ "$template": "Order #{/order/id}" }             // "Order #1042"
{ "$template": "Items: {/cart/count} in cart" }   // "Items: 3 in cart"

In a complete element:

"greeting": {
  "type": "Text",
  "props": {
    "content": { "$template": "Welcome, {/user/name}!" },
    "element": "h1"
  }
}

Where Expressions Apply

Expressions are resolved in element.props values only. They are not resolved in:

  • spec.title — accepts a literal string OR a {"$data": "/path"} binding (resolved at render time; see Spec Construction — title binding)
  • spec.layout — literal string
  • spec.data — the data source itself
  • element.children — always a list of element ID strings
  • element.action — handler name and method are literal
  • element.visible — visibility condition fields are literal

Expressions can appear at any depth inside a props object or array, but only as values — not as keys.

Using both expression types in one element:

"order_header": {
  "type": "Text",
  "props": {
    "content": { "$template": "Welcome, {/user/name}!" },
    "element": "h1"
  }
}

Single-Pass Guarantee

If a $data expression resolves to a string value that looks like {"$data": "/another/path"}, the inner expression is not re-resolved. Expressions are evaluated in a single pass. This prevents injection and makes expression evaluation predictable.

Hard Cap — What Does Not Exist

The expression language is intentionally minimal. The following do not exist and will not be added:

  • $if — no conditional rendering in expressions
  • $for — no loops in expressions
  • $state — no client-side state
  • $bind — no two-way binding
  • $map — no array transformation in expressions
  • $reduce — no aggregation in expressions

Conditional logic belongs in the Rust handler before calling render_file. Use the "visible" field on elements for simple show/hide logic based on data values. Complex branching is handled server-side — shape the data differently, or call render_file with a different spec path.

Infallible Semantics

Malformed expressions degrade to literal JSON values — the framework never panics on invalid expression objects. An expression with a non-string value or extra sibling keys is passed through unchanged. This is intentional for rendering reliability.

$each

$each instantiates one element per row in a data array.

The directive is element-level: it appears as a key on the element JSON object, alongside type, props, and children. It is resolved before render — by the time props are evaluated, the templated element has been replaced by N concrete clones.

"order_card": {
  "type": "Card",
  "$each": { "path": "/orders", "as": "order" },
  "props": { "title": { "$data": "/order/order_number" } }
}

Fields

  • path — slash-separated JSON Pointer path to a JSON array in spec.data. Required.
  • as — loop-variable name bound during expansion. Paths starting with /{as}/... in the templated element's props resolve to the current row. Required.

Expansion

For each row at index i in the resolved array, ferro:

  1. Clones the templated element.
  2. Rewrites prop expressions: paths starting with /{as}/... resolve against the row data.
  3. Assigns the clone the ID {element_id}-{i} (e.g., order_card-0, order_card-1, ...).
  4. Removes the original templated element.
  5. Updates any parent's children list to reference the clone IDs.

The resolve order is: $if first, then $each, then resolve_actions, then resolve_expressions. By the time props are walked for $data / $template, the element graph is already flat and concrete.

Validation

The following errors are emitted at Spec::from_json time, before any data is bound:

  • EachPathNotArray — emitted as a best-effort check when spec.data is non-null and the path resolves to a non-array value.
  • EachAsReservedNameas collides with one of the reserved names: data, root, _root, _each, this, self.
  • NestedEach — a $each-templated element's transitive descendant (deeper than direct children) is also $each-templated.
  • MismatchedEach — a $each-templated element's direct child is $each over a different {path, as} pair than its parent.

Correlated children

Sibling templates with the same {path, as} pair are correlated by index: the i-th clone of one sibling references the i-th clone of the other. The pattern shows up when a Card and its Badge / DropdownMenu children all iterate over the same source array — each card's badge and dropdown belong to the same row.

"order_card": {
  "type": "Card",
  "$each": { "path": "/orders", "as": "order" },
  "props": { "title": { "$data": "/order/order_number" } },
  "children": ["order_badge"]
},
"order_badge": {
  "type": "Badge",
  "$each": { "path": "/orders", "as": "order" },
  "props": { "label": { "$data": "/order/status_label" } }
}

With three orders, expansion produces order_card-0 through order_card-2, each referencing order_badge-0 through order_badge-2 correlated by index. Different {path, as} pairs at the same level are rejected as MismatchedEach.

Limitations

  • Per-row action.url values are not synthesized from row data inside $each. The controller pre-resolves URLs into spec.data and the templated element references them via { "$data": "/order/advance_url" }.
  • Nested $each is rejected. If a use case appears, file an issue with the data shape.

Example: kanban cards from a data array

$each can template the card elements inside a fixed KanbanColumn. This is distinct from KanbanBoard.items_path + group_by, which buckets a flat item array into fixed lanes and renders each item with the prescribed title/description card.

PatternUse when
KanbanBoard.items_path + group_byFixed lanes; each item rendered with the prescribed card (title, description, dropdown) — no custom card structure needed
$each inside KanbanColumnFixed lanes, but cards need custom structure (badges, nested elements) templated per item in a data array

Spec with $each inside a fixed column:

{
  "$schema": "ferro-json-ui/v2",
  "title": "Orders",
  "layout": "dashboard",
  "root": "board",
  "elements": {
    "board": {
      "type": "KanbanBoard",
      "props": {
        "columns": [
          { "id": "pending", "title": "Pending", "count": 3, "children": ["pending_card"] }
        ]
      }
    },
    "pending_card": {
      "type": "Card",
      "$each": { "path": "/orders/pending", "as": "order" },
      "props": {
        "title": { "$template": "#{/order/number} — {/order/total}" },
        "description": { "$data": "/order/customer_name" }
      },
      "children": ["pending_card_badge"]
    },
    "pending_card_badge": {
      "type": "Badge",
      "$each": { "path": "/orders/pending", "as": "order" },
      "props": { "label": { "$data": "/order/status_label" } }
    }
  }
}

Handler data:

{
  "orders": {
    "pending": [
      { "number": "1042", "total": "€ 99,00", "customer_name": "Alice", "status_label": "New" },
      { "number": "1043", "total": "€ 45,00", "customer_name": "Bob",   "status_label": "New" }
    ]
  }
}

At resolve time, pending_card expands to pending_card-0 and pending_card-1; pending_card_badge expands to correlated clones. The KanbanBoard.columns[0].children list is rewritten to reference the clone IDs.

Use this pattern when the card needs custom structure (badges, nested elements). When the prescribed title/description card suffices, use KanbanBoard.items_path + group_by — it buckets a flat item array into the fixed lanes with no per-card templating.

$if

$if removes an element from the spec when its predicate evaluates false.

The directive is element-level: it appears as a key on the element JSON object. It is resolved before render; falsy elements are deleted from Spec.elements and no HTML is emitted for them.

"btn_advance": {
  "type": "Button",
  "$if": { "path": "/can_advance", "operator": "eq", "value": true },
  "props": { "label": "Advance" }
}

Distinction from visible

$if and the visible prop both gate elements on a runtime condition, but they differ in when the check runs and in what reaches the response.

  • $if (resolve-time): falsy predicate → the element is removed from the spec. No HTML is emitted.
  • visible (render-time): falsy condition → the element renders with hidden semantics. The HTML is present but suppressed.

Use $if when the element should not exist in the response at all — security-sensitive actions, structural omission, eliminating wasted bytes. Use visible when client-side toggling needs the DOM available.

Predicate syntax

$if reuses the same predicate shape as visible. Both flat conditions and compound forms are accepted:

  • Flat condition: { "path": "/p", "operator": "eq", "value": x }.
  • Compound: { "and": [ ... ] }, { "or": [ ... ] }, { "not": { ... } }.

Operator names (snake_case): exists, not_exists, eq, not_eq, gt, lt, gte, lte, contains, not_empty, empty. The canonical name is eq, not equals — the equals form is not accepted.

Validation

  • IfPathMissing — emitted when spec.data is non-null and the predicate path resolves to None (the key is absent). This is distinct from a present-but-null value. The check fires at Spec::from_json time; runtime data is not validated this way.

Interaction with $each

When both $if and $each are present on the same element, $if is evaluated first. A falsy $if removes the element before $each would have expanded it; no clones are produced.

Missing-children behavior

When a parent's children list references an ID that was removed by $if, the render layer skips the reference. The page renders without the missing child; no error is raised. This is the same behavior the renderer already applies to references that point at nonexistent IDs.

JSON Schema Export

Ferro can export a JSON Schema document describing the full JSON-UI spec format, including all built-in components and any registered custom plugins. Use it to enable IDE validation and autocompletion for spec files.

Generate the Schema

Run from your project root:

# Export full schema (all components + custom plugins)
ferro json-ui:schema --output schema.json --pretty

# Export schema for a single component
ferro json-ui:schema --component DataTable --pretty

Flags:

FlagDescription
--output <file>Write schema to a file (defaults to stdout)
--prettyPretty-print the JSON output
--component <name>Export schema for a single component only

The command requires a compiled Ferro project — it runs your app binary to include custom plugins. The first run may be slow due to compilation.

VS Code Integration

Add to .vscode/settings.json to enable schema validation and autocompletion in spec files:

{
  "json.schemas": [
    {
      "fileMatch": ["src/views/*.json"],
      "url": "./schema.json"
    }
  ]
}

After this setup, VS Code validates spec files against the schema and provides autocompletion for component types and their props.

Other Editors

The generated schema.json is standard JSON Schema draft-07, compatible with any editor that supports JSON Schema validation — IntelliJ IDEA, Neovim with an LSP client, and others.

Configure your editor to associate src/views/*.json with the local schema.json file using its JSON Schema settings.

What the Schema Covers

  • Full Spec object shape ($schema, title, layout, root, elements)
  • All element fields (type, props, children, action, visible)
  • Per-component props definitions (required vs optional fields, allowed values)
  • Expression objects ($data and $template shapes)
  • Custom plugin components registered in the app

Schema Structure

Partial output to illustrate the shape:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Ferro JSON-UI Spec",
  "type": "object",
  "required": ["root", "elements"],
  "properties": {
    "$schema": { "type": "string" },
    "title": { "type": "string" },
    "layout": { "type": "string" },
    "root": { "type": "string" },
    "elements": {
      "type": "object",
      "additionalProperties": { "$ref": "#/definitions/Element" }
    }
  }
}

Keeping the Schema Up to Date

Re-run ferro json-ui:schema --output schema.json --pretty when you:

  • Add new custom plugins to the app
  • Upgrade Ferro to a new version

The schema reflects the component catalog at build time, so it must be regenerated whenever the catalog changes.

checkpoint_projection

checkpoint_projection is an MCP tool that runs a structured verification pass on a service projection and returns a single verdict. It is the primary tool for catching projection–model coherence issues before they surface at runtime.

When to Use

Call checkpoint_projection after generating or editing a projection — specifically when a projection field might reference a model attribute that no migration created. The tool catches this class of issue statically, before a running application exposes it as a runtime error.

{
  "name": "checkpoint_projection",
  "arguments": {
    "name": "booking_service"
  }
}

name is the projection function name as defined in src/projections/ (e.g. "booking_service"). It is resolved by function name; a service/struct name such as "Booking" will not resolve.

Verdict Shape

The tool returns a single Verdict object:

{
  "status": "fail",
  "projection": "booking_service",
  "seams": [
    {
      "seam": "field_to_column",
      "status": "fail",
      "source": "checkpoint",
      "findings": [
        {
          "subject": "phantom_col",
          "detail": "no column `phantom_col` on entity `booking`",
          "fix": "add column `phantom_col` to `booking` migration, or remove the field from the projection"
        }
      ]
    },
    {
      "seam": "props_to_contract",
      "status": "not_checked",
      "source": "validate_contracts",
      "reason": "unproven_against_real_inputs"
    }
  ],
  "next_steps": [
    "add column `phantom_col` to `booking` migration, or remove the field from the projection (seam: field_to_column)"
  ]
}

Top-level fields

FieldTypeDescription
statusSeamStatusAggregate status across all seams.
projectionstringThe projection name as supplied to the tool.
seamsSeamResult[]One entry per seam, regardless of outcome.
next_stepsstring[]Ranked, deduplicated actionable strings, capped at 5.

SeamStatus values

ValueMeaning
passSeam checked and no issues found.
warnSeam checked; findings present but not blocking.
failSeam checked; one or more blocking findings present.
not_checkedPrerequisite absent — seam was not run. Never coerced to pass.

SeamResult fields

FieldTypeDescription
seamstringSeam identifier (e.g. "field_to_column").
statusSeamStatusOutcome for this seam.
sourcestringProvenance tag naming the validator that produced the result. Seam 2 (field_to_column) uses "checkpoint"; seams 1, 3, 4, and 5 name their delegating validator (e.g. "validate_projection", "json_ui_verify_action").
findingsFinding[]Populated when status is fail or warn.
reasonstring?Populated for not_checked or warn outcomes; describes why.

Finding fields

FieldTypeDescription
subjectstringThe field, entity, or structural element the finding concerns.
detailstringHuman-readable description of the problem.
fixstringConcrete remediation step an agent can act on without a second call.

The field→column Seam

The field_to_column seam is the primary check delivered in this version. It verifies that every field in the projection has a corresponding column on the source model entity.

What it checks: each FieldDef in the reconstructed ServiceDef is present as a column name in the list_models output for the matching entity.

What it does not check: column type compatibility — this seam checks column presence only. Type mismatches are a separate concern deferred to a later phase.

Model resolution: the seam matches the projection's service_name (case-insensitive snake_case) against the entity struct name from SeaORM. If no entity matches, the seam returns not_checked with reason: "source_model_unresolved".

Relationship exemption: .has_many, .belongs_to, and similar relationship builders populate ServiceDef.relationships, not ServiceDef.fields. They are never subject to the column-presence check.

Coverage Honesty: not_checked

A not_checked seam means the prerequisite for running the check was absent — the model could not be resolved, the source could not be parsed, or the seam is not yet implemented. It does not mean the projection is clean.

not_checked never contributes to the aggregate status rising to fail. However, it is always listed in seams[] so the caller can see which checks did not run and why.

Do not treat a not_checked seam as equivalent to pass. A verdict of pass means all runnable seams passed. A verdict with not_checked seams means some checks were skipped.

Seam coverage by default

Not all seams run on every call. The table below documents which seams are active and which report not_checked by default.

SeamDefault outcomeRationale
field_to_columnActiveProven by an acceptance fixture (dangling field planted, exactly one finding).
action_to_routeActiveProven against the in-repo sample application (4 unregistered actions detected).
projection_well_formedActiveRuns via validate_projection; findings reported on name-collision path.
rendered_viewActiveRuns via render_projection; findings reported on name-collision path.
props_to_contractnot_checked by defaultProduced zero findings across both dogfood inputs (Phase 196 acceptance run). Reported as not_checked with reason: "unproven_against_real_inputs" rather than a vacuous pass.

A not_checked seam does not mean the projection is clean — it means the seam has not been exercised against real inputs that expose defects. When props_to_contract is not_checked, the aggregate status is determined by the other seams.

Aggregate Status Logic

  • fail if any seam is fail
  • warn if no seam is fail but at least one is warn
  • pass otherwise — including when all seams are not_checked

not_checked seams never raise the aggregate to fail.

next_steps

The next_steps list is assembled from all fail and warn findings:

  • Failures appear before warnings.
  • Within a rank, seam order (seam 1 through 5) is preserved.
  • Duplicate (subject, fix) pairs are deduplicated across seams.
  • The list is capped at 5 entries.
  • Each entry has the format: "<fix> (seam: <seam_name>)".

Status Cache

Every successful run writes .ferro/checkpoints/{name}.json with the full verdict plus two derived fields:

FieldDescription
ambient_status"clean" if status == pass, otherwise "failing".
checked_atISO 8601 UTC timestamp of the run.

This cache is read by other tools to surface ambient projection health without re-running the full check.

Read-Only Contract

checkpoint_projection reads source files, the route registry, and the DB schema. It does not:

  • invoke cargo or any compiler
  • write or modify source files
  • mutate database state

It is safe to call at any point in a development workflow, including in CI.

ToolWhen to use
validate_projectionStructural validation — unreachable states, unused guards.
projection_coverageCoverage gaps — which models have projections and which do not.
inspect_projectionInspect the raw ServiceDef structure of a projection.
render_projectionPreview the JSON-UI output a projection produces.

CLI Reference

Ferro provides a powerful CLI tool for project scaffolding, code generation, database management, and development workflow automation.

Installation

The Ferro CLI installs toolchain-free via Homebrew (recommended). Rust is only needed to build and run a scaffolded app, not to install the CLI.

brew install albertogferrario/ferro/ferro

curl installer (macOS and Linux)

curl -fsSL https://raw.githubusercontent.com/albertogferrario/ferro/main/scripts/install.sh | sh

Cargo (requires Rust)

cargo install ferro-cli

Or build from source:

git clone https://github.com/albertogferrario/ferro.git
cd ferro
cargo install --path ferro-cli

Project Commands

ferro new

Create a new Ferro project with the complete directory structure.

# Interactive mode (prompts for project name)
ferro new

# Direct creation
ferro new my-app

# Skip git initialization
ferro new my-app --no-git

# Non-interactive mode (uses defaults)
ferro new my-app --no-interaction

Options:

OptionDescription
--no-interactionSkip prompts, use defaults
--no-gitDon't initialize git repository

Generated Structure:

my-app/
├── src/
│   ├── main.rs
│   ├── bootstrap.rs
│   ├── routes.rs
│   ├── controllers/
│   │   └── mod.rs
│   ├── middleware/
│   │   ├── mod.rs
│   │   └── cors.rs
│   ├── models/
│   │   └── mod.rs
│   ├── migrations/
│   │   └── mod.rs
│   ├── events/
│   │   └── mod.rs
│   ├── listeners/
│   │   └── mod.rs
│   ├── jobs/
│   │   └── mod.rs
│   ├── notifications/
│   │   └── mod.rs
│   ├── tasks/
│   │   └── mod.rs
│   ├── seeders/
│   │   └── mod.rs
│   └── factories/
│       └── mod.rs
├── frontend/
│   ├── src/
│   │   ├── pages/
│   │   │   └── Home.tsx
│   │   ├── layouts/
│   │   │   └── Layout.tsx
│   │   ├── types/
│   │   │   └── inertia.d.ts
│   │   ├── app.tsx
│   │   └── main.tsx
│   ├── package.json
│   ├── tsconfig.json
│   ├── vite.config.ts
│   └── tailwind.config.js
├── Cargo.toml
├── .env
├── .env.example
└── .gitignore

Development Commands

ferro serve

Start the development server for both backend and frontend. Auto-reload is opt-in via --watch; without it, the r key triggers rebuilds on demand.

# Start both backend and frontend. No file watching; press r to rebuild on demand.
ferro serve

# Enable file-watch auto-reload with a 500ms trailing-edge debounce.
ferro serve --watch

# Custom ports.
ferro serve --port 8080 --frontend-port 5173

# Backend only (no frontend dev server).
ferro serve --backend-only

# Frontend only (no Rust compilation).
ferro serve --frontend-only

# Skip TypeScript type generation.
ferro serve --skip-types

Options:

OptionDefaultDescription
--port8080Backend server port
--frontend-port5173Frontend dev server port
--backend-onlyfalseRun only the backend
--frontend-onlyfalseRun only the frontend
--skip-typesfalseSkip TypeScript type generation
--watchfalseEnable file-watch auto-reload (500ms debounce)

Key bindings (when stdin is a TTY):

KeyAction
rRebuild the backend and regenerate types. If a build is in flight, it is cancelled and restarted.
q or Ctrl-CGraceful shutdown.

When stdin is not a TTY (for example, when ferro serve is piped or run under a process supervisor), the r key is unavailable and the banner says so; Ctrl-C still works.

What it does:

  1. Starts the Rust backend via an in-process supervisor that owns the cargo run child directly. Auto-reload is opt-in via --watch; without it, the supervisor waits for the r key to trigger a rebuild.
  2. Starts the Vite frontend dev server.
  3. With --watch, watches *.rs files under src/ with a 500ms trailing-edge debounce; a burst of saves produces one rebuild after the burst settles.
  4. On every rebuild (manual or file-triggered), regenerates TypeScript types from Rust InertiaProps structs.
  5. Auto-resolves port conflicts: if the frontend port is in use, the next available port is selected and propagated to the backend via VITE_DEV_SERVER.

ferro generate-types

Generate TypeScript type definitions from Rust InertiaProps structs.

ferro generate-types

This scans your Rust code for structs deriving InertiaProps and generates corresponding TypeScript interfaces in frontend/src/types/.

Includes route generation: generate-types also runs route generation internally, producing TypeScript route helpers alongside the type definitions.

ferro clean

Remove build artifacts from the project.

# Remove all build artifacts (runs cargo clean)
ferro clean

# Remove only artifacts older than N days (requires cargo-sweep)
ferro clean --sweep 7

Options:

OptionDefaultDescription
--sweep <days>Remove only artifacts older than N days. Requires cargo install cargo-sweep.

What it does:

  1. Without --sweep: runs cargo clean, removing the entire target/ directory.
  2. With --sweep <days>: invokes cargo sweep --maxage <days> to remove only stale artifacts, preserving recently compiled dependencies.

Code Generators

All generators follow the pattern ferro make:<type> <name> [options].

ferro make:controller

Generate a controller with handler methods.

# Basic controller
ferro make:controller UserController

# Resource controller with CRUD methods
ferro make:controller PostController --resource

# API controller (JSON responses)
ferro make:controller Api/ProductController --api

Options:

OptionDescription
--resourceGenerate index, show, create, store, edit, update, destroy methods
--apiGenerate API-style controller (JSON responses)

Generated file: src/controllers/user_controller.rs

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, json_response};

#[handler]
pub async fn index(req: Request) -> Response {
    json_response!({ "message": "index" })
}

#[handler]
pub async fn show(req: Request) -> Response {
    json_response!({ "message": "show" })
}

// ... additional methods for --resource
}

ferro make:middleware

Generate middleware for request/response processing.

ferro make:middleware Auth
ferro make:middleware RateLimit

Generated file: src/middleware/auth.rs

#![allow(unused)]
fn main() {
use ferro::{Middleware, Request, Response, Next};
use async_trait::async_trait;

pub struct Auth;

#[async_trait]
impl Middleware for Auth {
    async fn handle(&self, request: Request, next: Next) -> Response {
        // TODO: Implement middleware logic
        next.run(request).await
    }
}
}

ferro make:action

Generate a single-action class for complex business logic.

ferro make:action CreateOrder
ferro make:action ProcessPayment

Generated file: src/actions/create_order.rs

#![allow(unused)]
fn main() {
use ferro::FrameworkError;

pub struct CreateOrder;

impl CreateOrder {
    pub async fn execute(&self) -> Result<(), FrameworkError> {
        tracing::info!("executing resource action");
        Ok(())
    }
}
}

ferro make:auth

Scaffold a complete authentication system with migration, controller, and setup instructions.

# Generate auth scaffolding
ferro make:auth

# Force overwrite existing files
ferro make:auth --force

Options:

OptionDescription
--force, -fOverwrite existing auth controller and migration

Generated Files:

  • src/migrations/m{timestamp}_add_auth_fields_to_users.rs -- ALTER TABLE migration adding password, remember_token, and email_verified_at fields to the existing users table
  • src/controllers/auth_controller.rs -- Controller with register, login, and logout handlers

What it does:

  1. Generates an ALTER TABLE migration (assumes users table already exists)
  2. Creates an auth controller with register/login/logout handlers
  3. Registers the controller module in src/controllers/mod.rs
  4. Prints setup instructions for the auth provider and route registration

The command uses an ALTER TABLE approach because most projects already have a users table with basic fields. The migration adds only the authentication-specific columns.

See also: Authentication guide for the complete auth setup walkthrough.

ferro make:event

Generate an event struct for the event dispatcher.

ferro make:event UserRegistered
ferro make:event OrderPlaced

Generated file: src/events/user_registered.rs

#![allow(unused)]
fn main() {
use ferro_events::Event;

#[derive(Debug, Clone, Event)]
pub struct UserRegistered {
    pub user_id: i64,
}
}

ferro make:listener

Generate a listener that responds to events.

ferro make:listener SendWelcomeEmail
ferro make:listener NotifyAdmins

Generated file: src/listeners/send_welcome_email.rs

#![allow(unused)]
fn main() {
use ferro_events::{Listener, Event};
use async_trait::async_trait;

pub struct SendWelcomeEmail;

#[async_trait]
impl<E: Event + Send + Sync> Listener<E> for SendWelcomeEmail {
    async fn handle(&self, event: &E) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        tracing::info!("handling event");
        Ok(())
    }
}
}

ferro make:job

Generate a background job for queue processing.

ferro make:job ProcessImage
ferro make:job SendEmail

Generated file: src/jobs/process_image.rs

#![allow(unused)]
fn main() {
use ferro_queue::{Job, JobContext};
use serde::{Deserialize, Serialize};
use async_trait::async_trait;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessImage {
    pub image_id: i64,
}

#[async_trait]
impl Job for ProcessImage {
    async fn handle(&self, ctx: &JobContext) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        tracing::info!(id = self.image_id, "processing item");
        Ok(())
    }
}
}

ferro make:notification

Generate a multi-channel notification.

ferro make:notification OrderShipped
ferro make:notification InvoiceGenerated

Generated file: src/notifications/order_shipped.rs

#![allow(unused)]
fn main() {
use ferro_notifications::{Notification, Notifiable, Channel};

pub struct OrderShipped {
    pub order_id: i64,
}

impl Notification for OrderShipped {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail, Channel::Database]
    }
}
}

ferro make:migration

Generate a database migration file.

ferro make:migration create_posts_table
ferro make:migration add_status_to_orders

Generated file: src/migrations/m20240115_143052_create_posts_table.rs

#![allow(unused)]
fn main() {
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[derive(DeriveIden)]
enum Item {
    Table,
    Id,
    Name,
    CreatedAt,
}

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Item::Table)
                    .if_not_exists()
                    .col(ColumnDef::new(Item::Id).integer().not_null().auto_increment().primary_key())
                    .col(ColumnDef::new(Item::Name).string().not_null())
                    .col(ColumnDef::new(Item::CreatedAt).timestamp().not_null())
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Item::Table).to_owned())
            .await
    }
}
}

ferro make:inertia

Generate an Inertia.js page component with TypeScript types.

ferro make:inertia Dashboard
ferro make:inertia Users/Profile

Generated files:

  • frontend/src/pages/Dashboard.tsx
  • src/controllers/ (props struct)

ferro make:json-view

Generate a JSON-UI view file with AI-powered component generation.

# Generate a view (AI-powered if ANTHROPIC_API_KEY is set)
ferro make:json-view UserIndex

# Generate with a description for AI context
ferro make:json-view UserEdit --description "Edit form for user profile"

# Specify a layout
ferro make:json-view Login --layout auth

# Skip AI generation, use static template
ferro make:json-view Dashboard --no-ai

Options:

OptionDescription
--description, -dDescription of the desired UI for AI generation
--layout, -lLayout to use (default: app)
--no-aiSkip AI generation, use static template

How it works:

  1. Scans your models and routes for project context
  2. Sends context to Anthropic API (Claude Sonnet) for intelligent view generation
  3. Falls back to a static template if no API key is configured or AI generation fails
  4. Generates a view file in src/views/ and updates mod.rs

Requirements:

  • Set ANTHROPIC_API_KEY in your environment for AI-powered generation
  • Without the key, a static template with common components is generated
  • Model override via FERRO_AI_MODEL environment variable

Generated file: src/views/user_index.json

{
  "$schema": "ferro-json-ui/v2",
  "title": "User Index",
  "layout": "dashboard",
  "root": "root",
  "elements": {
    "root": {
      "type": "Card",
      "props": {
        "title": "User Index",
        "description": "Edit src/views/user_index.json to customize this view."
      },
      "children": ["heading"]
    },
    "heading": {
      "type": "Text",
      "props": { "content": "User Index", "element": "h1" }
    }
  }
}

Generated handler usage:

#![allow(unused)]
fn main() {
#[handler]
pub async fn user_index(req: Request) -> Response {
    let data = serde_json::json!({});
    JsonUi::render_file("views/user_index.json", data)
}
}

ferro make:task

Generate a scheduled task.

ferro make:task CleanupExpiredSessions
ferro make:task SendDailyReport

Generated file: src/tasks/cleanup_expired_sessions.rs

#![allow(unused)]
fn main() {
use ferro::{Task, Schedule};
use async_trait::async_trait;

pub struct CleanupExpiredSessions;

#[async_trait]
impl Task for CleanupExpiredSessions {
    fn schedule(&self) -> Schedule {
        Schedule::daily()
    }

    async fn handle(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        tracing::info!("running scheduled task");
        Ok(())
    }
}
}

ferro make:seeder

Generate a database seeder.

ferro make:seeder UserSeeder
ferro make:seeder ProductSeeder

Generated file: src/seeders/user_seeder.rs

#![allow(unused)]
fn main() {
use ferro::Seeder;
use async_trait::async_trait;

pub struct UserSeeder;

#[async_trait]
impl Seeder for UserSeeder {
    async fn run(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        // TODO: Seed data
        Ok(())
    }
}
}

ferro make:factory

Generate a model factory for testing.

ferro make:factory UserFactory
ferro make:factory PostFactory

Generated file: src/factories/user_factory.rs

#![allow(unused)]
fn main() {
use ferro::Factory;
use fake::{Fake, Faker};

pub struct UserFactory;

impl Factory for UserFactory {
    type Model = user::Model;

    fn definition(&self) -> Self::Model {
        // TODO: Define factory
        todo!()
    }
}
}

ferro make:error

Generate a custom error type.

ferro make:error PaymentFailed
ferro make:error ValidationError

ferro make:resource

Generate an API resource for transforming models into structured JSON responses.

# Basic resource (generates struct with placeholder fields)
ferro make:resource UserResource

# Auto-append "Resource" suffix if not present
ferro make:resource User

# With model path for automatic From<Model> implementation
ferro make:resource UserResource --model entities::users::Model

Options:

OptionDescription
--model, -mModel path for auto-generating From<Model> implementation

Generated file: src/resources/user_resource.rs

#![allow(unused)]
fn main() {
use ferro::{ApiResource, Resource, ResourceMap, Request};

#[derive(ApiResource)]
pub struct UserResource {
    pub id: i32,
    // Add fields from your model here
    // #[resource(rename = "display_name")]
    // pub name: String,
    // #[resource(skip)]
    // pub password_hash: String,
}
}

Field attributes:

AttributeDescription
#[resource(rename = "name")]Rename field in JSON output
#[resource(skip)]Exclude field from JSON output

The #[derive(ApiResource)] macro generates the Resource trait implementation, which provides to_resource() and to_json() methods. When --model is specified, a From<Model> implementation is generated to map model fields to resource fields.

After generation, add pub mod user_resource; to src/resources/mod.rs.

See also: API Resources guide for the complete resource system documentation.

ferro make:scaffold

Generate a complete CRUD scaffold: model, migration, controller, and Inertia pages.

# Basic scaffold
ferro make:scaffold Post

# With field definitions
ferro make:scaffold Post title:string content:text published:bool

# Complex example
ferro make:scaffold Product name:string description:text price:float stock:integer

Field Types:

TypeRust TypeDatabase Type
stringStringVARCHAR(255)
textStringTEXT
integeri32INTEGER
biginti64BIGINT
floatf64DOUBLE
boolboolBOOLEAN
datetimeDateTimeTIMESTAMP
dateDateDATE
uuidUuidUUID

Generated Files:

src/
├── models/post.rs           # SeaORM entity
├── migrations/m*_create_posts_table.rs
├── controllers/post_controller.rs
frontend/src/pages/
├── posts/
│   ├── Index.tsx
│   ├── Show.tsx
│   ├── Create.tsx
│   └── Edit.tsx

ferro make:api

Generate REST API endpoints for a set of models.

# Generate API for specific models
ferro make:api User Post

# Generate API for all detected models
ferro make:api --all

# Skip confirmation prompt and exclude specific fields
ferro make:api User --yes --exclude password_hash,secret_token

# Include all fields, disabling auto-exclusion of sensitive field names
ferro make:api User --include-all

Options:

OptionDefaultDescription
modelsOne or more model names (positional)
--allfalseGenerate API for all detected models
--yes, -yfalseSkip confirmation prompt
--excludeComma-delimited list of field names to exclude from generated endpoints
--include-allfalseDisable auto-exclusion of sensitive field names

What it does:

  1. Detects models in src/models/ (or uses the provided list)
  2. Generates controller, resource, and route entries for each model
  3. Automatically excludes fields matching sensitive patterns: password, secret, token, hash, key, salt, private, credentials
  4. --exclude accepts comma-delimited values (e.g. password_hash,secret_token)
  5. --include-all disables the auto-exclusion list entirely

ferro make:api-key

Generate an API key with SHA-256 hashing.

# Generate a live API key
ferro make:api-key "Production Bot"

# Generate a test key
ferro make:api-key "CI Testing" --env test

Options:

OptionDefaultDescription
nameDisplay name for the key (positional, required)
--envliveKey environment: live or test

What it does:

  1. Generates a cryptographically random API key prefixed with fe_<env>_
  2. Hashes the key with SHA-256 (the hash is what gets stored)
  3. Prints the raw key once — it is not stored in plaintext and cannot be recovered
  4. Outputs a ready-to-run SQL INSERT statement and a Rust snippet for verifying keys in handlers

ferro make:lang

Create translation files for a locale.

ferro make:lang fr
ferro make:lang pt-br

Options:

OptionDescription
nameBCP 47 locale tag (positional, required; e.g. en, fr, pt-br, zh-hans)

Generated files:

  • lang/<locale>/validation.json — Validation error message translations
  • lang/<locale>/app.json — Application-level string translations

ferro make:policy

Create an authorization policy for a model.

ferro make:policy Post
ferro make:policy PostPolicy --model Post

Options:

OptionDefaultDescription
namePolicy name (positional, required)
--model, -mName without Policy suffixThe model this policy guards

Generated file: src/policies/<name>_policy.rs

#![allow(unused)]
fn main() {
pub struct PostPolicy;

impl PostPolicy {
    pub fn view(&self, user: &User, post: &Post) -> bool {
        true
    }

    pub fn create(&self, user: &User) -> bool {
        true
    }

    pub fn update(&self, user: &User, post: &Post) -> bool {
        user.id == post.user_id
    }

    pub fn delete(&self, user: &User, post: &Post) -> bool {
        user.id == post.user_id
    }
}
}

ferro make:projection

Create a service projection definition.

ferro make:projection user
ferro make:projection order --from-model

Options:

OptionDefaultDescription
nameProjection name (positional, required)
--from-modelfalsePopulate fields from the matching SeaORM model in src/models/

Generated file: src/projections/<name>.rs

ferro make:stripe

Scaffold Stripe billing integration.

# Basic Stripe integration
ferro make:stripe

# Include Stripe Connect scaffolding
ferro make:stripe --connect

Options:

OptionDefaultDescription
--connectfalseInclude Stripe Connect webhook and connect account ID field

Generated files:

  • src/stripe/mod.rs — Stripe module root
  • src/stripe/webhook.rs — Stripe webhook handler
  • src/stripe/listeners.rs — Stripe event listeners
  • src/stripe/connect_webhook.rs — Connect webhook handler (with --connect)
  • src/migrations/m<timestamp>_create_tenant_billing_table.rs — Billing table migration (with --connect)

ferro make:theme

Create a JSON-UI theme with Tailwind v4 semantic tokens.

ferro make:theme ocean
ferro make:theme corporate

Options:

OptionDescription
nameTheme name (positional, required)

Generated files:

  • themes/<name>/tokens.css — Tailwind v4 @theme block with 23 semantic token slots
  • themes/<name>/theme.json — Empty JSON object for intent template overrides

The tokens.css file uses the Tailwind v4 @theme directive and defines semantic color, typography, and shape tokens. The theme.json file is a placeholder for overriding how structural intents (Browse, Focus, Collect, etc.) render for this theme.

ferro make:whatsapp

Scaffold WhatsApp Cloud API integration.

ferro make:whatsapp

No flags. Generates a complete WhatsApp webhook and listener scaffold.

Generated files:

  • src/whatsapp/mod.rs — WhatsApp module root
  • src/whatsapp/webhook.rs — WhatsApp webhook handler
  • src/whatsapp/listeners.rs — WhatsApp message listeners

Database Commands

ferro db:migrate

Run all pending migrations.

ferro db:migrate

ferro db:rollback

Rollback the last batch of migrations.

ferro db:rollback

ferro db:status

Show the status of all migrations.

ferro db:status

Output:

+------+------------------------------------------------+-------+
| Ran? | Migration                                       | Batch |
+------+------------------------------------------------+-------+
| Yes  | m20240101_000001_create_users_table            | 1     |
| Yes  | m20240101_000002_create_posts_table            | 1     |
| No   | m20240115_143052_add_status_to_posts           |       |
+------+------------------------------------------------+-------+

ferro db:fresh

Drop all tables and re-run all migrations.

ferro db:fresh

Warning: This is destructive and will delete all data.

ferro db:seed

Run database seeders to populate the database with test data.

# Run all seeders
ferro db:seed

# Run a specific seeder
ferro db:seed --class UserSeeder

Options:

OptionDescription
--classRun only a specific seeder by name

How it works:

The command delegates to cargo run -- db:seed in your project, which executes the registered seeders. Seeders are Rust structs implementing the Seeder trait, located in src/seeders/.

If no seeders directory exists, the command will prompt you to create one with ferro make:seeder <name>.

See also: ferro make:seeder to generate new seeder files.

ferro db:sync

Synchronize the database schema and generate entity files.

# Sync entities, running any pending migrations first (default)
ferro db:sync

# Sync entities without running migrations
ferro db:sync --skip-migrations

Options:

OptionDescription
--skip-migrationsSkip running migrations before syncing (migrations run by default)
--regenerate-modelsRegenerate SeaORM model wrappers

This command:

  1. Discovers the database schema (tables, columns, types)
  2. Generates SeaORM entity files in src/models/entities/
  3. Creates user-friendly model wrappers with the Ferro Model API

ferro db:query

Execute a raw SQL query against the database.

# Simple SELECT query
ferro db:query "SELECT * FROM users LIMIT 5"

# Query with conditions
ferro db:query "SELECT id, name, email FROM users WHERE active = true"

# Count query
ferro db:query "SELECT COUNT(*) FROM posts"

Features:

  • Reads DATABASE_URL from .env file
  • Supports SQLite and PostgreSQL databases
  • Displays results in a formatted table
  • Handles NULL values gracefully
  • Shows row count after results

Example Output:

+-----+-------+-------------------+
| 1   | Alice | alice@example.com |
| 2   | Bob   | bob@example.com   |
+-----+-------+-------------------+

→ 2 row(s)

Use Cases:

  • Quick data inspection during development
  • Debugging database state
  • Verifying migration results
  • Ad-hoc queries without external tools

Deployment Commands

ferro do:init

Initialize DigitalOcean App Platform deployment. Generates .do/app.yaml only — CI and Dockerfile scaffolding are handled by separate commands (ci:init, docker:init).

ferro do:init           # write .do/app.yaml if missing
ferro do:init --force   # regenerate an existing spec

The GitHub repo is auto-detected from git remote get-url origin; the region is hardcoded to fra1 in the template (edit the file afterwards to change it). The command reads .env.production to enumerate env keys for a commented-out scaffold; if .env.production is missing the command fails hard.

Generated file:

  • .do/app.yaml — App Platform spec with sanitized app name, auto-detected github.repo, region: fra1, a services: web entry, a workers: entry per non-test [[bin]], and a commented-out envs: scaffold. No databases: block is emitted.

Options:

  • --force, -f — Overwrite an existing .do/app.yaml.

See do:init for the full page.

Docker Commands

ferro docker:init

Generate a production-ready Dockerfile and .dockerignore. The Docker build consumes the project Cargo.toml directly — no dual manifest is written. Developers working against an unpublished ferro checkout maintain an uncommitted [patch.crates-io] block in their project Cargo.toml.

ferro docker:init          # write Dockerfile + .dockerignore
ferro docker:init --force  # overwrite existing files

Options:

  • --force, -f — Overwrite existing Dockerfile / .dockerignore.

Generated files:

  • Dockerfile — multi-stage build, base image defaults to rust:slim-bookworm.
  • .dockerignore

[package.metadata.ferro.deploy] in Cargo.toml:

docker:init reads optional deploy metadata from the project's Cargo.toml:

[package.metadata.ferro.deploy]
runtime_apt = ["libpq5", "ca-certificates"]
copy_dirs = ["migrations", "templates"]
  • runtime_apt — additional apt packages installed into the runtime stage.
  • copy_dirs — extra directories copied from the builder into the runtime image.

ferro docker:compose

Manage Docker Compose services.

# Start services
ferro docker:compose up

# Stop services
ferro docker:compose down

# Rebuild and start
ferro docker:compose up --build

Scheduling Commands

ferro schedule:run

Run scheduled tasks that are due.

ferro schedule:run

This executes all tasks whose schedule indicates they should run now. Typically called by a system cron job every minute:

* * * * * cd /path/to/project && ferro schedule:run >> /dev/null 2>&1

ferro schedule:work

Start the scheduler worker for continuous task execution.

ferro schedule:work

This runs in the foreground and checks for due tasks every minute. Useful for development or container deployments.

ferro schedule:list

Display all registered scheduled tasks.

ferro schedule:list

Output:

+---------------------------+-------------+-------------------+
| Task                      | Schedule    | Next Run          |
+---------------------------+-------------+-------------------+
| CleanupExpiredSessions    | Daily 00:00 | 2024-01-16 00:00  |
| SendDailyReport           | Daily 09:00 | 2024-01-15 09:00  |
| PruneOldNotifications     | Weekly Mon  | 2024-01-22 00:00  |
+---------------------------+-------------+-------------------+

Storage Commands

Create a symbolic link from public/storage to storage/app/public.

ferro storage:link

This allows publicly accessible files stored in storage/app/public to be served via the web server.

Validation & Diagnostics

ferro api:check

Verify that the local API server is running and MCP-compatible.

# Check local API server for MCP integration
ferro api:check

# Custom URL and API key
ferro api:check --url http://localhost:8080 --api-key fe_live_xxx

# Custom OpenAPI spec path
ferro api:check --spec-path /api/docs/openapi.json

Options:

OptionDefaultDescription
--urlhttp://localhost:8080Base URL of the running API server
--api-keyAPI key for testing authenticated endpoints
--spec-path/api/openapi.jsonPath to the OpenAPI spec endpoint

What it does:

  1. Checks server reachability by sending a GET request to --url
  2. Fetches the OpenAPI spec from <url><spec-path>
  3. Validates the spec structure (version, paths, info object)
  4. Tests API key authentication if --api-key is provided
  5. Prints ferro-api-mcp configuration for connecting AI tools to this server

ferro projection:check

Validate service projection definitions.

Requires the projections feature. Build with cargo build --features projections or add projections to the default features in Cargo.toml before running this command.

# Validate all projections
ferro projection:check

# Validate a single projection by function name
ferro projection:check --name user_service

Options:

OptionDefaultDescription
--nameCheck only the named projection function

What it does:

  1. Discovers all projection definitions in src/projections/
  2. Validates that each projection's field types, intent hints, and visibility rules are well-formed
  3. Reports structural errors and type mismatches with file and line references

ferro validate:contracts

Validate Inertia TypeScript contracts against Rust handler props.

# Validate all contracts
ferro validate:contracts

# Filter by route prefix
ferro validate:contracts --filter /users

# JSON output for CI integration
ferro validate:contracts --json

Options:

OptionDefaultDescription
--filter, -fOnly validate routes matching this prefix
--jsonfalseOutput results as JSON (useful for CI)

What it does:

  1. Compares Rust InertiaProps structs with the generated TypeScript interfaces in frontend/src/types/
  2. Reports field name mismatches, type incompatibilities, and missing optional fields
  3. With --json, outputs a machine-readable result for use in CI pipelines

AI-Assisted Development

ferro mcp

Start the Model Context Protocol (MCP) server for AI-assisted development.

ferro mcp

The MCP server provides introspection tools that help AI assistants understand your Ferro application structure, including routes, models, controllers, and configuration.

ferro boost:install

Install AI development boost features.

ferro boost:install

This sets up configuration for enhanced AI-assisted development workflows.

ferro claude:install

Install Ferro Claude Code skills to enable /ferro:* slash commands in Claude Code.

# Install all skills
ferro claude:install

# Force overwrite existing skills
ferro claude:install --force

# List available skills without installing
ferro claude:install --list

Options:

OptionDescription
--force, -fOverwrite existing skill files
--list, -lList available skills without installing

Installed Location: ~/.claude/commands/ferro/

Available Skills:

CommandDescription
/ferro:helpShow all available Ferro commands
/ferro:infoDisplay project information
/ferro:routesList all registered routes
/ferro:route:explainExplain a specific route in detail
/ferro:modelGenerate a new model with migration
/ferro:modelsList all models with fields
/ferro:controllerGenerate a new controller
/ferro:middlewareGenerate new middleware
/ferro:dbDatabase operations (migrate, rollback, seed)
/ferro:testRun tests with coverage options
/ferro:serveStart the development server
/ferro:newCreate a new Ferro project
/ferro:tinkerInteractive database REPL
/ferro:diagnoseDiagnose errors using MCP introspection

Skills leverage ferro-mcp for intelligent code generation and project introspection.

Command Summary

CommandDescription
newCreate a new Ferro project
serveStart development server
generate-typesGenerate TypeScript types
cleanRemove build artifacts
make:actionCreate an action class
make:apiGenerate REST API endpoints for models
make:api-keyGenerate an API key
make:authScaffold authentication system
make:controllerCreate a controller
make:errorCreate a custom error
make:eventCreate an event
make:factoryCreate a model factory
make:inertiaCreate an Inertia page
make:jobCreate a background job
make:json-viewCreate a JSON-UI view (AI-powered)
make:langCreate translation files for a locale
make:listenerCreate a listener
make:middlewareCreate middleware
make:migrationCreate a migration
make:notificationCreate a notification
make:policyCreate an authorization policy
make:projectionCreate a service projection
make:resourceCreate an API resource
make:scaffoldCreate complete CRUD scaffold
make:seederCreate a database seeder
make:stripeScaffold Stripe billing integration
make:taskCreate a scheduled task
make:themeCreate a JSON-UI theme
make:whatsappScaffold WhatsApp integration
db:migrateRun migrations
db:rollbackRollback migrations
db:statusShow migration status
db:freshFresh migrate (drop all)
db:seedRun database seeders
db:syncSync database schema
db:queryExecute raw SQL query
do:initGenerate DigitalOcean App Platform spec (.do/app.yaml)
ci:initGenerate GitHub Actions CI workflow (.github/workflows/ci.yml)
doctorRun project health diagnostics (eleven checks)
docker:initGenerate Dockerfile and .dockerignore
docker:composeManage Docker Compose
schedule:runRun due scheduled tasks
schedule:workStart scheduler worker
schedule:listList scheduled tasks
storage:linkCreate storage symlink
api:checkVerify API server and MCP integration
projection:checkValidate service projection definitions
validate:contractsValidate Inertia TypeScript contracts
mcpStart MCP server
boost:installInstall AI boost features
claude:installInstall Claude Code skills

Environment Variables

The CLI respects these environment variables:

VariableDescription
DATABASE_URLDatabase connection string
APP_ENVApplication environment (development, production)
RUST_LOGLogging level

ferro do:init

Generates the DigitalOcean App Platform deployment spec (.do/app.yaml) for a Ferro project.

Usage

ferro do:init [--force]

The command takes no positional arguments. The GitHub repository is auto-detected from git remote get-url origin; the region is hardcoded to fra1 in the generated template.

What it writes

Only .do/app.yaml. The spec contains:

  • A sanitized app name derived from the crate name.
  • region: fra1 (hardcoded in the template — edit the file afterwards if you need a different region).
  • github.repo auto-detected from the origin git remote, with deploy_on_push: true.
  • A services: block with one web entry running the default binary.
  • A workers: block with one entry per non-test/dev/debug [[bin]] declared in Cargo.toml.
  • A commented-out envs: scaffold listing every key read from .env.production. Values are not emitted — only the key names — so the scaffold is safe to commit. Uncomment and wire up secrets via the DO dashboard or doctl.

No databases: block is generated. Attach managed databases by editing .do/app.yaml directly.

.env.production is required

do:init reads .env.production to enumerate the keys that belong in the commented envs scaffold. If the file is missing the command fails hard with:

.env.production not found — copy .env.example to .env.production and fill in production values

This is intentional: the production env file is the single source of truth for which keys the deployed app needs. The command never falls back to .env.example.

Post-scaffold editing is user-owned

After do:init writes .do/app.yaml, the file is yours. Edit it directly to:

  • Change region.
  • Uncomment and configure the envs: block.
  • Add managed databases, domains, alerts, or additional components.
  • Adjust resource sizes or instance counts.

do:init will refuse to overwrite an existing spec without --force.

Idempotency

  • Missing .do/app.yaml → written.
  • Existing .do/app.yaml + no --force → refuses to run.
  • Existing .do/app.yaml + --force → regenerated from the current template (hand edits are lost).

CI is decoupled

do:init does not write .github/workflows/ci.yml. CI scaffolding lives in a separate command: ferro ci:init. The two commands are intentionally independent — one handles DigitalOcean deploy spec, the other handles GitHub Actions. Run them independently as needed.

ferro ci:init

Generate a GitHub Actions CI workflow at .github/workflows/ci.yml.

Usage

ferro ci:init           # write the workflow if missing
ferro ci:init --force   # overwrite an existing workflow

The command walks up from the current directory to locate Cargo.toml, then writes the workflow under <project-root>/.github/workflows/ci.yml.

What gets written

A single lint-and-test job that runs the canonical Ferro lint gate:

StepCommand
Format checkcargo fmt --all -- --check
Lintcargo clippy --all-targets -- -D warnings
Testscargo test --all-features
API readinesscargo run -p ferro-cli -- api:check
Inertia contract validationcargo run -p ferro-cli -- validate:contracts

The toolchain is installed via dtolnay/rust-toolchain@stable (with rustfmt and clippy components) and the cargo registry plus target directory are cached via Swatinem/rust-cache@v2.

Triggers

The workflow runs on:

  • pull_request — every PR, regardless of base branch.
  • push to main — to keep the main branch green and to populate the Swatinem cache for subsequent PRs.

Idempotency

ferro ci:init is idempotent: if .github/workflows/ci.yml already exists, the command exits non-zero and refuses to overwrite. Pass --force to regenerate the file from the current template.

The renderer is a pure function — running it twice produces byte-identical output, so --force is safe to wire into automation.

Why cargo run -p ferro-cli instead of installing the binary

The api:check and validate:contracts steps shell out via cargo run -p ferro-cli rather than relying on a globally installed ferro binary. This keeps CI hermetic — every run uses the exact ferro-cli version pinned in the project's Cargo.lock, with no extra install step and no risk of version drift between local and CI.

Relationship to do:init

ci:init and do:init are intentionally decoupled:

  • do:init scaffolds DigitalOcean App Platform deploy config (.do/app.yaml).
  • ci:init scaffolds the GitHub Actions workflow (.github/workflows/ci.yml).

They are independent commands with no shared state and no implicit chaining — a project can adopt CI without deploying to DO, and vice versa. Run whichever you need; both are idempotent.

ferro doctor

Single-command project health diagnostics. Runs eleven checks in declared order, prints colored human output by default, or a stable JSON schema with --json for agent / CI consumption.

ferro doctor
ferro doctor --json | jq '.summary'

Relationship to ferro:info MCP tool

ferro doctor is complementary to the ferro:info MCP introspection tool — it does not replace it (D-22). ferro:info describes what the project is (routes, models, installed crates); ferro doctor answers is it healthy?. Use ferro:info for understanding, ferro doctor for validation.

Checks

Checks run in this exact order (D-01):

#NameCategoryPurpose
1toolchain_matchGeneralrustc --version vs rust-toolchain.toml channel
2db_connectionGeneralDATABASE_URL reachable via cargo run -- db:status
3migrations_pendingGeneralPending vs applied migration count
4local_env_parityGeneralEvery key in .env.example is set in .env
5deploy_env_parityGeneral.env.production keys match the commented envs scaffold in .do/app.yaml
6copy_dirs_dockerignore_collisionDeploycopy_dirs entries not silently excluded by .dockerignore
7docker_template_driftDeployCommitted Dockerfile matches current scaffolder output
8generated_artifactsGeneralDockerfile, .dockerignore, .do/app.yaml present
9database_url_sqlite_in_prodGeneralWarns if DATABASE_URL in .env.production points at SQLite
10git_clean_and_pushedGeneralWorking tree clean and HEAD pushed to the tracked remote
11frontend_types_conventionGeneralNo hand-written files in frontend/src/types/ (see frontend-types)

Check #4 (local_env_parity) and #5 (deploy_env_parity) are powered by the deploy::env_production::parse_env_production_keys module, which parses .env.production for key names only (values are never read).

Status semantics

StatusMeaning
okCheck passed
warnNon-blocking issue (recommended fix; does not affect exit code)
errorBlocking issue (forces non-zero exit)

Exit code contract (D-09)

Overall statusExit code
All ok0
Any warn0
Any error1

The contract: non-zero iff at least one check returned error. Warnings never block. This makes doctor safe to drop into CI without false positives on dev-mode path deps.

JSON schema

ferro doctor --json emits this stable shape:

{
  "summary": {
    "overall": "warn",
    "ok": 5,
    "warn": 2,
    "error": 0
  },
  "checks": [
    {
      "name": "toolchain_match",
      "status": "ok",
      "message": "rustc 1.88.0 matches channel 1.88.0"
    },
    {
      "name": "generated_artifacts",
      "status": "warn",
      "message": "1 artifact(s) missing",
      "details": "missing: .do/app.yaml"
    }
  ]
}

Field reference:

  • summary.overallok | warn | error — worst status across all checks.
  • summary.ok / warn / error — counts.
  • checks[].name — stable identifier (one of the eleven names above).
  • checks[].statusok | warn | error.
  • checks[].message — short human-readable summary.
  • checks[].details — optional, present only when extra context exists.

Examples

# Full human report (default)
ferro doctor

# Machine output for CI / agents
ferro doctor --json

# Just the overall status
ferro doctor --json | jq -r '.summary.overall'

# List failing checks
ferro doctor --json | jq '.checks[] | select(.status == "error")'

# Use in CI as a gate
ferro doctor || exit 1

Deploy filter (--deploy)

ferro doctor --deploy runs only the checks with category Deploy (currently copy_dirs_dockerignore_collision). The same filter is available via the deploy_check MCP tool (see below). Combining --deploy with --json produces the same Report schema as a full run, filtered to deploy-category checks.

ferro doctor --deploy
ferro doctor --deploy --json | jq '.summary'

Preflight checks

Deploy-category checks catch failures that would otherwise surface only after a 1–10 minute Docker round-trip.

  • copy_dirs_dockerignore_collision — flags any copy_dirs entry in [package.metadata.ferro.deploy] that is excluded by .dockerignore, which would silently drop files from the Docker build context.

ferro deploy:init

Scaffolds the [package.metadata.ferro.deploy] table in the root Cargo.toml. Detects sensible defaults (binary name, common directories) and writes the block in-place using toml_edit, preserving comments and key order.

ferro deploy:init [--yes] [--dry-run]
  • --yes — accept all detected defaults without interactive prompts. Errors if a required value (e.g., web_bin) cannot be inferred.
  • --dry-run — print the block that would be written without modifying any files.

Example block produced for a project with a migrations/ directory and a binary named myapp:

[package.metadata.ferro.deploy]
runtime_apt = []
copy_dirs = ["migrations"]
web_bin = "myapp"

If [package.metadata.ferro.deploy] already exists in Cargo.toml, the command prompts for a collision policy: abort (default), overwrite (replace existing values), or merge (add missing keys only). Passing --yes without a pre-existing table always succeeds; with a pre-existing table it defaults to abort — use interactive mode to select overwrite or merge.

deploy_check MCP tool

The deploy_check MCP tool is the MCP surface of the same deploy check registry. Internally it runs ferro doctor --deploy --json from the project root and returns the JSON Report verbatim.

Frontend types: generator-owned convention

ferro generate-types is the single source of truth for the TypeScript types the Inertia frontend imports. The directory frontend/src/types/ is reserved for its output. Hand-written types live elsewhere.

What the generator produces

ferro generate-types writes exactly two files into frontend/src/types/:

FileContents
inertia-props.tsPer-component Inertia prop interfaces derived from #[handler] return types and Inertia::render call sites
routes.tsTyped route map keyed by route name, derived from the route registry

Both files are regenerated from current Rust source on every ferro serve restart (and on every cargo run that boots the app). The generator is deterministic for a given Rust source tree but is not guaranteed stable across unrelated changes — that is intentional, because the output is not tracked.

Why frontend/src/types/ is gitignored

The Ferro scaffold's .gitignore template marks frontend/src/types/ as generator-owned. The rationale:

  • No drift. Every server start regenerates from current Rust source. Drift between Rust and TypeScript types is impossible by construction.
  • No git noise. Tracking the generated output would produce a "modified" entry against the two files on every server restart, polluting git status.
  • No review burden. Derived files in pull requests duplicate the information already present in the Rust diff.

This is the same pattern as target/, dist/, or OpenAPI-derived clients: ignore the output, regenerate from the source.

Where hand-written types belong

Project-specific hand-written TypeScript types (domain types not produced by the generator) live under frontend/src/lib/types/. Any path outside frontend/src/types/ works; frontend/src/lib/types/ is the recommended convention.

The ferro doctor check frontend_types_convention flags any file in frontend/src/types/ whose name is not on the generator's allowlist (inertia-props.ts, routes.ts). The check is advisory (warning, not error) and never blocks the doctor exit code.

Fresh-clone bootstrap

On a fresh clone, frontend/src/types/ does not exist yet — it is gitignored, so it was never committed. Run the backend once to populate it:

cargo run

The first start emits frontend/src/types/inertia-props.ts and frontend/src/types/routes.ts. After that, npm run dev and npm run build can resolve their imports.

If the frontend build fails with errors like:

TS2307: Cannot find module './types/inertia-props' or its corresponding type declarations.

it means types have not been generated yet — run cargo run once.

Docker production builds

Because frontend/src/types/ is gitignored, it is also absent from the Docker build context. The scaffolder addresses this by emitting a dedicated Rust-toolchain stage (types-gen) in the rendered Dockerfile:

FROM rust:<tag> AS types-gen
WORKDIR /app
RUN cargo install ferro-cli --version <pinned> --locked
COPY . .
RUN ferro generate-types

FROM node:20-bookworm-slim AS frontend-builder
WORKDIR /frontend
# ...
COPY --from=types-gen /app/frontend/src/types ./src/types
RUN npm run build

The types-gen stage regenerates the types from Rust source, and the frontend stage copies them in before npm run build runs.

The pinned ferro-cli version is read from your project's Cargo.lock (the ferro-rs package's version). You can override it explicitly:

ferro docker:init --ferro-version <pinned> --force

Upgrading an existing project

If your project was scaffolded against an older Ferro that did not emit the types-gen stage, re-run the scaffolder to regenerate the Dockerfile:

ferro docker:init --force

This overwrites the existing Dockerfile with the current renderer output. Inspect the diff before committing.

ferro generate-routes --json — Stable JSON Schema

Status: Stable contract (Phase 124, D-10..D-12). Consumed by ferro-mcp and external agents. Additive changes only; renames or removals are breaking changes that require a major version bump.

Command

ferro generate-routes --json

Without --json, ferro generate-routes writes a TypeScript route helper file (default behavior, unchanged).

With --json, the command prints a single JSON document to stdout and exits non-zero on error. No files are written.

Schema

Expressed as TypeScript declarations matching the Rust types in ferro-cli/src/commands/generate_routes.rs (RoutesJson / RouteJson):

interface RoutesJson {
  routes: RouteJson[];
}

interface RouteJson {
  /** Uppercase HTTP verb. */
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

  /** Path with `{param}` placeholders, e.g. "/users/{id}". */
  path: string;

  /** Fully-qualified handler, e.g. "controllers::user::show". */
  handler: string;

  /** Optional named route, e.g. "users.show". `null` if unnamed. */
  name: string | null;

  /**
   * Middleware names attached to this route. Always present.
   *
   * Phase 124 always emits `[]` — middleware parsing from `routes.rs` is
   * future work. The field is part of the stable contract so consumers can
   * rely on its presence today.
   */
  middleware: string[];
}

Field stability

FieldStabilityNotes
routesstableTop-level array.
methodstableAlways uppercase.
pathstable{param} placeholders preserved verbatim.
handlerstablemodule::fn joined with ::.
namestablenull when no .name(...) call on route.
middlewarestable, partialCurrently always []; populated in a later phase.

Path parameters are intentionally not emitted as a separate field — consumers parse them from path themselves. Form-request bodies and TypeScript-only artifacts are out of scope for the JSON contract.

Example

A project with two routes:

#![allow(unused)]
fn main() {
get!("/users", controllers::user::index).name("users.index");
get!("/users/{id}", controllers::user::show).name("users.show");
}

Produces:

{
  "routes": [
    {
      "method": "GET",
      "path": "/users",
      "handler": "controllers::user::index",
      "name": "users.index",
      "middleware": []
    },
    {
      "method": "GET",
      "path": "/users/{id}",
      "handler": "controllers::user::show",
      "name": "users.show",
      "middleware": []
    }
  ]
}

Consumer hint: ferro-mcp (D-12)

ferro-mcp introspection tools should shell out to ferro generate-routes --json and deserialize the result rather than parsing the human pretty-printed output of ferro routes. The schema above is the contract.