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-mcpfor 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 scaffolding —
ferro 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-mcpto your AI agent and usecode_templatesto scaffold handlers,list_routesto explore your API, andget_handlerto 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
Homebrew (macOS and Linux — recommended)
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:
- Create a new directory
my-app - Initialize a Rust workspace
- Set up the frontend with React and TypeScript
- Configure the database
- 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 your first feature
- Directory Structure - Understand the project layout
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?
- Add validation to user creation
- Implement authentication
- Add middleware for protected routes
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:
-
Orient — Call
application_infoto understand the project: installed crates, configured features, database details, and overall application structure. -
Explore — Use feature-specific tools to drill into the area of interest. Exploring routes:
list_routesthenexplain_route. Understanding data:list_modelsthenexplain_modelthenmodel_usages. Diagnosing a problem:last_errorordiagnose_error. -
Generate — Call
code_templatesorgeneration_contextto 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
-
The agent calls
code_templatesand receives a list of templates. Themodeltemplate entry includes:generation_hint: "Use `ferro make:scaffold <ModelName>` to scaffold a new model with migration" -
The agent reads the hint, identifies the CLI command:
ferro make:scaffold. -
The agent executes the CLI command:
ferro make:scaffold Post -
Ferro generates
src/models/post.rsand a timestamped migration file with the correct SeaORM structure. -
The agent reads the generated file using
get_handleror 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_templates→ferro make:action— scaffold a request handlercode_templates→ferro make:migration— scaffold a database migrationcode_templates→ferro make:job— scaffold a background jobgeneration_contextfor the current route →ferro make:controllerwith 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 Bridge —
ferro-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 artifactsnode_modules/- Node.js dependenciesfrontend/dist/- Built frontend assets
Storage
The storage/ directory holds application files:
# Create public storage symlink
ferro storage:link
This links public/storage → storage/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:
| Method | URI | Handler |
|---|---|---|
| GET | /users | index |
| GET | /users/create | create |
| POST | /users | store |
| GET | /users/:id | show |
| GET | /users/:id/edit | edit |
| PUT | /users/:id | update |
| DELETE | /users/:id | destroy |
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:
- Create
src/middleware/auth.rswith a middleware stub - Update
src/middleware/mod.rsto 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 requestnext: A function to call the next middleware in the chain (or the route handler)
You can:
- Continue the chain: Call
next(request).awaitto 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:
- Global middleware (in registration order)
- Route group middleware
- Route-level middleware
- 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
| Header | Default Value | Purpose |
|---|---|---|
| X-Content-Type-Options | nosniff | Prevents MIME-type sniffing |
| X-Frame-Options | DENY | Prevents clickjacking |
| Content-Security-Policy | default-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-Policy | strict-origin-when-cross-origin | Controls referer header |
| Permissions-Policy | geolocation=(), camera=(), microphone=() | Restricts browser features |
| Cross-Origin-Opener-Policy | same-origin | Isolates browsing context |
| X-XSS-Protection | 0 | Disables 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
| Feature | Usage |
|---|---|
| Create middleware | Implement Middleware trait |
| Global middleware | global_middleware!(MyMiddleware) in bootstrap.rs |
| Route middleware | .middleware(MyMiddleware) on route definition |
| Group middleware | .middleware(MyMiddleware) on route group |
| Short-circuit | Return Err(HttpResponse::...) without calling next() |
| Continue chain | Call 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:
- Automatic parameter extraction from path, query, body
- Dependency injection for services
- 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 headersInertia::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:
| Method | Handler | Description |
|---|---|---|
| GET /resources | index | List all |
| GET /resources/create | create | Show create form |
| POST /resources | store | Create new |
| GET /resources/:id | show | Show single |
| GET /resources/:id/edit | edit | Show edit form |
| PUT /resources/:id | update | Update |
| DELETE /resources/:id | destroy | Delete |
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_to— required. 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 yourroutes.rsas 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 configuredredirect_toon 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_actionflash 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
- Controllers — for the routing layer that mounts handlers.
- Request & Response — for non-redirecting response types.
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:
| Field | Type | Source |
|---|---|---|
key | &str | the key argument |
cumulative_bytes | usize | running total for key within the request |
threshold_bytes | usize | the configured threshold |
fallback_url | &str | the fallback_url argument |
route_pattern | String | req.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:
| Convention | Example | When 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) | None | global 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 listenersSend + Sync + 'static- For async safetyEventtrait - 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
| Method | Description | Default |
|---|---|---|
handle(&self, event) | Process the event | Required |
name(&self) | Listener identifier | Type name |
should_stop_propagation(&self) | Stop other listeners | false |
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
Ergonomic API (Recommended)
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
- Keep events immutable - Events are data, not behavior
- Use descriptive names - Past tense for things that happened (OrderPlaced, UserRegistered)
- Include all needed data - Listeners shouldn't need to fetch additional data
- Queue heavy operations - Use
ShouldQueuefor emails, PDFs, external APIs - Handle failures gracefully - Listeners should not break on individual failures
- 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:
- Postgres —
SELECT … FOR UPDATE SKIP LOCKEDinside 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
| Method | Description | Default |
|---|---|---|
handle() | Job execution logic | Required |
name() | Job identifier for logging | Type name |
max_retries() | Retry attempts on failure | 3 |
retry_delay(attempt) | Delay before retry | Full-jitter exponential (see below) |
timeout() | Maximum execution time | 60 seconds |
failed(error) | Called when all retries exhausted | Logs error |
idempotency_key() | Deduplication key on enqueue | None |
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() }; }
| Field | Description | Default |
|---|---|---|
queues | Queue names to process, in priority order | ["default"] |
max_jobs | Maximum concurrent in-flight jobs | 10 |
sleep_duration | Idle poll interval when queue is empty | 1s |
visibility_timeout | Time before a claimed job is reclaimed by the reaper | 300s |
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
pendingwithattempts + 1if they have retries remaining - Parked as
failedif they have exhaustedmax_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 API | New (DB) | Notes |
|---|---|---|
Queue::init(QueueConfig::new(broker_url)) | Queue::register::<J>() in bootstrap; framework auto-inits | Connection injected at bootstrap from the app DB |
Separate worker process / cargo run --bin worker | WorkerLoop auto-started inside Application::run | Single binary, work-stealing |
External broker env vars (HOST, PORT, PASSWORD) | None required | Queue uses the app's DATABASE_URL |
failed_jobs table | jobs WHERE status='failed' | Single table, error recorded inline |
2^attempt fixed backoff | Full-jitter exponential default | Override via Job::retry_delay |
| No deduplication hook | Job::idempotency_key() | Dedup on enqueue when Some |
QueueConnection type | Removed | Queue::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:
| Job | Old registration | New registration |
|---|---|---|
RenderDocumentPdfJob | worker.register::<RenderDocumentPdfJob>() in worker binary | Queue::register::<RenderDocumentPdfJob>() in bootstrap |
SendBookingReminderJob | worker.register::<SendBookingReminderJob>() in worker binary | Queue::register::<SendBookingReminderJob>() in bootstrap |
DeliverNotificationJob | worker.register::<DeliverNotificationJob>() in worker binary | Queue::register::<DeliverNotificationJob>() in bootstrap |
screenshot_worker | separate process binary | Queue::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
| Method | Description | Default |
|---|---|---|
via() | Channels to send through | Required |
to_mail() | Mail message content | None |
to_database() | Database message content | None |
to_slack() | Slack message content | None |
notification_type() | Type name for logging | Type 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
| Method | Description | Default |
|---|---|---|
route_notification_for(channel) | Get routing info per channel | Required |
notifiable_id() | Unique identifier | "unknown" |
notifiable_type() | Type name | Type name |
notify(notification) | Send a notification | Provided |
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Variable | Description | Default |
|---|---|---|
MAIL_DRIVER | Mail transport driver | smtp |
SMTP (when MAIL_DRIVER=smtp) | ||
MAIL_HOST | SMTP server host | Required |
MAIL_PORT | SMTP server port | 587 |
MAIL_USERNAME | SMTP username | - |
MAIL_PASSWORD | SMTP password | - |
MAIL_ENCRYPTION | "tls" or "none" | tls |
Resend (when MAIL_DRIVER=resend) | ||
RESEND_API_KEY | Resend API key | Required |
| Shared | ||
MAIL_FROM_ADDRESS | Default from email | Required |
MAIL_FROM_NAME | Default from name | - |
SLACK_WEBHOOK_URL | Slack incoming webhook | - |
Best Practices
- Use descriptive notification names -
OrderShippednotNotification1 - Include all needed data - Pass everything the notification needs
- Keep notifications focused - One notification per event
- Use database for in-app - Combine with UI notification center
- Handle failures gracefully - Log errors, don't crash on send failures
- 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
-
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); } -
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 returnsOk(())—ferro-whatsappis never touched, soWhatsApp::initis not required. -
Implement
Notifiableto 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, } } } } -
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:
- Persistence — written via your
DatabaseNotificationStoreimplementation - Real-time fanout — published via
ferro-broadcastto channeluser.{notifiable_id}with eventNotification.{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'sChannelAuthorizerto 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/mixedemail with oneSinglePartper attachment whenattachmentsis 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:
| Type | Prefix | Authorization | Use Case |
|---|---|---|---|
| Public | none | No | News feeds, global notifications |
| Private | private- | Yes | User-specific data, order updates |
| Presence | presence- | Yes | Online 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:
- Verifies the user is authenticated via session (
Auth::id()) - Receives
channel_nameandsocket_idfrom the client - Calls
Broadcaster::check_auth()with the user's ID as the auth token - Returns 200 with auth confirmation if authorized, 401 if unauthenticated, 403 if unauthorized
- For presence channels, includes
channel_datawithuser_id
Private Channel Auth Flow
The full authorization flow for private and presence channels:
- Client connects to
ws://host/_ferro/wsand receives asocket_id - Client sends HTTP POST to
/broadcasting/authwithchannel_nameandsocket_id - Server validates session auth and calls the registered
ChannelAuthorizer - If authorized, client receives auth confirmation
- Client sends a
subscribemessage over WebSocket with theauthtoken - 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-- includesuser_idanduser_infomember_removed-- includesuser_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
| Variable | Description | Default |
|---|---|---|
BROADCAST_MAX_SUBSCRIBERS | Max subscribers per channel (0 = unlimited) | 0 |
BROADCAST_MAX_CHANNELS | Max total channels (0 = unlimited) | 0 |
BROADCAST_HEARTBEAT_INTERVAL | Heartbeat interval in seconds | 30 |
BROADCAST_CLIENT_TIMEOUT | Client timeout in seconds (disconnect if no activity) | 60 |
BROADCAST_ALLOW_CLIENT_EVENTS | Allow 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 var | Replacement |
|---|---|
AWS_CDN_URL | CDN_URL |
BUNNY_CDN_URL | CDN_URL + CDN_PROVIDER=bunny |
CF_CDN_URL | CDN_URL + CDN_PROVIDER=cloudflare |
DO_SPACES_CDN_ID | CDN_PURGE_ZONE |
CF_ZONE_ID | CDN_PURGE_ZONE |
DIGITALOCEAN_ACCESS_TOKEN | CDN_PURGE_TOKEN |
CF_API_TOKEN | CDN_PURGE_TOKEN |
BUNNY_ACCESS_KEY | CDN_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_ZONEis unset,purge()is a logged no-op that returnsOk(()). 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
| Variable | Description | Default |
|---|---|---|
FILESYSTEM_DISK | Default disk name | local |
FILESYSTEM_LOCAL_ROOT | Local disk root path | ./storage |
FILESYSTEM_LOCAL_URL | Local disk URL base | - |
FILESYSTEM_PUBLIC_ROOT | Public disk root path | ./storage/public |
FILESYSTEM_PUBLIC_URL | Public disk URL base | /storage |
AWS_ACCESS_KEY_ID | S3 access key | - |
AWS_SECRET_ACCESS_KEY | S3 secret key | - |
AWS_DEFAULT_REGION | S3 region | us-east-1 |
AWS_BUCKET | S3 bucket name | - |
AWS_URL | S3 URL base | - |
Best Practices
- Use meaningful disk names -
public,uploads,backupsinstead ofdisk1 - Set appropriate visibility - Use private for sensitive files
- Organize files by date -
uploads/2024/01/file.pdfprevents directory bloat - Use the public disk for web assets - Images, CSS, JS that need URLs
- Use memory driver for tests - Fast and isolated testing
- Clean up temporary files - Delete files that are no longer needed
- 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
| Variable | Description | Default |
|---|---|---|
CACHE_DRIVER | Cache backend ("memory" or "redis") | memory |
CACHE_PREFIX | Key prefix for all entries | - |
CACHE_TTL | Default TTL in seconds | 3600 |
CACHE_MEMORY_CAPACITY | Max entries for memory store | 10000 |
REDIS_URL | Redis connection URL | redis://127.0.0.1:6379 |
Best Practices
- Use meaningful cache keys -
user:123:profilenotkey1 - Set appropriate TTLs - Balance freshness vs performance
- Use tags for related data - Makes invalidation easier
- Cache at the right level - Cache complete objects, not fragments
- Handle cache misses gracefully - Always have a fallback
- Use remember pattern - Cleaner code, less boilerplate
- Prefix keys in production - Avoid collisions between environments
- 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
emailandpasswordfields Authenticatableimplementation on your User modelDatabaseUserProviderfor user retrievalAuthControllerwith register, login, and logout handlers- Routes with auth/guest middleware
Configuration
Session
Authentication state is stored in server-side sessions. Configure via environment variables:
| Variable | Default | Description |
|---|---|---|
SESSION_LIFETIME | 120 | Idle timeout in minutes (expires after inactivity) |
SESSION_ABSOLUTE_LIFETIME | 43200 | Absolute timeout in minutes (30 days; expires regardless of activity) |
SESSION_COOKIE | ferro_session | Cookie name |
SESSION_SECURE | true | HTTPS-only cookies |
SESSION_PATH | / | Cookie path |
SESSION_SAME_SITE | Lax | SameSite 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 Level | Idle Timeout | Absolute Timeout |
|---|---|---|
| Standard web app | 30-60 min | 4-8 hours |
| Financial/medical | 5-15 min | 1-2 hours |
| Framework default | 120 min | 30 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
| Method | Default | Description |
|---|---|---|
auth_identifier(&self) -> i64 | Required | Returns the user's unique ID (primary key) |
auth_identifier_name(&self) -> &'static str | "id" | Column name for the identifier |
as_any(&self) -> &dyn Any | Required | Enables 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
| Method | Required | Description |
|---|---|---|
retrieve_by_id(id) | Yes | Load user by primary key |
retrieve_by_credentials(credentials) | No | Load user by arbitrary credentials (e.g., email lookup) |
validate_credentials(user, credentials) | No | Check 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
| Method | Returns | Description |
|---|---|---|
Auth::check() | bool | true if authenticated |
Auth::guest() | bool | true 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 toT-- useuser.name, notuser.0.nameOptionalUser<T>derefs toOption<T>-- useuser.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
| Function | Description |
|---|---|
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::Response | 302-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:
| Protection | Mechanism |
|---|---|
| Session fixation | Session ID regenerated on Auth::login() |
| CSRF | Token regenerated on login and logout |
| Timing attacks | Bcrypt uses constant-time comparison |
| Cookie theft | HttpOnly flag set by default (not accessible via JavaScript) |
| Cross-site requests | SameSite=Lax cookie attribute by default |
| HTTPS enforcement | Secure cookie flag on by default |
| Session hijacking | Database-backed sessions with configurable lifetime |
| Session expiry | Dual idle + absolute timeouts per OWASP |
| Session invalidation | Direct DB deletion on password change via Auth::logout_other_devices() |
| Password storage | Bcrypt 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
| Variable | Description |
|---|---|
MCP_TOKEN_SECRET | HMAC-SHA256 secret for signing JWTs. Required. |
APP_URL | Base URL used in discovery metadata and token audience. Required. |
Authorization-Code Flow
The standard browser-based OAuth 2.1 flow with PKCE:
- Client registers via
POST /register(Dynamic Client Registration, RFC 7591). - Client redirects the browser to
GET /authorizewithresponse_type=code,client_id,redirect_uri,code_challenge(S256), andstate. - 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_redirectto return to the authorize endpoint. - Authenticated user sees a consent page; on approval an authorization code is issued
and the browser is redirected to
redirect_uriwithcodeandstate. - Client exchanges the code at
POST /tokenwithgrant_type=authorization_codeandcode_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
}
| Field | Description |
|---|---|
device_code | Opaque polling credential. Never shown to the user. |
user_code | Short human-typeable code (XXXX-XXXX, RFC 8628 §6.1 charset). |
verification_uri | URL the user opens on any browser to complete authorization. |
verification_uri_complete | Same URL with user_code pre-filled (suitable for QR codes). |
expires_in | Grant TTL in seconds (600 s / 10 min). |
interval | Minimum 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:
- If unauthenticated: redirected to the app login page via the Phase 202 resume contract;
returns to
/deviceautomatically after authentication. - Enters or confirms the
user_code. - Sees the consent page (same approve/deny UI as the authorization-code flow).
- On approval:
user_idandtenant_idare captured from the session and bound to the grant. The device receives anaccess_tokenon 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):
| HTTP | error | Meaning |
|---|---|---|
| 200 | — | Token issued; access_token, token_type, expires_in present. |
| 400 | authorization_pending | User has not yet approved. Continue polling. |
| 400 | slow_down | Poll interval too short; add 5 s to the current interval. |
| 400 | access_denied | User denied the request. Stop polling. |
| 400 | expired_token | Grant 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_idis captured from the session at consent/approval time, not from form input. TheTenantFailureMode::Allowmode 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_codeis a 256-bit URL-safe random string (same entropy as PKCE authorization codes).user_codeuses the RFC 8628 §6.1 unambiguous consonant charset (BCDFGHJKLMNPQRSTVWXZ) and is accepted case-insensitively with or without the hyphen. - Rate limiting: The polling
slow_downresponse enforces a minimum interval between polls. Additional rate limiting onPOST /device_authorizationis 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 shape | Branch |
|---|---|
Starts with ferro_ | API-key validation via validate_api_key |
| Anything else | JWT validation via validate_bearer |
| Header absent | BearerCheck::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.
| Scope | tools/list | tools/call (read tool) | tools/call (write tool) |
|---|---|---|---|
read | read tools only | allowed | rejected (-32603) |
read_write | all tools | allowed | allowed |
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_tenantagainst thetenant_idcolumn; a key issued to tenant A returnsBearerCheck::Forbiddenif presented on a tenant-B-scoped request. - Scope re-check at dispatch.
tools/callre-evaluates the key scope before dispatching any write tool, so atools/listfilter 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:
| Tool | Derived from | Kind |
|---|---|---|
list_order | the projection's fields | read |
submit, approve, ship | each ActionDef | write |
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 toNoneand the call fails.tenant_idcomes 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_abilityvia the appGate; the natural-language read path enforces the same ability as the direct path. - Untrusted classification —
/mcp/chatarguments enter the identical validation and dispatch pipeline as any direct call; classification is an entry point, not a trust shortcut.
See also
- MCP OAuth Authorization Server — the browser authorization-code and device flows.
- MCP Per-Tenant API-Key Auth — the
ferro_key path for headless agents. - Multi-Tenancy — the tenant context the dispatch paths read.
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:
TenantMiddleware— resolves the tenant from the incoming request using a pluggable resolver strategy.TenantContext— the resolved tenant data (id, slug, name, plan), available as a handler parameter viaFromRequest.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: 2meansyourapp.comis the base — the first segment before it is the slug.- For
app.yourapp.com(3 parts), usebase_domain_parts: 2andapp.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:
| Variant | HTTP Status | When to use |
|---|---|---|
NotFound | 404 | Standard SaaS apps — unknown tenants don't exist |
Forbidden | 403 | Apps 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
| Attribute | Effect | Example |
|---|---|---|
#[resource(skip)] | Exclude field from JSON output | Passwords, internal tokens |
#[resource(rename = "display_name")] | Use a different key in JSON | API 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
| Method | Description |
|---|---|
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"}} }
| Method | Output 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> }
| Constructor | Output |
|---|---|
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:
- Add
mod api;tosrc/main.rsorsrc/lib.rs - Register
api_routes()in your route configuration - Register
docs_routes()for API documentation - Register
ApiKeyProviderImplas a service:App::bind::<dyn ApiKeyProvider>(Box::new(ApiKeyProviderImpl)); - Run
ferro db:migrateto create theapi_keystable - Generate your first API key programmatically
Generated Files
ferro make:api generates the following files for each model:
| File | Purpose |
|---|---|
src/api/{model}_api.rs | CRUD controller with index, show, store, update, destroy handlers |
src/resources/{model}_resource.rs | API resource with Resource trait implementation and From<Model> conversion |
src/requests/{model}_request.rs | Create{Model}Request and Update{Model}Request with validation |
Infrastructure files (generated once):
| File | Purpose |
|---|---|
src/api/mod.rs | Module declarations for all API controllers |
src/api/routes.rs | Route group with ApiKeyMiddleware and Throttle middleware |
src/api/docs.rs | OpenAPI JSON and ReDoc HTML handlers |
src/models/api_key.rs | SeaORM entity for the api_keys table |
src/providers/api_key_provider.rs | ApiKeyProvider implementation with revocation and expiry checks |
src/migrations/m*_create_api_keys_table.rs | Migration 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 keysfe_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:
| Column | Type | Description |
|---|---|---|
id | BIGINT PK | Auto-increment identifier |
name | VARCHAR | Human-readable label (e.g., "Production Bot") |
prefix | VARCHAR(16) | First 16 characters for indexed lookup |
hashed_key | VARCHAR(64) | SHA-256 hex digest |
scopes | TEXT NULL | JSON array of permission scopes |
last_used_at | TIMESTAMPTZ NULL | Last request timestamp |
expires_at | TIMESTAMPTZ NULL | Expiration timestamp |
revoked_at | TIMESTAMPTZ NULL | Revocation timestamp |
created_at | TIMESTAMPTZ | Creation timestamp |
An index on prefix enables fast key lookup.
Verification Flow
- Extract
Bearer {key}from theAuthorizationheader - Look up the key record by prefix (first 16 characters)
- Check revocation (
revoked_at IS NULL) - Check expiry (
expires_atnot passed) - Constant-time SHA-256 hash comparison via
subtle::ConstantTimeEq - Check required scopes against granted scopes
- Store
ApiKeyInfoin 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:
| Flag | Description | Default |
|---|---|---|
--env | Key environment: live or test | live |
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_passwordsecret,token,api_key,hashed_keyremember_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:
- Server connectivity -- can the CLI reach your server?
- Spec available -- does
/api/openapi.jsonreturn a response? - Spec valid -- is the response a valid OpenAPI 3.x document?
- 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:
| Endpoint | Description |
|---|---|
/api/docs | Interactive ReDoc UI |
/api/openapi.json | Raw OpenAPI specification |
How Specs Are Built
The OpenAPI spec builder reads from the Ferro route registry:
- Filters routes matching the
/api/prefix - Generates operations with auto-summaries (e.g.,
GET /api/v1/usersbecomes "List users") - Extracts path parameters from
{param}patterns - Groups endpoints by resource name as tags
- Adds API key security scheme (
Authorizationheader)
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:
- Parse model metadata via syn AST visitor (same pattern as
ferro make:api) - Validate field names against the model struct definition
- Build parameterized SQL using
sea_orm::Statement::from_sql_and_values - Execute against the project's configured database
- 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
| Protection | Mechanism |
|---|---|
| Key storage | SHA-256 hash only; raw key never persisted |
| Timing attacks | Constant-time comparison via subtle::ConstantTimeEq |
| Key rotation | Revocation via revoked_at timestamp |
| Key expiry | expires_at checked on every request |
| SQL injection | Parameterized queries in all CRUD operations |
| Rate limiting | Per-key or per-IP throttling with configurable windows |
| Scope enforcement | Middleware-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:
-
Scaffold the API:
ferro make:api --all -
Wire routes in
src/main.rs:#![allow(unused)] fn main() { mod api; // In route registration: api::routes::api_routes() api::docs::docs_routes() } -
Run the migration:
ferro db:migrate -
Generate an API key:
ferro make:api-key "My Key"Save the raw key -- it is shown only once.
-
Start the server:
cargo run -
Verify the setup:
ferro api:check --api-key fe_live_... -
Add MCP config to your AI agent (see MCP Host Configuration below).
How It Works
- Reads the OpenAPI spec from your Ferro app's
/api/docs/openapi.jsonendpoint - Converts each API operation into an MCP tool with typed input schemas
- Runs as a stdio MCP server that AI agents connect to
- Supports
x-mcpvendor extensions for customizing tool names, descriptions, hints, and visibility
Prerequisites
- A Ferro app with
make:apiscaffold (see REST API) - The API running and accessible (e.g.,
ferro serveon localhost:8080) - An API key generated via
ferro make:apisetup
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.
| Extension | Effect |
|---|---|
x-mcp-tool-name | AI-friendly snake_case tool name (e.g., list_users) |
x-mcp-description | AI-optimized description for the tool |
x-mcp-hint | Usage hint appended to tool description |
x-mcp-hidden | Set 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
| Method | Effect | When to Use |
|---|---|---|
.mcp_tool_name("name") | Override auto-generated tool name | When the default name is unclear (e.g., store_user -> create_user_account) |
.mcp_description("desc") | Override auto-generated description | When the default summary needs more context for AI agents |
.mcp_hint("hint") | Append hint text to description | To guide AI agents on parameter usage or expected behavior |
.mcp_hidden() | Exclude route from MCP tools | For 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
| Problem | Cause | Solution |
|---|---|---|
| "Cannot connect to {url}" | API server not running | Start the server with ferro serve |
| "HTTP 401" on tool calls | Missing or invalid API key | Check --api-key matches a key in the database |
| "HTTP 404" on tool calls | Endpoint does not exist | Verify the API is running and the spec is current |
| "request timed out" | API slow or network issue | Check server logs, verify connectivity |
| "spec parsed but 0 operations" | Empty or malformed spec | Check /api/docs/openapi.json manually |
| "unsupported OpenAPI version" | Spec is not 3.0.x | ferro-api-mcp requires OpenAPI 3.0.x |
| Tool arguments rejected | Missing required fields | Check tool input schema for required params |
Base URL Resolution
ferro-api-mcp resolves the API base URL in this order:
--base-urlflag (explicit override)servers[0].urlfrom the OpenAPI spec- 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
| Method | Window |
|---|---|
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:
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests allowed in the window |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Reset | Seconds until the current window resets |
When a request is rejected (429), an additional header is included:
| Header | Description |
|---|---|
Retry-After | Seconds 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.
| Setup | Configuration |
|---|---|
| Single server (default) | No configuration needed. Uses in-memory cache. |
| Multi-server | Set 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
- Route parameter
{user}is extracted from the URL - The parameter value is parsed as the model's primary key type
- The model is fetched from the database
- If not found, a 404 response is returned automatically
- 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
| Variable | Description | Default |
|---|---|---|
DATABASE_URL | Database connection URL | sqlite://./database.db |
DB_MAX_CONNECTIONS | Maximum pool connections | 10 |
DB_MIN_CONNECTIONS | Minimum pool connections | 1 |
DB_CONNECT_TIMEOUT | Connection timeout (seconds) | 30 |
DB_LOGGING | Enable SQL query logging | false |
Supported Databases
| Database | URL Format | Notes |
|---|---|---|
| PostgreSQL | postgres://user:pass@host:5432/db | Recommended for production |
| SQLite | sqlite://./path/to/db.sqlite | Great for development |
| SQLite (memory) | sqlite::memory: | For testing |
Best Practices
- Use migrations - Never modify database schema manually
- Implement Model traits - Get convenient static methods for free
- Use QueryBuilder - Cleaner API than raw SeaORM queries
- Leverage route binding - Automatic 404 handling for missing models
- Test with test_database! - Isolated, repeatable tests
- Use dependency injection - Cleaner code with
#[inject] db: Database - Enable logging in development -
DB_LOGGING=truefor debugging - 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
Relationenums - 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):
| Outcome | exec_one | exec_at_most_one |
|---|---|---|
| 1 row matched | Ok(()) | Ok(true) |
| 0 rows matched | Err(NoRowsAffected) | Ok(false) |
| >1 rows matched | Err(TooManyRows { affected }) | Err(TooManyRows { affected }) |
| DB error | Err(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
| Variant | When | Notes |
|---|---|---|
NoRowsAffected | Predicate matched zero rows | The operative "capacity exhausted" / "pre-condition unmet" signal for exec_one |
TooManyRows { affected } | Predicate matched more than one row | Indicates the filter is not unique-key-equivalent — typically an index or uniqueness bug at the call site |
EmptyUpdate | exec_* called with no set_* calls | Programming error; surfaces immediately without touching the database |
Db(sea_orm::DbErr) | Underlying SeaORM error | Connection 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)
| Method | Effect |
|---|---|
.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).await | Persist the entry and return the AuditEntry |
Query helpers
| Helper | Returns | Order |
|---|---|---|
history_for_target(&target, &conn) | Vec<AuditEntry> for the target | created_at ASC |
recent_by_actor(&actor, &conn, limit) | Vec<AuditEntry> for the actor | created_at DESC |
recent(&conn, limit) | Vec<AuditEntry> globally | created_at DESC |
For pagination or custom filters, drop down to SeaORM directly via the re-exported AuditLogEntity.
Retention
| Helper | Effect |
|---|---|
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:
| Variant | DB actor_kind | DB 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:
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | UUID | NO | Client-generated UUIDv4 at write time |
tenant_id | VARCHAR | YES | Multi-tenant scoping |
actor_kind | VARCHAR | NO | snake_case enum variant name |
actor_id | VARCHAR | YES | NULL for System / Anonymous |
action | VARCHAR | NO | Required verb (dotted namespace) |
target_kind | VARCHAR | YES | NULL for pure events |
target_id | VARCHAR | YES | NULL for pure events |
before | JSON | YES | Pre-state snapshot |
after | JSON | YES | Post-state snapshot |
reason | VARCHAR | YES | Free-text cause |
correlation_id | UUID | YES | Caller-supplied request correlation |
created_at | TIMESTAMP | NO | DB-stamped via DEFAULT CURRENT_TIMESTAMP |
Indexes:
| Name | Columns |
|---|---|
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:
| Variant | Display | When |
|---|---|---|
MissingAction | audit: action is required | write() 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>;
}
Keyidentifies a resource instance (ProductId,ShowId,ApiClientId, ...).Windowscopes capacity (a date+time range for booking,()for non-windowed resources).KINDis a&'static strdotted-namespace constant — convention mirrorsferro-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
| Method | Transition | Returns |
|---|---|---|
hold(&conn, key, window, qty, ttl, ctx) | (none) → held | ReservationHandle |
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:
- Call
R::capacity(&conn, &key, &window). - Call
R::held(&conn, &key, &window). - If
held + quantity > capacity→Err(Insufficient { requested, available, capacity }). - INSERT one
reservationsrow withstatus = 'held',expires_at = now() + ttl. - Write one
AuditEntrywithaction = "reservation.held"viaferro-audit. - Emit
ReservationEvent::Heldviaferro-events. - 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:
| Variant | When |
|---|---|
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
-
Audit failure does not roll back state. If
AuditEntry::writefails for any reason (database connection lost,audit_logtable missing), the kernel returnsReservationError::AuditAFTER the DB row has already been transitioned. The state change is committed; only the audit record is missing. Monitor forReservationError::Auditin your error-handling layer. -
Event dispatch is best-effort.
ferro_events::dispatchfailure logs attracing::warn!but does not propagate. Use the audit log for durable replay of state transitions. -
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 callingextend.
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 fields — clear_<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
| Method | Returns | Description |
|---|---|---|
Entity::create() | CreateBuilder | Builder for inserting a new row |
instance.update() | UpdateBuilder | Builder for selectively updating fields |
instance.delete() | Result | Deletes 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
| Rule | Description | Example |
|---|---|---|
required | Field must be present and non-empty | #[rule(required)] |
email | Must be a valid email address | #[rule(required, email)] |
string | Must be a string value | #[rule(string)] |
integer | Must 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))] |
url | Must be a valid URL | #[rule(url)] |
nullable | Field 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
| Rule | Description | Example |
|---|---|---|
required() | Field must be present and not empty | required() |
required_if(field, value) | Required if another field equals value | required_if("type", "business") |
string() | Must be a string | string() |
integer() | Must be an integer | integer() |
numeric() | Must be numeric | numeric() |
boolean() | Must be a boolean | boolean() |
array() | Must be an array | array() |
min(n) | Minimum length/value | min(8) |
max(n) | Maximum length/value | max(255) |
between(min, max) | Value between min and max | between(1, 100) |
email() | Valid email format | email() |
url() | Valid URL format | url() |
regex(pattern) | Matches regex pattern | regex(r"^\d{5}$") |
alpha() | Only alphabetic characters | alpha() |
alpha_num() | Only alphanumeric | alpha_num() |
alpha_dash() | Alphanumeric, dashes, underscores | alpha_dash() |
date() | Valid date (YYYY-MM-DD) | date() |
confirmed() | Must match {field}_confirmation | confirmed() |
in_array(values) | Must be one of values | in_array(vec!["a", "b"]) |
not_in(values) | Must not be one of values | not_in(vec!["x", "y"]) |
different(field) | Must differ from field | different("old_email") |
same(field) | Must match field | same("password") |
nullable() | Can be null (stops if null) | nullable() |
accepted() | Must be "yes", "on", "1", true | accepted() |
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
- Use Form Requests for complex validation - Keeps controllers clean
- Provide custom messages - User-friendly error messages improve UX
- Use custom attributes - Replace technical field names with readable ones
- Validate early - Fail fast with clear error messages
- Use nullable() for optional fields - Prevents errors on missing optional data
- Create custom rules - Reuse validation logic across the application
- Return 422 status - Standard HTTP status for validation errors
- 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:
| Variable | Default | Description |
|---|---|---|
APP_LOCALE | en | Default locale |
APP_FALLBACK_LOCALE | en | Fallback when key missing in requested locale |
LANG_PATH | lang | Directory 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:
| Syntax | Meaning |
|---|---|
{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:
?locale=xxquery parameter (explicit override)Accept-Languageheader (first language tag)APP_LOCALEdefault 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:
- Check the requested locale
- 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()andtrans()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
- Use test_database! for isolation - Each test gets a fresh database
- Use factories for test data - Consistent, readable test setup
- Test both success and failure cases - Validate error handling
- Use meaningful test names -
test_user_cannot_access_admin_panel - Keep tests focused - One assertion concept per test
- Use Expect for readable assertions - Fluent API improves clarity
- Mock external services - Use TestContainer to isolate from APIs
- 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 pattern | Cache-Control | Rationale |
|---|---|---|
/assets/* | public, max-age=31536000, immutable | Vite hashed output — content hash in filename means the URL changes when content changes |
| Everything else | public, max-age=0, must-revalidate | Root 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 outsidepublic/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:
- Your controller returns an Inertia response with a component name and props
- On the first request, a full HTML page is rendered with the initial data
- On subsequent requests, only JSON is returned
- 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()orreq.input(), the body is consumed. ButInertia::render()needs request metadata (headers, URL).SavedInertiaContextcaptures 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"
}
}
Links and Navigation
Inertia Link Component
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, ¤t_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-onlymode 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.
Option 1: Manual Type Files (Recommended)
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: trueheader → 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-Inertiaheader - Uses 303 status for POST/PUT/PATCH/DELETE (forces GET on redirect)
- Includes proper
X-Inertia: trueresponse 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
- Use InertiaProps derive - Automatic camelCase conversion matches JavaScript conventions
- Save context before consuming request - Use
SavedInertiaContextfor validation flows - Share common data via middleware - Auth, flash, CSRF in
ShareInertiaData - Organize pages in folders -
Posts/Index.tsx,Posts/Show.tsxfor clarity - Use compile-time validation -
inertia_response!macro catches typos early - Handle version conflicts - Ensure smooth deployments with version checking
- Keep props minimal - Only send what the page needs
- Use partial reloads - Optimize updates by requesting only changed data
- 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
- The spec file at
src/views/*.jsondeclares the element tree, layout, and expressions. - The handler assembles data — no component building in Rust.
JsonUi::render_fileloads 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
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
center | [f64, f64] | No* | — | Map center as [latitude, longitude] |
zoom | number | No | 13 | Zoom level (0–18) |
height | string | No | "400px" | CSS height of the map container |
fit_bounds | boolean | No | — | Auto-zoom to fit all markers. When true, center/zoom are ignored if markers exist |
markers | array | No | [] | Markers to display |
tile_url | string | No | OpenStreetMap | Custom tile layer URL template |
attribution | string | No | OSM attribution | Tile layer attribution text |
max_zoom | number | No | 19 | Maximum zoom level |
*center is optional when fit_bounds is true and markers are provided.
MapMarker
| Field | Type | Required | Description |
|---|---|---|---|
lat | number | Yes | Latitude |
lng | number | Yes | Longitude |
popup | string | No | Plain text popup content |
color | string | No | Hex color for a colored CSS pin (e.g., "#3B82F6") |
popup_html | string | No | HTML popup content (alternative to popup) |
href | string | No | URL 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.comfor scripts andhttps://*.tile.openstreetmap.orgfor 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 functionsrc/stripe/webhook.rs— platform webhook handlersrc/stripe/listeners.rs— subscription sync event listenerssrc/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:
| Status | Meaning |
|---|---|
trialing | Trial period active |
active | Paid and current |
incomplete | First invoice pending |
incomplete_expired | First invoice expired |
past_due | Renewal invoice failed |
canceled | Subscription ended |
unpaid | Multiple invoice failures |
paused | Collection 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.
- Create the account. Register a connected account for the seller and store
its
acct_xxxid on the tenant record. - Link onboarding. Send the seller through Stripe-hosted onboarding with
account::create_link(account_id, refresh_url, return_url)(see Connect Onboarding). - Persist capabilities on
account.updated. Stripe emitsaccount.updatedas the seller completes onboarding. Persist thecharges_enabled/payouts_enabledcapability 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). - Route the charge with a computed fee. Derive the platform fee from
STRIPE_APPLICATION_FEE_PERCENTviaStripeConfig::application_fee_forand feed it toCheckoutBuilder::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 event | Typed event | Meaning |
|---|---|---|
payment_intent.amount_capturable_updated | StripePaymentIntentAmountCapturableUpdated | Funds authorized and capturable (hold is live) |
payment_intent.canceled | StripePaymentIntentCanceled | Hold 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.canceledevent. Thecancellation_reasonfield onStripePaymentIntentCanceledindicates 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-reservation | Stripe 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
| Route | Purpose | Secret |
|---|---|---|
POST /stripe/webhook | Platform events | STRIPE_WEBHOOK_SECRET |
POST /stripe/connect/webhook | Connect events | STRIPE_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 event | ferro-events Event |
|---|---|
customer.subscription.updated | StripeSubscriptionUpdated |
customer.subscription.deleted | StripeSubscriptionDeleted |
checkout.session.completed | StripeCheckoutCompleted |
invoice.paid | StripeInvoicePaid |
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:
| Field | Type | Description |
|---|---|---|
stripe_subscription_id | String | Stripe subscription ID (sub_xxx) |
plan | String | Plan name: free, pro, enterprise |
status | SubscriptionStatus | Stripe status |
trial_ends_at | Option<DateTime<Utc>> | Trial end timestamp |
cancel_at_period_end | bool | Scheduled for cancellation |
current_period_end | DateTime<Utc> | Billing period end |
stripe_connect_account_id | Option<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
| Variable | Required | Description |
|---|---|---|
STRIPE_SECRET_KEY | Yes | Stripe secret API key (sk_live_xxx or sk_test_xxx) |
STRIPE_WEBHOOK_SECRET | Yes | Platform webhook signing secret (whsec_xxx) |
STRIPE_CONNECT_WEBHOOK_SECRET | No | Connect webhook signing secret |
STRIPE_APPLICATION_FEE_PERCENT | No | Platform 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 functionwebhook.rs— GET challenge verification and POST webhook handlerslisteners.rs— event listener stubs for inbound events
Configuration
Set the following environment variables:
| Variable | Source | Required |
|---|---|---|
WHATSAPP_APP_SECRET | Meta Developer Dashboard → App Settings → Basic → App Secret | Yes |
WHATSAPP_ACCESS_TOKEN | Meta Developer Dashboard → WhatsApp → API Setup → Permanent Token | Yes |
WHATSAPP_PHONE_NUMBER_ID | Meta Developer Dashboard → WhatsApp → API Setup → Phone Number ID | Yes |
WHATSAPP_VERIFY_TOKEN | A secret string you choose for webhook verification | Yes |
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:
WhatsAppRawMessageis the rawferro_whatsapp::Messageenum (re-exported under this name to avoid colliding withferro_notifications::WhatsAppMessage, the notification-system wrapper). UseWhatsAppRawMessagewhen callingWhatsApp::senddirectly; useWhatsAppMessage(with itstext()/template()builders) when implementingNotification::to_whatsappfor 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:
- Set Callback URL to
https://yourdomain.com/whatsapp/webhook - Set Verify Token to the value of
WHATSAPP_VERIFY_TOKEN - Subscribe to
messagesandmessage_statuswebhook fields
Webhook Processing Flow
The generated src/whatsapp/webhook.rs follows this flow:
-
GET
/whatsapp/webhook— Meta sends a challenge to verify the endpoint. The handler checks the verify token and responds withhub.challengeas plain text. -
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
ProcessWhatsAppWebhookjob 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 IDsender_identity: SenderIdentity—Owner(phone)orCustomer(phone)text: String— message bodytimestamp: chrono::DateTime<Utc>— message timestampraw: 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 withSendResult.wamidfromWhatsApp::send()status: DeliveryStatus—Sent,Delivered,Read,Failed, orUnknowntimestamp: 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@themeblock 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)
| Token | Default (light) | Purpose |
|---|---|---|
--color-background | oklch(100% 0 0) | Page background |
--color-surface | oklch(97% 0 0) | Section/panel background |
--color-card | oklch(95% 0 0) | Card component background |
--color-border | oklch(90% 0 0) | Borders and dividers |
--color-text | oklch(15% 0 0) | Primary text |
--color-text-muted | oklch(50% 0 0) | Secondary/placeholder text |
Role Tokens (8)
| Token | Default (light) | Purpose |
|---|---|---|
--color-primary | oklch(55% 0.2 250) | Primary actions, links |
--color-primary-foreground | oklch(100% 0 0) | Text on primary backgrounds |
--color-secondary | oklch(70% 0.05 250) | Secondary actions |
--color-secondary-foreground | oklch(15% 0 0) | Text on secondary backgrounds |
--color-accent | oklch(65% 0.15 200) | Highlights, badges |
--color-destructive | oklch(55% 0.22 25) | Delete, error states |
--color-success | oklch(55% 0.18 145) | Success states |
--color-warning | oklch(70% 0.18 80) | Warning states |
Shape Tokens (4)
| Token | Default | Purpose |
|---|---|---|
--radius-sm | 0.25rem | Small elements (badges, tags) |
--radius-md | 0.375rem | Medium elements (inputs, buttons) |
--radius-lg | 0.5rem | Large elements (cards, modals) |
--radius-full | 9999px | Pill-shaped elements |
Shadow Tokens (3)
| Token | Purpose |
|---|---|
--shadow-sm | Subtle elevation (inputs, dropdowns) |
--shadow-md | Card elevation |
--shadow-lg | Modal/overlay elevation |
Typography Tokens (2)
| Token | Default | Purpose |
|---|---|---|
--font-family-sans | ui-sans-serif, system-ui, sans-serif | Body and UI text |
--font-family-mono | ui-monospace, monospace | Code, 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
- ServiceDef — describe your service: field names, data types, semantic meanings, state machines, guards, and actions.
- derive_intents — analyzes the service definition and returns a ranked list of
IntentScorevalues. The highest-scoring intent is the primary one. - Renderer — takes the service definition, the ranked intents, and a render context, and produces output. The
Renderertrait is modality-agnostic: each implementation declares its ownOutputandContexttypes. 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:
| Method | Description |
|---|---|
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:
| Variant | When to use |
|---|---|
DataType::Integer | Whole numbers: IDs, counts, quantities |
DataType::Float | Decimal numbers: prices, measurements, scores |
DataType::String | Text values: names, titles, codes, descriptions |
DataType::Boolean | True/false flags |
DataType::Date | Calendar date (no time) |
DataType::DateTime | Date plus time |
DataType::Json | Structured payloads stored as JSON |
DataType::Binary | Opaque byte sequences |
DataType::Uuid | UUID identifiers |
DataType::Enum | Fixed set of values: status, category |
FieldMeaning:
| Variant | When to use |
|---|---|
FieldMeaning::Identifier | Primary key or unique ID |
FieldMeaning::ForeignKey | Reference to another record's identifier |
FieldMeaning::EntityName | Display name of the record |
FieldMeaning::Email | Email address |
FieldMeaning::Phone | Phone number |
FieldMeaning::Url | Web URL |
FieldMeaning::ImageUrl | Image URL or path |
FieldMeaning::Money | Monetary amount |
FieldMeaning::Percentage | Percentage value |
FieldMeaning::Quantity | Aggregate count or numeric quantity |
FieldMeaning::Status | Current state or lifecycle value |
FieldMeaning::Category | Categorical tag or grouping |
FieldMeaning::Boolean | Yes/no flag |
FieldMeaning::FreeText | Long descriptive text |
FieldMeaning::CreatedAt / FieldMeaning::UpdatedAt / FieldMeaning::DateTime | Timestamp fields |
FieldMeaning::Sensitive | Sensitive 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 variant | Effect in non-visual output |
|---|---|
RenderHint::AltText(String) | Render the given string in place of the raw URL/image value |
RenderHint::Skip | Omit 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:
- Field count — many fields suggest a form (Collect); few fields suggest a list (Browse).
- Field meanings — presence of
Money,Status,Quantitymeanings shifts scores toward specific intents. - State machines — a state machine with transitions strongly scores Process.
- Guards and actions — approval workflows score Track; rich action sets score Process.
- Naming patterns — service name patterns like "report", "summary", "dashboard" shift scores toward Summarize or Analyze.
The seven intents:
| Intent | Structural signal |
|---|---|
Intent::Browse | List of records with identifier and name fields |
Intent::Focus | Single record detail view |
Intent::Collect | Input form (many fields, writable) |
Intent::Process | Workflow with state machine and transitions |
Intent::Summarize | Aggregated or summary-level data |
Intent::Analyze | Metric-heavy or analytical view |
Intent::Track | Audit 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:
| Field | Type | Description |
|---|---|---|
intent_index | usize | Index into the IntentScore list; 0 for primary intent |
current_state | Option<String> | Active state name from the state machine, if applicable |
mode | RenderMode | RenderMode::Display for read-only; RenderMode::Input for forms |
templates | Option<ThemeTemplates> | Custom layout overrides; None uses defaults |
RenderMode:
| Variant | Output |
|---|---|
RenderMode::Display | Read-only component tree for viewing data |
RenderMode::Input | Editable 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
| Field | Type | Description |
|---|---|---|
intent_index | usize | Index into the IntentScore list; 0 for the primary intent |
current_state | Option<String> | Active workflow state, surfaced by Process/Track output |
evaluated_guards | HashMap<String, bool> | Guard-name → result. Filters action affordances (see below) |
verbosity | Verbosity | Verbosity::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:
| Intent | Output shape |
|---|---|
Browse | Entity name + its identifying fields as a list |
Collect | "Fields to fill in" — the writable inputs, with (required) markers |
Process | Current state + the guard-passing actions available from it |
Summarize | Entity name + a one-line "Key metrics" list |
Track | Current 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
| Type | Description |
|---|---|
ServiceDef | Builder for describing a service's data shape, workflow, and capabilities |
DataType | Enum of storage types for a field (Integer, String, Float, etc.) |
FieldMeaning | Semantic meaning of a field (Identifier, Money, Status, etc.) |
StateMachine | Builder for workflow states and transitions |
StateDef | A single workflow state; call .final_state() to mark terminal states |
Transition | A directed edge between two states, triggered by an action name |
GuardDef | A named permission or condition checked before an action is allowed |
ActionDef | A user-triggerable operation; optionally requires a guard via .precondition() |
Intent | Enum of seven structural intents: Browse, Focus, Collect, Process, Summarize, Analyze, Track |
IntentScore | A ranked intent result with intent, confidence, and matching_signals |
IntentHint | Override directive: Primary(intent) promotes, Exclude(intent) blocks |
derive_intents | Analyzes a ServiceDef and returns a confidence-ranked Vec<IntentScore> |
JsonUiRenderer | Implements Renderer; converts ServiceDef + intents + context to JSON-UI (Output = Spec) |
TextRenderer | Implements Renderer; converts ServiceDef + intents + BaseContext to conversational text (Output = String) |
Renderer | Modality-agnostic trait; one method render(def, intents, ctx) with associated Output/Context types |
VisualContext | Visual render parameters: intent index, current state, mode, template overrides (embeds BaseContext) |
BaseContext | Modality-agnostic render parameters: intent index, current state, evaluated guards, verbosity |
RenderMode | Display for read-only output; Input for editable form output (visual only) |
Verbosity | Full (default, complete render) or Brief (single-line) for non-visual output |
RenderHint | Optional 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:
| Context | URL pattern | Notes |
|---|---|---|
| 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
ServiceDefdefinitions 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
ServiceDefbreakdown: all fields with theirDataTypeandFieldMeaning, state machine states and transitions, guards, actions, and the full rankedIntentScorelist fromderive_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
DisplayvsInputoutput; 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 mismatchedIntentHintdirectives - 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
ServiceDefprojections 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" }
}
NAMEis a dotted-namespace constant:"inventory.dashboard","checkout.cart","orders.recent". Same convention asferro-audit's action namespace.State: Defaultis required so a fresh key initializes fromState::default()on first apply. If a state model has no sensible default, return an empty or zero variant fromDefault.applyis synchronous. It runs inside the per-key Mutex; an asyncapplywould 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
| Method | Use |
|---|---|
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).await | Manual 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
- Broadcast failure does NOT roll back state. If
Broadcast::sendfails (no subscribers, network error), the snapshot row is already persisted; the runtime logs attracing::warn!and returnsProjectionError::Broadcast. Subscribers reconcile by re-reading the snapshot viaruntime.read(...). - 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.
registeris not idempotent onArcidentity. CallingArc<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:
| Variant | When |
|---|---|
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, }; }
| Field | Default | Description |
|---|---|---|
model | claude-sonnet-4-6 | Model ID passed to the provider |
max_tokens | 1024 | Maximum response tokens |
max_retries | 1 | Additional retry attempts on transient errors |
retry_delay | 1s | Delay between retries |
confidence_threshold | 0.7 | Minimum 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_retriesadditional times - Permanent errors (400, 401, 403, 404, 422) are not retried
LowConfidenceis 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:
- Removes the pending action
- Dispatches a
ConfirmationExpiredevent viaferro_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_KEYin 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_confirmationis 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
ServiceDefto inspect or pass to a renderer. - Returns: A
ServiceDefJSON object — the same shapeferro ai:makeproduces. Does NOT write files; use theferro ai:makeCLI command to writesrc/projections/<name>.rs. - Note: Makes a real LLM call (costs tokens, governed by
FERRO_AI_MAX_TOKENS_PER_COMMAND). RequiresFERRO_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 inreadystatus.Error::ArtifactDeleted—artifact_deleted_atis 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
| Variant | Meaning |
|---|---|
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, .htm | Html |
.css | Css |
.js, .mjs | Js |
.jpg, .jpeg | Jpeg |
.png | Png |
.avif | Avif |
| anything else | Other (passthrough) |
Asset.path is a logical artifact key — ferro-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
| Variant | Meaning |
|---|---|
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 theorders_todayfield 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:
| Value | Description |
|---|---|
"dashboard" | Sidebar navigation with header |
"app" | Top navigation bar |
"auth" | Centered card, used for login / register pages |
"" or omit | Minimal 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
| Category | Components |
|---|---|
| Layout | Card, Grid, Tabs, Separator, Modal, Skeleton, Collapsible, FormSection |
| Data Display | Text, DataTable, Table, DescriptionList, Badge, Avatar, Progress, Breadcrumb, Pagination, StatCard, Image, CalendarCell |
| Forms | Form, Input, Select, Checkbox, CheckboxList, CheckboxGroup, Switch, Button, ButtonGroup, DropdownMenu |
| Feedback | Alert, Toast, EmptyState |
| Navigation | Sidebar, Header, PageHeader, NotificationDropdown |
| Action | ActionCard |
| Onboarding | Checklist |
| Commerce | ProductTile |
| Kanban | KanbanBoard, KanbanColumn |
| Extensible | RawHtml, 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.
| Prop | Type | Description |
|---|---|---|
title | string | Card heading |
description | string | null | Secondary text below the title |
subtitle | string | null | Muted secondary identifier rendered between title and description (e.g. staff name beneath customer name) |
badge | string | null | Small 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"
| Value | Classes applied | Padding | Typical use |
|---|---|---|---|
"bordered" | border border-border bg-card shadow-sm overflow-visible | p-4 | Dashboard cards in dense layouts |
"elevated" | bg-card shadow-md overflow-visible (no border) | p-8 | Auth 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.
| Prop | Type | Description |
|---|---|---|
columns | number | null | Number of columns (default: 2) |
gap | gap_size | null | Gap 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.
| Prop | Type | Description |
|---|---|---|
default_tab | string | Value of the initially active tab |
tabs | array | Tab definitions |
Each object in tabs:
| Field | Type | Description |
|---|---|---|
value | string | Tab identifier (matches default_tab) |
label | string | Tab label text |
children | array of strings | Element 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.
| Prop | Type | Description |
|---|---|---|
orientation | orientation | null | "horizontal" (default) or "vertical" |
"divider": {
"type": "Separator",
"props": {}
}
Modal
Dialog overlay with title, body children, footer children, and a trigger button label.
| Prop | Type | Description |
|---|---|---|
title | string | Modal heading |
description | string | null | Modal description text |
trigger_label | string | null | Label 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.
| Prop | Type | Description |
|---|---|---|
width | string | null | CSS width (e.g., "100%", "200px") |
height | string | null | CSS height (e.g., "40px") |
rounded | boolean | null | Use rounded corners |
"loading_placeholder": {
"type": "Skeleton",
"props": {
"width": "100%",
"height": "40px",
"rounded": true
}
}
Collapsible
An expandable/collapsible section with a trigger label.
| Prop | Type | Description |
|---|---|---|
trigger | string | Label for the toggle |
open | boolean | null | Initially 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.
| Prop | Type | Description |
|---|---|---|
title | string | Section heading |
description | string | null | Section 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.
| Prop | Type | Description |
|---|---|---|
content | string | Text content |
element | text_element | null | HTML 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.
| Prop | Type | Description |
|---|---|---|
columns | array | Column definitions (see below) |
data_path | string | JSON Pointer to the row data array (e.g., "/orders") |
row_actions | array | null | Actions available per row |
empty_message | string | null | Message when no data is present |
sortable | boolean | null | Enable column sorting |
sort_column | string | null | Currently sorted column key |
sort_direction | sort_direction | null | "asc" or "desc" |
Each column object:
| Field | Type | Description |
|---|---|---|
key | string | Data field key in the row object |
label | string | Column header text |
format | column_format | null | Display 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.
| Prop | Type | Description |
|---|---|---|
columns | array | Column definitions (same structure as DataTable) |
rows | array | Static 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.
| Prop | Type | Description |
|---|---|---|
items | array | Description items (see below) |
columns | number | null | Number of columns for layout |
Each item object:
| Field | Type | Description |
|---|---|---|
label | string | Item label |
value | string | Item value |
format | column_format | null | Display 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.
| Prop | Type | Description |
|---|---|---|
label | string | Badge text |
variant | badge_variant | null | Visual style (default: "default") |
"status_badge": {
"type": "Badge",
"props": {
"label": "Active",
"variant": "default"
}
}
Avatar
User avatar with image, fallback initials, and size.
| Prop | Type | Description |
|---|---|---|
alt | string | Alt text (required for accessibility) |
src | string | null | Image URL |
fallback | string | null | Fallback initials when no image |
size | size | 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.
| Prop | Type | Description |
|---|---|---|
value | number | Percentage value (0-100) |
max | number | null | Maximum value |
label | string | null | Label text above the bar |
"upload_progress": {
"type": "Progress",
"props": {
"value": 75,
"max": 100,
"label": "Uploading..."
}
}
Breadcrumb
Navigation breadcrumb trail.
| Prop | Type | Description |
|---|---|---|
items | array | Breadcrumb items (see below) |
Each item object:
| Field | Type | Description |
|---|---|---|
label | string | Breadcrumb text |
url | string | null | Link 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.
| Prop | Type | Description |
|---|---|---|
current_page | number | Current page number |
per_page | number | Items per page |
total | number | Total item count |
base_url | string | null | Base 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.
| Prop | Type | Description |
|---|---|---|
label | string | Metric label (e.g., "Total Revenue") |
value | string | Current metric value (e.g., "€12,345") |
icon | string | null | Icon name |
subtitle | string | null | Secondary text below the value |
sse_target | string | null | SSE 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.
| Prop | Type | Description |
|---|---|---|
src | string | Image URL |
alt | string | Alt text |
width | number | null | CSS width in pixels |
height | number | null | CSS height in pixels |
class | string | null | Additional 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.
| Prop | Type | Description |
|---|---|---|
day | number | Day of month (1–31) |
is_today | boolean | null | Highlights the cell as today (default: false) |
is_current_month | boolean | null | Dims the cell when outside the current month (default: false) |
event_count | number | null | Event indicator dot count (default: 0) |
dot_colors | array | null | Per-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.
| Prop | Type | Description |
|---|---|---|
method | string | null | HTTP method override ("GET", "POST", "PUT", "PATCH", "DELETE") |
max_width | form_max_width | null | Max 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.
| Prop | Type | Description |
|---|---|---|
field | string | Form field name |
label | string | Input label |
input_type | input_type | null | Input type (default: "text") |
placeholder | string | null | Placeholder text |
required | boolean | null | Mark as required |
disabled | boolean | null | Disable the field |
error | string | null | Validation error message |
description | string | null | Help text below the input |
default_value | string | null | Pre-filled static value |
data_path | string | null | JSON Pointer for pre-filling from handler data |
step | string | null | HTML 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.
| Prop | Type | Description |
|---|---|---|
field | string | Form field name |
label | string | Select label |
options | array | Option objects: { "value": string, "label": string } |
placeholder | string | null | Placeholder text |
required | boolean | null | Mark as required |
disabled | boolean | null | Disable the field |
error | string | null | Validation error message |
description | string | null | Help text below the select |
default_value | string | null | Pre-selected static value |
data_path | string | null | JSON 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.
| Prop | Type | Description |
|---|---|---|
field | string | Form field name |
label | string | Checkbox label |
description | string | null | Help text below the checkbox |
checked | boolean | null | Default checked state |
data_path | string | null | JSON Pointer for pre-filling from handler data |
required | boolean | null | Mark as required |
disabled | boolean | null | Disable the field |
error | string | null | Validation 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.
| Prop | Type | Description |
|---|---|---|
field | string | Form field name |
label | string | Switch label |
description | string | null | Help text below the switch |
checked | boolean | null | Default checked state |
data_path | string | null | JSON Pointer for pre-filling from handler data |
required | boolean | null | Mark as required |
disabled | boolean | null | Disable the field |
compact | boolean | null | Scale the toggle down (scale-75) for use in dense grid layouts |
error | string | null | Validation error message |
action | Action | null | When 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.
| Prop | Type | Description |
|---|---|---|
field | string | Form field name; each selected checkbox submits as field=value |
options | array | null | Static option list: [{ "value": string, "label": string }] |
options_path | string | null | JSON Pointer to a data array of { "value", "label" } objects (used when options is empty) |
selected_path | string | null | JSON Pointer to a string[] of pre-selected values |
label | string | null | Group label |
description | string | null | Help text below the group |
disabled | boolean | null | Disable all checkboxes |
error | string | null | Validation 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.
| Prop | Type | Description |
|---|---|---|
label | string | Button label |
variant | button_variant | null | Visual style (default: "default") |
size | size | null | Button size (default: "default") |
disabled | boolean | null | Disable the button |
icon | string | null | Icon name |
icon_position | icon_position | null | "left" (default) or "right" |
button_type | string | null | HTML 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.
| Prop | Type | Description |
|---|---|---|
buttons | array | Button 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" }
]
}
}
DropdownMenu
A button that opens a dropdown with action items. Useful for per-row table actions.
| Prop | Type | Description |
|---|---|---|
label | string | Trigger button label |
actions | array | Action items (see below) |
Each action object:
| Field | Type | Description |
|---|---|---|
label | string | Menu item text |
handler | string | Route handler name |
method | string | HTTP method |
variant | string | 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.
| Prop | Type | Description |
|---|---|---|
message | string | Alert message content |
variant | alert_variant | null | Visual style (default: "info") |
title | string | null | Alert 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.
| Prop | Type | Description |
|---|---|---|
message | string | Toast message content |
variant | toast_variant | null | Visual style (default: "info") |
timeout | number | null | Seconds before auto-dismiss (default: 5) |
dismissible | boolean | null | Allow 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.
| Prop | Type | Description |
|---|---|---|
title | string | Empty state heading |
description | string | null | Supporting text |
action_label | string | null | CTA button label |
icon | string | null | Icon 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" }
}
Navigation Components
Sidebar
Sidebar navigation shell with fixed top items, grouped items, and fixed bottom items. Typically used inside the dashboard layout.
| Prop | Type | Description |
|---|---|---|
fixed_top | array | null | Items pinned at the top (e.g., logo/home) |
groups | array | null | Collapsible navigation groups |
fixed_bottom | array | null | Items pinned at the bottom (e.g., settings, logout) |
Navigation item object:
| Field | Type | Description |
|---|---|---|
label | string | Link text |
href | string | Link URL |
icon | string | null | Icon name |
active | boolean | null | Mark as current page |
Navigation group object:
| Field | Type | Description |
|---|---|---|
label | string | Group heading |
collapsed | boolean | null | Start collapsed |
items | array | Navigation 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" }
]
}
}
Header
Application header with business name, user info, notification count, and logout link. Typically used inside the dashboard layout.
| Prop | Type | Description |
|---|---|---|
business_name | string | Application name |
notification_count | number | null | Unread notification count |
user_name | string | null | Current user's name |
user_avatar | string | null | Current user's avatar URL |
logout_url | string | null | Logout 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"
}
}
PageHeader
Page-level header with a title, optional subtitle, optional breadcrumb, and optional action buttons.
| Prop | Type | Description |
|---|---|---|
title | string | Page title |
breadcrumb | array | null | Breadcrumb items (same shape as Breadcrumb items) |
actions | array | null | Element 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 value | Result |
|---|---|
| omitted | empty list |
null | empty 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.
| Prop | Type | Description |
|---|---|---|
notifications | array | Notification items (see below) |
empty_text | string | null | Text when list is empty |
Each notification object:
| Field | Type | Description |
|---|---|---|
text | string | Notification message |
icon | string | null | Icon name |
timestamp | string | null | Human-readable time string |
read | boolean | null | Whether the notification has been read |
action_url | string | null | URL 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.
| Prop | Type | Description |
|---|---|---|
title | string | Card heading |
description | string | null | Supporting text |
icon | string | null | Icon name |
variant | action_card_variant | null | Visual 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.
| Prop | Type | Description |
|---|---|---|
title | string | Checklist heading |
items | array | Checklist items (see below) |
dismissible | boolean | null | Allow dismissal (default: true) |
dismiss_label | string | null | Custom dismiss button label |
data_key | string | null | Server-side state persistence key |
Each item object:
| Field | Type | Description |
|---|---|---|
label | string | Step description |
checked | boolean | null | Whether this step is complete |
href | string | null | Link 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.
| Prop | Type | Description |
|---|---|---|
title | string | Product name |
price | string | Formatted price string (e.g., "€29.00") |
description | string | null | Product description |
image_url | string | null | Product image URL |
badge | string | null | Badge text (e.g., "New", "Sale") |
action_label | string | null | Action 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.
| Prop | Type | Description |
|---|---|---|
columns | array | null | Lane structure — KanbanColumnProps objects (id + title). Always rendered. |
items_path | string | null | JSON Pointer to a flat array of entity objects to bucket into lanes. |
group_by | string | null | Field on each item selecting its lane: column.id == item[group_by]. |
card_title_key | string | null | Item field whose value becomes the card title. |
card_description_key | string | null | Item field whose value becomes the card subtitle. |
row_actions | array | null | Per-card dropdown actions. {row_key} / {id} interpolate from the item. |
row_key | string | null | Item field used for {row_key} substitution in action URLs (defaults to id). |
mobile_default_column | string | null | Lane id selected by default on mobile tab view. |
empty_label | string | null | Placeholder 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.
| Prop | Type | Description |
|---|---|---|
title | string | Column heading |
data_path | string | JSON Pointer to the card data array |
count | number | null | Badge count shown in the column header |
empty_message | string | null | Message 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.
| Prop | Type | Description |
|---|---|---|
html | string | Server-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.
| Prop | Type | Description |
|---|---|---|
sse_url | string | URL of the SSE endpoint that streams tokens |
placeholder | string? | Text shown inside the content area before the first token arrives |
loading_text | string? | 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:
| Field | Type | Description |
|---|---|---|
"title" | string | Dialog 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" }
}
}
Navigation Actions (GET)
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 stringspec.layout— literal stringspec.data— the data source itselfelement.children— always a list of element ID stringselement.action— handler name and method are literalelement.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
fieldprop. - 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 name | Description |
|---|---|
"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:
| Field | Type | Description |
|---|---|---|
title | &str | Page title from the spec "title" field |
content | &str | Rendered element HTML fragment |
head | &str | Additional <head> content (CSS links, meta tags) |
body_class | &str | CSS classes for the <body> element |
scripts | &str | JS 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:
- Renders all elements in the spec
- Collects the plugin type names encountered
- Calls each plugin's
css_assets()andjs_assets()methods - Deduplicates assets by URL (two
Mapelements on the same page load Leaflet once) - Injects CSS
<link>tags into<head>automatically - 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:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
center | [lat, lng] | No | — | Map center coordinates. Optional when fit_bounds is true |
zoom | number | No | 13 | Initial zoom level (0–18) |
height | string | No | "400px" | CSS height of the map container |
fit_bounds | boolean | No | false | Auto-zoom to fit all markers; overrides center/zoom |
markers | array | No | [] | Markers to place on the map |
tile_url | string | No | OpenStreetMap | Custom tile layer URL template |
attribution | string | No | OSM credit | Tile layer attribution string |
max_zoom | number | No | 19 | Maximum zoom level |
Marker object fields:
| Field | Type | Required | Description |
|---|---|---|---|
lat | number | Yes | Latitude |
lng | number | Yes | Longitude |
popup | string | No | Plain text popup on click |
popup_html | string | No | HTML popup content (takes priority over popup) |
color | string | No | Hex color for the marker pin (e.g., "#3B82F6") |
href | string | No | URL 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:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
field | string | Yes | — | Form field name for the hidden input |
label | string | Yes | — | Label text above the editor |
default_value | string | null | No | "" | Initial HTML content (static) |
data_path | string | null | No | — | JSON Pointer to pre-fill the editor from handler data |
error | string | null | No | — | Validation 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
| Attribute | Required | Default | Description |
|---|---|---|---|
data-lazy-hero | yes | — | Opt-in marker. The element must also have preload="none". |
data-lazy-hero-margin | no | 200px 0px | Per-element rootMargin for the IntersectionObserver. Any CSS-margin shorthand the IntersectionObserver constructor accepts. |
data-lazy-hero-promoted | no (runtime sets it) | absent | Idempotency 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>withoutpreload="none"is ignored. The runtime does not override an author-tunedpreloadvalue.- Non-
<video>elements withdata-lazy-heroare ignored. The promote action (flippreload, 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 shape | Use | Where it lives |
|---|---|---|
| Static — the page does not depend on per-request data | JSON file + JsonUi::render_file | src/views/{module}/{view}.json |
| Homogeneous iteration — N elements with uniform shape, data-driven count | JSON file + $each directive | src/views/{module}/{view}.json |
| Conditional emission — single template, branches on a runtime flag | JSON file + $if directive | src/views/{module}/{view}.json |
| Heterogeneous runtime construction — element graph cannot be expressed declaratively | Rust + SpecBuilder::element_nested | controller 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:
$ifis evaluated at resolve time. A falsy predicate removes the element fromSpec.elements. No HTML is emitted.visibleis 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 fromspec.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
$eachand$ifcan co-occur on the same element.$ifis evaluated first; if false, the element is removed and$eachproduces 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 asMismatchedEachat validation time. - Nested
$eachdeeper than direct-children siblings is rejected asNestedEachat validation time. If a nested-iteration case appears, file an issue with the data shape. - Reserved
asnames:data,root,_root,_each,this,self. Using any of these as the loop variable is rejected asEachAsReservedName.
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 stringspec.data— the data source itselfelement.children— always a list of element ID stringselement.action— handler name and method are literalelement.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 inspec.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:
- Clones the templated element.
- Rewrites prop expressions: paths starting with
/{as}/...resolve against the row data. - Assigns the clone the ID
{element_id}-{i}(e.g.,order_card-0,order_card-1, ...). - Removes the original templated element.
- Updates any parent's
childrenlist 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 whenspec.datais non-null and the path resolves to a non-array value.EachAsReservedName—ascollides 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$eachover 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.urlvalues are not synthesized from row data inside$each. The controller pre-resolves URLs intospec.dataand the templated element references them via{ "$data": "/order/advance_url" }. - Nested
$eachis 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.
| Pattern | Use when |
|---|---|
KanbanBoard.items_path + group_by | Fixed lanes; each item rendered with the prescribed card (title, description, dropdown) — no custom card structure needed |
$each inside KanbanColumn | Fixed 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 whenspec.datais non-null and the predicate path resolves toNone(the key is absent). This is distinct from a present-but-null value. The check fires atSpec::from_jsontime; 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:
| Flag | Description |
|---|---|
--output <file> | Write schema to a file (defaults to stdout) |
--pretty | Pretty-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
Specobject 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 (
$dataand$templateshapes) - 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
| Field | Type | Description |
|---|---|---|
status | SeamStatus | Aggregate status across all seams. |
projection | string | The projection name as supplied to the tool. |
seams | SeamResult[] | One entry per seam, regardless of outcome. |
next_steps | string[] | Ranked, deduplicated actionable strings, capped at 5. |
SeamStatus values
| Value | Meaning |
|---|---|
pass | Seam checked and no issues found. |
warn | Seam checked; findings present but not blocking. |
fail | Seam checked; one or more blocking findings present. |
not_checked | Prerequisite absent — seam was not run. Never coerced to pass. |
SeamResult fields
| Field | Type | Description |
|---|---|---|
seam | string | Seam identifier (e.g. "field_to_column"). |
status | SeamStatus | Outcome for this seam. |
source | string | Provenance 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"). |
findings | Finding[] | Populated when status is fail or warn. |
reason | string? | Populated for not_checked or warn outcomes; describes why. |
Finding fields
| Field | Type | Description |
|---|---|---|
subject | string | The field, entity, or structural element the finding concerns. |
detail | string | Human-readable description of the problem. |
fix | string | Concrete 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.
| Seam | Default outcome | Rationale |
|---|---|---|
field_to_column | Active | Proven by an acceptance fixture (dangling field planted, exactly one finding). |
action_to_route | Active | Proven against the in-repo sample application (4 unregistered actions detected). |
projection_well_formed | Active | Runs via validate_projection; findings reported on name-collision path. |
rendered_view | Active | Runs via render_projection; findings reported on name-collision path. |
props_to_contract | not_checked by default | Produced 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
failif any seam isfailwarnif no seam isfailbut at least one iswarnpassotherwise — including when all seams arenot_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:
| Field | Description |
|---|---|
ambient_status | "clean" if status == pass, otherwise "failing". |
checked_at | ISO 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
cargoor 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.
Related Tools
| Tool | When to use |
|---|---|
validate_projection | Structural validation — unreachable states, unused guards. |
projection_coverage | Coverage gaps — which models have projections and which do not. |
inspect_projection | Inspect the raw ServiceDef structure of a projection. |
render_projection | Preview 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.
Homebrew (macOS and Linux — recommended)
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:
| Option | Description |
|---|---|
--no-interaction | Skip prompts, use defaults |
--no-git | Don'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:
| Option | Default | Description |
|---|---|---|
--port | 8080 | Backend server port |
--frontend-port | 5173 | Frontend dev server port |
--backend-only | false | Run only the backend |
--frontend-only | false | Run only the frontend |
--skip-types | false | Skip TypeScript type generation |
--watch | false | Enable file-watch auto-reload (500ms debounce) |
Key bindings (when stdin is a TTY):
| Key | Action |
|---|---|
r | Rebuild the backend and regenerate types. If a build is in flight, it is cancelled and restarted. |
q or Ctrl-C | Graceful 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:
- Starts the Rust backend via an in-process supervisor that owns the
cargo runchild directly. Auto-reload is opt-in via--watch; without it, the supervisor waits for therkey to trigger a rebuild. - Starts the Vite frontend dev server.
- With
--watch, watches*.rsfiles undersrc/with a 500ms trailing-edge debounce; a burst of saves produces one rebuild after the burst settles. - On every rebuild (manual or file-triggered), regenerates TypeScript types from Rust
InertiaPropsstructs. - 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-typesalso 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:
| Option | Default | Description |
|---|---|---|
--sweep <days> | — | Remove only artifacts older than N days. Requires cargo install cargo-sweep. |
What it does:
- Without
--sweep: runscargo clean, removing the entiretarget/directory. - With
--sweep <days>: invokescargo 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:
| Option | Description |
|---|---|
--resource | Generate index, show, create, store, edit, update, destroy methods |
--api | Generate 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:
| Option | Description |
|---|---|
--force, -f | Overwrite existing auth controller and migration |
Generated Files:
src/migrations/m{timestamp}_add_auth_fields_to_users.rs-- ALTER TABLE migration addingpassword,remember_token, andemail_verified_atfields to the existing users tablesrc/controllers/auth_controller.rs-- Controller withregister,login, andlogouthandlers
What it does:
- Generates an ALTER TABLE migration (assumes
userstable already exists) - Creates an auth controller with register/login/logout handlers
- Registers the controller module in
src/controllers/mod.rs - 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.tsxsrc/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:
| Option | Description |
|---|---|
--description, -d | Description of the desired UI for AI generation |
--layout, -l | Layout to use (default: app) |
--no-ai | Skip AI generation, use static template |
How it works:
- Scans your models and routes for project context
- Sends context to Anthropic API (Claude Sonnet) for intelligent view generation
- Falls back to a static template if no API key is configured or AI generation fails
- Generates a view file in
src/views/and updatesmod.rs
Requirements:
- Set
ANTHROPIC_API_KEYin your environment for AI-powered generation - Without the key, a static template with common components is generated
- Model override via
FERRO_AI_MODELenvironment 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:
| Option | Description |
|---|---|
--model, -m | Model 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:
| Attribute | Description |
|---|---|
#[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:
| Type | Rust Type | Database Type |
|---|---|---|
string | String | VARCHAR(255) |
text | String | TEXT |
integer | i32 | INTEGER |
bigint | i64 | BIGINT |
float | f64 | DOUBLE |
bool | bool | BOOLEAN |
datetime | DateTime | TIMESTAMP |
date | Date | DATE |
uuid | Uuid | UUID |
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:
| Option | Default | Description |
|---|---|---|
models | — | One or more model names (positional) |
--all | false | Generate API for all detected models |
--yes, -y | false | Skip confirmation prompt |
--exclude | — | Comma-delimited list of field names to exclude from generated endpoints |
--include-all | false | Disable auto-exclusion of sensitive field names |
What it does:
- Detects models in
src/models/(or uses the provided list) - Generates controller, resource, and route entries for each model
- Automatically excludes fields matching sensitive patterns:
password,secret,token,hash,key,salt,private,credentials --excludeaccepts comma-delimited values (e.g.password_hash,secret_token)--include-alldisables 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:
| Option | Default | Description |
|---|---|---|
name | — | Display name for the key (positional, required) |
--env | live | Key environment: live or test |
What it does:
- Generates a cryptographically random API key prefixed with
fe_<env>_ - Hashes the key with SHA-256 (the hash is what gets stored)
- Prints the raw key once — it is not stored in plaintext and cannot be recovered
- 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:
| Option | Description |
|---|---|
name | BCP 47 locale tag (positional, required; e.g. en, fr, pt-br, zh-hans) |
Generated files:
lang/<locale>/validation.json— Validation error message translationslang/<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:
| Option | Default | Description |
|---|---|---|
name | — | Policy name (positional, required) |
--model, -m | Name without Policy suffix | The 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:
| Option | Default | Description |
|---|---|---|
name | — | Projection name (positional, required) |
--from-model | false | Populate 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:
| Option | Default | Description |
|---|---|---|
--connect | false | Include Stripe Connect webhook and connect account ID field |
Generated files:
src/stripe/mod.rs— Stripe module rootsrc/stripe/webhook.rs— Stripe webhook handlersrc/stripe/listeners.rs— Stripe event listenerssrc/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:
| Option | Description |
|---|---|
name | Theme name (positional, required) |
Generated files:
themes/<name>/tokens.css— Tailwind v4@themeblock with 23 semantic token slotsthemes/<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 rootsrc/whatsapp/webhook.rs— WhatsApp webhook handlersrc/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:
| Option | Description |
|---|---|
--class | Run 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:
| Option | Description |
|---|---|
--skip-migrations | Skip running migrations before syncing (migrations run by default) |
--regenerate-models | Regenerate SeaORM model wrappers |
This command:
- Discovers the database schema (tables, columns, types)
- Generates SeaORM entity files in
src/models/entities/ - 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_URLfrom.envfile - 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-detectedgithub.repo,region: fra1, aservices:web entry, aworkers:entry per non-test[[bin]], and a commented-outenvs:scaffold. Nodatabases: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 existingDockerfile/.dockerignore.
Generated files:
Dockerfile— multi-stage build, base image defaults torust: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
ferro storage:link
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:
| Option | Default | Description |
|---|---|---|
--url | http://localhost:8080 | Base URL of the running API server |
--api-key | — | API key for testing authenticated endpoints |
--spec-path | /api/openapi.json | Path to the OpenAPI spec endpoint |
What it does:
- Checks server reachability by sending a GET request to
--url - Fetches the OpenAPI spec from
<url><spec-path> - Validates the spec structure (version, paths, info object)
- Tests API key authentication if
--api-keyis provided - Prints
ferro-api-mcpconfiguration for connecting AI tools to this server
ferro projection:check
Validate service projection definitions.
Requires the
projectionsfeature. Build withcargo build --features projectionsor addprojectionsto thedefaultfeatures inCargo.tomlbefore running this command.
# Validate all projections
ferro projection:check
# Validate a single projection by function name
ferro projection:check --name user_service
Options:
| Option | Default | Description |
|---|---|---|
--name | — | Check only the named projection function |
What it does:
- Discovers all projection definitions in
src/projections/ - Validates that each projection's field types, intent hints, and visibility rules are well-formed
- 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:
| Option | Default | Description |
|---|---|---|
--filter, -f | — | Only validate routes matching this prefix |
--json | false | Output results as JSON (useful for CI) |
What it does:
- Compares Rust
InertiaPropsstructs with the generated TypeScript interfaces infrontend/src/types/ - Reports field name mismatches, type incompatibilities, and missing optional fields
- 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:
| Option | Description |
|---|---|
--force, -f | Overwrite existing skill files |
--list, -l | List available skills without installing |
Installed Location: ~/.claude/commands/ferro/
Available Skills:
| Command | Description |
|---|---|
/ferro:help | Show all available Ferro commands |
/ferro:info | Display project information |
/ferro:routes | List all registered routes |
/ferro:route:explain | Explain a specific route in detail |
/ferro:model | Generate a new model with migration |
/ferro:models | List all models with fields |
/ferro:controller | Generate a new controller |
/ferro:middleware | Generate new middleware |
/ferro:db | Database operations (migrate, rollback, seed) |
/ferro:test | Run tests with coverage options |
/ferro:serve | Start the development server |
/ferro:new | Create a new Ferro project |
/ferro:tinker | Interactive database REPL |
/ferro:diagnose | Diagnose errors using MCP introspection |
Skills leverage ferro-mcp for intelligent code generation and project introspection.
Command Summary
| Command | Description |
|---|---|
new | Create a new Ferro project |
serve | Start development server |
generate-types | Generate TypeScript types |
clean | Remove build artifacts |
make:action | Create an action class |
make:api | Generate REST API endpoints for models |
make:api-key | Generate an API key |
make:auth | Scaffold authentication system |
make:controller | Create a controller |
make:error | Create a custom error |
make:event | Create an event |
make:factory | Create a model factory |
make:inertia | Create an Inertia page |
make:job | Create a background job |
make:json-view | Create a JSON-UI view (AI-powered) |
make:lang | Create translation files for a locale |
make:listener | Create a listener |
make:middleware | Create middleware |
make:migration | Create a migration |
make:notification | Create a notification |
make:policy | Create an authorization policy |
make:projection | Create a service projection |
make:resource | Create an API resource |
make:scaffold | Create complete CRUD scaffold |
make:seeder | Create a database seeder |
make:stripe | Scaffold Stripe billing integration |
make:task | Create a scheduled task |
make:theme | Create a JSON-UI theme |
make:whatsapp | Scaffold WhatsApp integration |
db:migrate | Run migrations |
db:rollback | Rollback migrations |
db:status | Show migration status |
db:fresh | Fresh migrate (drop all) |
db:seed | Run database seeders |
db:sync | Sync database schema |
db:query | Execute raw SQL query |
do:init | Generate DigitalOcean App Platform spec (.do/app.yaml) |
ci:init | Generate GitHub Actions CI workflow (.github/workflows/ci.yml) |
doctor | Run project health diagnostics (eleven checks) |
docker:init | Generate Dockerfile and .dockerignore |
docker:compose | Manage Docker Compose |
schedule:run | Run due scheduled tasks |
schedule:work | Start scheduler worker |
schedule:list | List scheduled tasks |
storage:link | Create storage symlink |
api:check | Verify API server and MCP integration |
projection:check | Validate service projection definitions |
validate:contracts | Validate Inertia TypeScript contracts |
mcp | Start MCP server |
boost:install | Install AI boost features |
claude:install | Install Claude Code skills |
Environment Variables
The CLI respects these environment variables:
| Variable | Description |
|---|---|
DATABASE_URL | Database connection string |
APP_ENV | Application environment (development, production) |
RUST_LOG | Logging 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
namederived from the crate name. region: fra1(hardcoded in the template — edit the file afterwards if you need a different region).github.repoauto-detected from theorigingit remote, withdeploy_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 inCargo.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 ordoctl.
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.
Related commands
ferro ci:init— standalone CI workflow scaffold.ferro docker:init— Dockerfile generation (see CLI reference).ferro doctor— project health diagnostics.
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:
| Step | Command |
|---|---|
| Format check | cargo fmt --all -- --check |
| Lint | cargo clippy --all-targets -- -D warnings |
| Tests | cargo test --all-features |
| API readiness | cargo run -p ferro-cli -- api:check |
| Inertia contract validation | cargo 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.pushtomain— 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:initscaffolds DigitalOcean App Platform deploy config (.do/app.yaml).ci:initscaffolds 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):
| # | Name | Category | Purpose |
|---|---|---|---|
| 1 | toolchain_match | General | rustc --version vs rust-toolchain.toml channel |
| 2 | db_connection | General | DATABASE_URL reachable via cargo run -- db:status |
| 3 | migrations_pending | General | Pending vs applied migration count |
| 4 | local_env_parity | General | Every key in .env.example is set in .env |
| 5 | deploy_env_parity | General | .env.production keys match the commented envs scaffold in .do/app.yaml |
| 6 | copy_dirs_dockerignore_collision | Deploy | copy_dirs entries not silently excluded by .dockerignore |
| 7 | docker_template_drift | Deploy | Committed Dockerfile matches current scaffolder output |
| 8 | generated_artifacts | General | Dockerfile, .dockerignore, .do/app.yaml present |
| 9 | database_url_sqlite_in_prod | General | Warns if DATABASE_URL in .env.production points at SQLite |
| 10 | git_clean_and_pushed | General | Working tree clean and HEAD pushed to the tracked remote |
| 11 | frontend_types_convention | General | No 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
| Status | Meaning |
|---|---|
ok | Check passed |
warn | Non-blocking issue (recommended fix; does not affect exit code) |
error | Blocking issue (forces non-zero exit) |
Exit code contract (D-09)
| Overall status | Exit code |
|---|---|
All ok | 0 |
Any warn | 0 |
Any error | 1 |
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.overall—ok|warn|error— worst status across all checks.summary.ok/warn/error— counts.checks[].name— stable identifier (one of the eleven names above).checks[].status—ok|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 anycopy_dirsentry 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/:
| File | Contents |
|---|---|
inertia-props.ts | Per-component Inertia prop interfaces derived from #[handler] return types and Inertia::render call sites |
routes.ts | Typed 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.
Related commands
ferro doctor— runs thefrontend_types_conventioncheckferro docker:init— regenerates the Dockerfile with the currenttypes-genstage
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
| Field | Stability | Notes |
|---|---|---|
routes | stable | Top-level array. |
method | stable | Always uppercase. |
path | stable | {param} placeholders preserved verbatim. |
handler | stable | module::fn joined with ::. |
name | stable | null when no .name(...) call on route. |
middleware | stable, partial | Currently 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.