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 — 80+ 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. Current milestone work targets v12.0 spec-driven rendering.
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
- Rust 1.75+ (with Cargo)
- Node.js 18+ (for frontend)
- PostgreSQL, SQLite, or MySQL
Installing the CLI
Install the Ferro CLI globally:
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 57 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:model <ModelName>` to scaffold a new model with migration" -
The agent reads the hint, identifies the CLI command:
ferro make:model. -
The agent executes the CLI command:
ferro make:model Post -
Ferro generates
app/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:handler— 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 } })) } }
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.
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 Redis-backed queue system for processing jobs asynchronously. This is essential for handling time-consuming tasks like sending emails, processing uploads, or generating reports without blocking HTTP requests.
Configuration
Environment Variables
Configure queues in your .env file:
# Queue driver: "sync" for development, "redis" for production
QUEUE_CONNECTION=sync
# Default queue name
QUEUE_DEFAULT=default
# Redis connection
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=0
Bootstrap Setup
In src/bootstrap.rs, initialize the queue system:
#![allow(unused)] fn main() { use ferro::{Queue, QueueConfig}; pub async fn register() { // ... other setup ... // Initialize queue (for production with Redis) if !QueueConfig::is_sync_mode() { let config = QueueConfig::from_env(); Queue::init(config).await.expect("Failed to initialize queue"); } } }
Creating Jobs
Using the CLI
Generate a new job:
ferro make:job ProcessPayment
This creates src/jobs/process_payment.rs:
#![allow(unused)] fn main() { use ferro::{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 } fn retry_delay(&self, attempt: u32) -> std::time::Duration { // Exponential backoff: 2s, 4s, 8s... std::time::Duration::from_secs(2u64.pow(attempt)) } } }
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 | 5 seconds |
timeout() | Maximum execution time | 60 seconds |
failed(error) | Called when all retries exhausted | Logs error |
Dispatching Jobs
Basic Dispatch
#![allow(unused)] fn main() { use crate::jobs::ProcessPayment; // In a controller or service ProcessPayment { order_id: 123, amount: 99.99, } .dispatch() .await?; }
With Delay
Process the job after a delay:
#![allow(unused)] fn main() { use std::time::Duration; ProcessPayment { order_id: 123, amount: 99.99 } .delay(Duration::from_secs(60)) // Wait 1 minute .dispatch() .await?; }
To Specific Queue
Route jobs to different queues for priority handling:
#![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(300)) // 5 minute delay .on_queue("payments") .dispatch() .await?; }
Running Workers
Development
For development, use sync mode (QUEUE_CONNECTION=sync) which processes jobs immediately during the HTTP request.
Production
Run a worker process to consume jobs from Redis:
// src/bin/worker.rs use ferro::{Worker, WorkerConfig}; use myapp::jobs::{ProcessPayment, SendEmail, GenerateReport}; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Initialize app (loads .env, connects to Redis) myapp::bootstrap::register().await; let worker = Worker::new(WorkerConfig { queue: "default".into(), ..Default::default() }); // Register all job types this worker handles worker.register::<ProcessPayment>(); worker.register::<SendEmail>(); worker.register::<GenerateReport>(); // Run forever (handles graceful shutdown) worker.run().await?; Ok(()) }
Run with:
cargo run --bin worker
Multiple Queues
Run separate workers for different queues:
# High priority worker
QUEUE_NAME=high-priority cargo run --bin worker
# Default queue worker
cargo run --bin worker
# Email-specific worker
QUEUE_NAME=emails cargo run --bin worker
Error Handling
Automatic Retries
Failed jobs are automatically retried based on max_retries() and retry_delay():
#![allow(unused)] fn main() { impl Job for ProcessPayment { fn max_retries(&self) -> u32 { 5 // Try 5 times total } fn retry_delay(&self, attempt: u32) -> Duration { // Exponential backoff with jitter let base = Duration::from_secs(2u64.pow(attempt)); let jitter = Duration::from_millis(rand::random::<u64>() % 1000); base + jitter } } }
Failed Job Handler
Handle permanent failures:
#![allow(unused)] fn main() { impl Job for ProcessPayment { async fn failed(&self, error: &Error) { tracing::error!( order_id = self.order_id, error = ?error, "Payment processing permanently failed" ); // Notify admins, update order status, etc. } } }
Best Practices
- Keep jobs small - Jobs should do one thing well
- Make jobs idempotent - Safe to run multiple times
- Use appropriate timeouts - Set
timeout()based on expected duration - Handle failures gracefully - Implement
failed()for cleanup - Use dedicated queues - Separate critical jobs from bulk processing
- Monitor queue depth - Alert on growing backlogs
Environment Variables Reference
| Variable | Description | Default |
|---|---|---|
QUEUE_CONNECTION | "sync" or "redis" | sync |
QUEUE_DEFAULT | Default queue name | default |
QUEUE_PREFIX | Redis key prefix | ferro_queue |
QUEUE_BLOCK_TIMEOUT | Worker polling timeout (seconds) | 5 |
QUEUE_MAX_CONCURRENT | Max parallel jobs per worker | 10 |
REDIS_URL | Full Redis URL (overrides individual settings) | - |
REDIS_HOST | Redis server host | 127.0.0.1 |
REDIS_PORT | Redis server port | 6379 |
REDIS_PASSWORD | Redis password | - |
REDIS_DATABASE | Redis database number | 0 |
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 job execution history from Redis: job name, status (completed, failed, retrying), attempt count, and timestamp. Use this to diagnose jobs that are silently failing or retrying excessively.
queue_status
Returns current queue depth, active workers, and pending job counts per queue name. Use this to check whether a queue is backed up or whether workers are running.
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.
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, })) } }
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 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.
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
#![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
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() |
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.
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. 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 that renders Tailwind-styled HTML from Rust data structures. No frontend build step, no React, no Node.js -- define your interface as a component tree and the framework renders it to HTML.
How It Works
- Define a
JsonUiViewcontaining a tree ofComponentNodevalues - Attach data, actions, and visibility rules to components
- Call
JsonUi::render()to produce a full HTML page with Tailwind classes - The framework resolves route names to URLs and binds data automatically
JSON-UI is an alternative to Inertia.js. Both use the same handler pattern and return Response, but JSON-UI outputs server-rendered HTML while Inertia delegates rendering to a React frontend.
When to Use JSON-UI vs Inertia
| Use Case | JSON-UI | Inertia |
|---|---|---|
| Admin panels and dashboards | Ideal | Overkill |
| CRUD applications | Ideal | Works, but heavier setup |
| Rapid prototyping | Ideal | Slower iteration |
| Server-rendered pages | Built for this | Not designed for this |
| Rich interactive UIs | Limited | Ideal |
| Complex client state | Not suited | Ideal |
| SPA behavior | Not suited | Ideal |
Both can coexist in the same application on different routes.
Quick Example
#![allow(unused)] fn main() { use ferro::{handler, JsonUi, JsonUiView, ComponentNode, Component, CardProps, TableProps, Column, Action, Response}; #[handler] pub async fn index() -> Response { let view = JsonUiView::new() .title("Users") .layout("app") .component(ComponentNode { key: "header".to_string(), component: Component::Card(CardProps { title: "User Management".to_string(), description: Some("View and manage users".to_string()), children: vec![], footer: vec![], }), action: None, visibility: None, }) .component(ComponentNode { key: "users-table".to_string(), component: Component::Table(TableProps { columns: vec![ Column { key: "name".to_string(), label: "Name".to_string(), format: None }, Column { key: "email".to_string(), label: "Email".to_string(), format: None }, ], data_path: "/data/users".to_string(), row_actions: None, empty_message: Some("No users found".to_string()), sortable: None, sort_column: None, sort_direction: None, }), action: None, visibility: None, }); let data = serde_json::json!({ "users": [ {"name": "Alice", "email": "alice@example.com"}, {"name": "Bob", "email": "bob@example.com"}, ] }); JsonUi::render(&view, &data) } }
Key Concepts
-
Components -- 20 built-in component types: Card, Table, Form, Button, Input, Select, Alert, Badge, Modal, Text, Checkbox, Switch, Separator, DescriptionList, Tabs, Breadcrumb, Pagination, Progress, Avatar, and Skeleton.
-
Actions -- Route-based navigation and form submission. Actions reference handler names (
"users.store") that resolve to URLs at render time. -
Data Binding & Visibility -- Pre-fill form fields from handler data via
data_path, and conditionally show/hide components with visibility rules. -
Layouts -- Page structure with navigation. Built-in
"app"layout includes sidebar and header;"auth"layout centers content. Custom layouts via theLayouttrait.
Plugin System
JSON-UI supports plugin components that extend the built-in set 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 | Option<[f64; 2]> | No* | -- | Map center as [latitude, longitude] |
zoom | u8 | No | 13 | Zoom level (0-18) |
height | String | No | "400px" | CSS height of the map container |
fit_bounds | Option<bool> | No | -- | Auto-zoom to fit all markers. When true, center/zoom are ignored if markers exist |
markers | Vec<MapMarker> | 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 | u8 | No | 19 | Maximum zoom level |
*center is optional when fit_bounds is true and markers are provided.
MapMarker
| Field | Type | Required | Description |
|---|---|---|---|
lat | f64 | Yes | Latitude |
lng | f64 | Yes | Longitude |
popup | Option<String> | No | Plain text popup content |
color | Option<String> | No | Hex color for a colored CSS pin (e.g., "#3B82F6"). Renders as a DivIcon instead of the default marker |
popup_html | Option<String> | No | HTML content for the popup (alternative to plain text popup) |
href | Option<String> | No | URL to navigate to on marker click |
Basic Example
{
"type": "Map",
"center": [51.505, -0.09],
"zoom": 13,
"markers": [
{"lat": 51.5, "lng": -0.09, "popup": "London"}
]
}
Colored Markers with HTML Popups
{
"type": "Map",
"fit_bounds": true,
"markers": [
{
"lat": 45.464,
"lng": 9.190,
"color": "#3B82F6",
"popup_html": "<strong>Milan</strong><br>Fashion capital",
"href": "/places/milan"
},
{
"lat": 41.902,
"lng": 12.496,
"color": "#EF4444",
"popup_html": "<strong>Rome</strong><br>Eternal city",
"href": "/places/rome"
}
]
}
When fit_bounds is true, the map auto-zooms to fit all markers. Colored markers render as CSS DivIcon pins. Clicking a marker with href navigates to that URL.
Custom Tiles and Height
{
"type": "Map",
"center": [40.7128, -74.0060],
"zoom": 12,
"height": "600px",
"tile_url": "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
"attribution": "Map data: OpenTopoMap",
"max_zoom": 17
}
Notes
- Tabs and Modals: Maps inside hidden containers (Tabs, Modals) are handled automatically. An
IntersectionObservercallsinvalidateSize()when the map becomes visible. - Multiple maps: Each map container gets a unique ID. Multiple maps on the same page work independently.
- CSP requirements: If using Content Security Policy headers, allow
https://unpkg.comfor scripts andhttps://*.tile.openstreetmap.orgfor tile images.
CLI Support
Scaffold views 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 view file. Without an API key, it falls back to a static template.
See CLI Reference for details.
MCP Tools
Three MCP tools support JSON-UI development: catalog browsing, component inspection, and view generation.
json_ui_catalog
- Returns: All available components (built-in + registered plugins) with their prop schemas, required vs optional fields, and example JSON
- When to use: Discover what components exist before building a view; look up the exact prop names and types for a component you want to use
json_ui_inspect
- Returns: A parsed breakdown of an existing
JsonUiViewor JSON view definition: component tree, data paths referenced, actions and their resolved routes, and any visibility rules - When to use: Debug a view that isn't rendering as expected; audit data paths before changing handler output; verify that actions resolve to the correct route names
json_ui_generate
- Returns: A complete
JsonUiViewRust struct (or JSON definition) scaffolded from a model and intent description - When to use: Rapidly prototype a new view from a model; generate a starting point that you refine rather than starting from scratch. 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)) } }
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. Use this to diagnose missing configuration before debugging webhook failures.
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, WhatsAppMessage, WhatsAppSendResult}; let result: WhatsAppSendResult = WhatsApp::send( "393401234567", WhatsAppMessage::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); }
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, WhatsAppMessage}; WhatsApp::send( "393401234567", WhatsAppMessage::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[]
↓
JsonUiRenderer.render(&service_def, &intents, &ctx) → serde_json::Value
- 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. - JsonUiRenderer — takes the service definition, the ranked intents, and a render context, and produces a ferro-json-ui component tree.
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, RenderContext, }; 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 json = renderer .render(&product, &intents, &RenderContext::default()) .expect("rendering a valid service definition should not fail"); // json["$schema"] == "ferro-json-ui/v1" // json["components"] contains the generated component tree }
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 | Short text: names, titles, codes |
DataType::Boolean | True/false flags |
DataType::Date | Calendar date (no time) |
DataType::DateTime | Date plus time |
DataType::Text | Long-form prose: descriptions, body content |
DataType::Enum | Fixed set of values: status, category |
FieldMeaning:
| Variant | When to use |
|---|---|
FieldMeaning::Identifier | Primary key or unique ID |
FieldMeaning::EntityName | Display name of the record |
FieldMeaning::Money | Monetary amount |
FieldMeaning::Description | Long descriptive text |
FieldMeaning::Status | Current state or lifecycle value |
FieldMeaning::Email | Email address |
FieldMeaning::Phone | Phone number |
FieldMeaning::Url | Web URL |
FieldMeaning::Image | Image URL or path |
FieldMeaning::Timestamp | Created/updated timestamps |
FieldMeaning::Count | Aggregate count |
FieldMeaning::Location | Geographic location |
FieldMeaning::Generic | No specific semantic meaning |
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,Countmeanings 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 RenderContext to control how the output is shaped.
#![allow(unused)] fn main() { use ferro::{JsonUiRenderer, RenderContext, RenderMode, Renderer}; // Display mode: read-only view of data let display_ctx = RenderContext { 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 = RenderContext { intent_index: 0, current_state: Some("draft".to_string()), // current workflow state mode: RenderMode::Input, // form layout templates: None, }; let renderer = JsonUiRenderer; let json = renderer.render(&service_def, &intents, &input_ctx).expect("rendering a valid service definition should not fail"); }
RenderContext 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<...> | Custom layout overrides (from theme.json); 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, RenderContext, 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::Timestamp) .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 = RenderContext { intent_index: 0, current_state: Some("draft".to_string()), mode: RenderMode::Input, templates: None, }; let json = 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 }
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 |
Renderer | Trait implemented by renderers; one method: render(def, intents, ctx) |
RenderContext | Render parameters: intent index, current state, mode, template overrides |
RenderMode | Display for read-only output; Input for editable form output |
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
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
Getting Started with JSON-UI
Build server-rendered pages with Ferro's component system. No frontend toolchain required.
Prerequisites
- An existing Ferro application
- No additional dependencies -- JSON-UI is built into the framework
Your First View
Create a handler that returns a JSON-UI page. The view is a tree of components rendered to HTML with Tailwind classes.
1. Build the view
#![allow(unused)] fn main() { use ferro::{handler, JsonUi, JsonUiView, ComponentNode, Component, CardProps, TextProps, TextElement, StatCardProps, Response}; #[handler] pub async fn dashboard() -> Response { let view = JsonUiView::new() .title("Dashboard") .layout("dashboard") .component(ComponentNode::card("welcome", CardProps { title: "Welcome".to_string(), description: Some("Your application dashboard".to_string()), children: vec![ ComponentNode { key: "intro".to_string(), component: Component::Text(TextProps { content: "This page is rendered entirely from Rust.".to_string(), element: TextElement::P, }), action: None, visibility: None, }, ], footer: vec![], })) .component(ComponentNode::stat_card("orders", StatCardProps { label: "Orders Today".to_string(), value: "42".to_string(), icon: Some("shopping-bag".to_string()), subtitle: Some("Last updated just now".to_string()), sse_target: Some("orders_today".to_string()), })); JsonUi::render(&view, &serde_json::json!({})) } }
2. Register the route
#![allow(unused)] fn main() { use ferro::get; get!("/dashboard", controllers::dashboard::dashboard); }
That's it. Visit /dashboard to see a styled card with your content, wrapped in the dashboard layout with sidebar and navigation.
Adding a Form
Forms use the Form component with Input fields. The form's action references a named route that the framework resolves to a URL at render time.
Create form
#![allow(unused)] fn main() { use ferro::{handler, JsonUi, JsonUiView, ComponentNode, Component, FormProps, InputProps, InputType, SelectProps, SelectOption, Action, Response}; #[handler] pub async fn create() -> Response { let view = JsonUiView::new() .title("Create User") .layout("dashboard") .component(ComponentNode { key: "form".to_string(), component: Component::Form(FormProps { action: Action::new("users.store"), fields: vec![ ComponentNode { key: "name".to_string(), component: Component::Input(InputProps { field: "name".to_string(), label: "Name".to_string(), input_type: InputType::Text, placeholder: Some("Enter full name".to_string()), required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: None, step: None, }), action: None, visibility: None, }, ComponentNode { key: "email".to_string(), component: Component::Input(InputProps { field: "email".to_string(), label: "Email".to_string(), input_type: InputType::Email, placeholder: Some("user@example.com".to_string()), required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: None, step: None, }), action: None, visibility: None, }, ComponentNode { key: "role".to_string(), component: Component::Select(SelectProps { field: "role".to_string(), label: "Role".to_string(), options: vec![ SelectOption { value: "user".to_string(), label: "User".to_string() }, SelectOption { value: "admin".to_string(), label: "Admin".to_string() }, ], placeholder: Some("Select a role".to_string()), required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: None, }), action: None, visibility: None, }, ], method: None, }), action: None, visibility: None, }); JsonUi::render(&view, &serde_json::json!({})) } }
Pre-fill an edit form
For edit forms, pass the existing record as data and use data_path on each field to bind values:
#![allow(unused)] fn main() { #[handler] pub async fn edit(user: User) -> Response { let view = JsonUiView::new() .title("Edit User") .layout("dashboard") .component(ComponentNode { key: "form".to_string(), component: Component::Form(FormProps { action: Action::new("users.update"), fields: vec![ ComponentNode { key: "name".to_string(), component: Component::Input(InputProps { field: "name".to_string(), label: "Name".to_string(), input_type: InputType::Text, placeholder: None, required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: Some("/data/user/name".to_string()), step: None, }), action: None, visibility: None, }, ComponentNode { key: "email".to_string(), component: Component::Input(InputProps { field: "email".to_string(), label: "Email".to_string(), input_type: InputType::Email, placeholder: None, required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: Some("/data/user/email".to_string()), step: None, }), action: None, visibility: None, }, ], method: None, }), action: None, visibility: None, }); let data = serde_json::json!({ "user": { "name": user.name, "email": user.email, } }); JsonUi::render(&view, &data) } }
The data_path value "/data/user/name" tells the renderer to look up data.user.name and pre-fill the input.
Validation errors
When validation fails, use JsonUi::render_validation_error() to automatically populate error messages on the corresponding form fields:
#![allow(unused)] fn main() { use ferro::{handler, Request, Response, Validator, HttpResponse}; #[handler] pub async fn store(req: Request) -> Response { let form: serde_json::Value = req.json().await?; let result = Validator::new(&form) .rules("name", rules![required(), string()]) .rules("email", rules![required(), email()]) .validate(); if let Err(errors) = result { let view = create_form_view(); // reuse the view from create() return JsonUi::render_validation_error(&view, &serde_json::json!({}), &errors); } // Create user and redirect... Ok(HttpResponse::redirect("/users")) } }
The framework matches error field names ("name", "email") to input field values and sets the error prop on each matching component.
Adding a Table
Tables bind to a data path and render rows automatically from the handler data.
#![allow(unused)] fn main() { use ferro::{handler, JsonUi, JsonUiView, ComponentNode, Component, TableProps, Column, ColumnFormat, Action, PaginationProps, Response}; #[handler] pub async fn index() -> Response { let view = JsonUiView::new() .title("Users") .layout("dashboard") .component(ComponentNode::table("users-table", TableProps { columns: vec![ Column { key: "name".to_string(), label: "Name".to_string(), format: None }, Column { key: "email".to_string(), label: "Email".to_string(), format: None }, Column { key: "created_at".to_string(), label: "Created".to_string(), format: Some(ColumnFormat::Date), }, ], data_path: "/data/users".to_string(), row_actions: Some(vec![ Action::get("users.edit"), Action::delete("users.destroy") .confirm_danger("Delete this user?"), ]), empty_message: Some("No users found".to_string()), sortable: None, sort_column: None, sort_direction: None, })) .component(ComponentNode { key: "pagination".to_string(), component: Component::Pagination(PaginationProps { current_page: 1, per_page: 25, total: 100, base_url: Some("/users".to_string()), }), action: None, visibility: None, }); let data = serde_json::json!({ "users": [ {"name": "Alice", "email": "alice@example.com", "created_at": "2026-01-15"}, {"name": "Bob", "email": "bob@example.com", "created_at": "2026-01-20"}, ] }); JsonUi::render(&view, &data) } }
Key points:
data_pathtells the table where to find its row data in the handler responserow_actionsadds action buttons to each row (Edit link, Delete with confirmation)ColumnFormat::Dateformats thecreated_atcolumn as a datePaginationrenders page navigation below the table
Using the CLI
Generate view files with the CLI:
ferro make:json-view UserIndex
With an Anthropic API key configured, the command reads your models and routes to generate a complete view file with appropriate components. Without an API key, it produces a static template as a starting point.
Next Steps
- Components -- Reference for all 26 built-in component types
- Actions -- Navigation, form submission, confirmations, and outcomes
- Data Binding & Visibility -- Data paths and conditional rendering
- Layouts -- Page structure, DashboardLayout, and custom layouts
- Plugins -- Extend the component catalog with custom interactive components
Components
JSON-UI includes 26 built-in component types organized into six groups. Every component serializes to JSON with a "type" discriminant and is wrapped in a ComponentNode that adds a unique key, an optional action binding, and optional visibility rules.
Component Overview
| Category | Components |
|---|---|
| Layout | Card, Tabs, Separator, Modal, Skeleton |
| Data Display | Table, DescriptionList, Badge, Avatar, Text, Progress, Breadcrumb, Pagination, StatCard |
| Forms | Form, Input, Select, Checkbox, Switch, Button |
| Feedback | Alert, Toast |
| Navigation | Sidebar, Header, NotificationDropdown |
| Onboarding | Checklist |
| Extensible | Plugin |
ComponentNode
Every component is wrapped in a ComponentNode:
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, CardProps}; ComponentNode { key: "my-card".to_string(), // unique identifier on the page component: Component::Card(CardProps { /* ... */ }), action: None, // optional Action binding visibility: None, // optional Visibility condition } }
Convenience constructors are available for common components:
#![allow(unused)] fn main() { // Equivalent to the struct literal above, but more concise let node = ComponentNode::card("my-card", CardProps { title: "Hello".to_string(), description: None, children: vec![], footer: vec![], }); }
Shared Types
Size
Controls sizing for Button, Avatar, and other components.
| Value | Serialized |
|---|---|
Size::Xs | "xs" |
Size::Sm | "sm" |
Size::Default | "default" |
Size::Lg | "lg" |
ButtonVariant
Visual styles for the Button component (aligned to shadcn/ui).
| Value | Serialized | Use Case |
|---|---|---|
ButtonVariant::Default | "default" | Primary actions |
ButtonVariant::Secondary | "secondary" | Secondary actions |
ButtonVariant::Destructive | "destructive" | Delete, remove |
ButtonVariant::Outline | "outline" | Bordered style |
ButtonVariant::Ghost | "ghost" | Minimal style |
ButtonVariant::Link | "link" | Link appearance |
AlertVariant
Visual styles for Alert and Toast components.
| Value | Serialized |
|---|---|
AlertVariant::Info | "info" |
AlertVariant::Success | "success" |
AlertVariant::Warning | "warning" |
AlertVariant::Error | "error" |
BadgeVariant
Visual styles for the Badge component (aligned to shadcn/ui).
| Value | Serialized |
|---|---|
BadgeVariant::Default | "default" |
BadgeVariant::Secondary | "secondary" |
BadgeVariant::Destructive | "destructive" |
BadgeVariant::Outline | "outline" |
ColumnFormat
Display format for Table columns and DescriptionList items.
| Value | Serialized |
|---|---|
ColumnFormat::Date | "date" |
ColumnFormat::DateTime | "date_time" |
ColumnFormat::Currency | "currency" |
ColumnFormat::Boolean | "boolean" |
TextElement
Semantic HTML element for the Text component.
| Value | Serialized | HTML |
|---|---|---|
TextElement::P | "p" | <p> |
TextElement::H1 | "h1" | <h1> |
TextElement::H2 | "h2" | <h2> |
TextElement::H3 | "h3" | <h3> |
TextElement::Span | "span" | <span> |
TextElement::Div | "div" | <div> |
TextElement::Section | "section" | <section> |
ToastVariant
Visual styles for the Toast component. Mirrors AlertVariant.
| Value | Serialized |
|---|---|
ToastVariant::Info | "info" |
ToastVariant::Success | "success" |
ToastVariant::Warning | "warning" |
ToastVariant::Error | "error" |
Layout Components
Card
Container with title, optional description, nested children, and footer.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
title | String | Yes | - | Card title |
description | Option<String> | No | None | Description below the title |
children | Vec<ComponentNode> | No | [] | Nested components in the card body |
footer | Vec<ComponentNode> | No | [] | Components in the card footer |
#![allow(unused)] fn main() { use ferro::{ComponentNode, CardProps, ButtonProps, ButtonVariant, Size}; let node = ComponentNode::card("user-card", CardProps { title: "User Details".to_string(), description: Some("Account information".to_string()), children: vec![ ComponentNode::button("edit-btn", ButtonProps { label: "Edit".to_string(), variant: ButtonVariant::Outline, size: Size::Default, disabled: None, icon: None, icon_position: None, }), ], footer: vec![], }); }
JSON output:
{
"key": "user-card",
"type": "Card",
"title": "User Details",
"description": "Account information",
"children": [
{ "key": "edit-btn", "type": "Button", "label": "Edit", "variant": "outline", "size": "default" }
]
}
Tabs
Tabbed content with multiple panels. Each tab contains its own set of child components.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
default_tab | String | Yes | - | Value of the initially active tab |
tabs | Vec<Tab> | Yes | - | Tab definitions |
Tab defines a tab panel:
| Field | Type | Required | Description |
|---|---|---|---|
value | String | Yes | Tab identifier (matches default_tab) |
label | String | Yes | Tab label text |
children | Vec<ComponentNode> | No | Components displayed when the tab is active |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, TabsProps, Tab}; ComponentNode { key: "settings-tabs".to_string(), component: Component::Tabs(TabsProps { default_tab: "general".to_string(), tabs: vec![ Tab { value: "general".to_string(), label: "General".to_string(), children: vec![/* ... */], }, Tab { value: "security".to_string(), label: "Security".to_string(), children: vec![/* ... */], }, ], }), action: None, visibility: None, } }
JSON output:
{
"key": "settings-tabs",
"type": "Tabs",
"default_tab": "general",
"tabs": [
{ "value": "general", "label": "General", "children": [] },
{ "value": "security", "label": "Security", "children": [] }
]
}
Separator
Visual divider between content sections.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
orientation | Option<Orientation> | No | Horizontal | Direction: horizontal or vertical |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, SeparatorProps}; ComponentNode { key: "divider".to_string(), component: Component::Separator(SeparatorProps { orientation: None, // defaults to horizontal }), action: None, visibility: None, } }
JSON output:
{ "key": "divider", "type": "Separator" }
Modal
Dialog overlay with title, content, footer, and trigger button.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
title | String | Yes | - | Modal title |
description | Option<String> | No | None | Modal description |
children | Vec<ComponentNode> | No | [] | Content components inside the modal body |
footer | Vec<ComponentNode> | No | [] | Components in the modal footer |
trigger_label | Option<String> | No | None | Label for the trigger button |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, ModalProps, ButtonProps, ButtonVariant, Size, Action}; ComponentNode { key: "delete-modal".to_string(), component: Component::Modal(ModalProps { title: "Delete Item".to_string(), description: Some("This action cannot be undone.".to_string()), children: vec![], footer: vec![ ComponentNode { key: "delete-btn".to_string(), component: Component::Button(ButtonProps { label: "Delete".to_string(), variant: ButtonVariant::Destructive, size: Size::Default, disabled: None, icon: None, icon_position: None, }), action: Some(Action::delete("items.destroy").confirm_danger("Confirm deletion")), visibility: None, }, ], trigger_label: Some("Delete".to_string()), }), action: None, visibility: None, } }
JSON output:
{
"key": "delete-modal",
"type": "Modal",
"title": "Delete Item",
"description": "This action cannot be undone.",
"trigger_label": "Delete",
"children": [],
"footer": [{ "key": "delete-btn", "type": "Button", "label": "Delete", "variant": "destructive", "size": "default" }]
}
Skeleton
Loading placeholder with configurable dimensions for content that is still loading.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
width | Option<String> | No | None | CSS width (e.g., "100%", "200px") |
height | Option<String> | No | None | CSS height (e.g., "40px") |
rounded | Option<bool> | No | None | Whether to use rounded corners |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, SkeletonProps}; ComponentNode { key: "loading-placeholder".to_string(), component: Component::Skeleton(SkeletonProps { width: Some("100%".to_string()), height: Some("40px".to_string()), rounded: Some(true), }), action: None, visibility: None, } }
JSON output:
{ "key": "loading-placeholder", "type": "Skeleton", "width": "100%", "height": "40px", "rounded": true }
Data Display Components
Table
Data table with column definitions, row actions, and sorting support. Rows are loaded from handler data via data_path.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
columns | Vec<Column> | Yes | - | Column definitions |
data_path | String | Yes | - | Path to the row data array (e.g., "/data/users") |
row_actions | Option<Vec<Action>> | No | None | Actions available per row |
empty_message | Option<String> | No | None | Message when no data |
sortable | Option<bool> | No | None | Enable column sorting |
sort_column | Option<String> | No | None | Currently sorted column key |
sort_direction | Option<SortDirection> | No | None | Sort direction: asc or desc |
Column defines a table column:
| Field | Type | Required | Description |
|---|---|---|---|
key | String | Yes | Data field key matching the row object |
label | String | Yes | Column header text |
format | Option<ColumnFormat> | No | Display format (Date, DateTime, Currency, Boolean) |
#![allow(unused)] fn main() { use ferro::{ComponentNode, TableProps, Column, ColumnFormat, Action}; let node = ComponentNode::table("users-table", TableProps { columns: vec![ Column { key: "name".to_string(), label: "Name".to_string(), format: None }, Column { key: "email".to_string(), label: "Email".to_string(), format: None }, Column { key: "created_at".to_string(), label: "Created".to_string(), format: Some(ColumnFormat::Date), }, ], data_path: "/data/users".to_string(), row_actions: Some(vec![ Action::get("users.edit"), Action::delete("users.destroy").confirm_danger("Delete this user?"), ]), empty_message: Some("No users found.".to_string()), sortable: Some(true), sort_column: None, sort_direction: None, }); }
JSON output:
{
"key": "users-table",
"type": "Table",
"data_path": "/data/users",
"columns": [
{ "key": "name", "label": "Name" },
{ "key": "email", "label": "Email" },
{ "key": "created_at", "label": "Created", "format": "date" }
],
"sortable": true
}
DescriptionList
Key-value pairs displayed as a description list. Reuses ColumnFormat for value formatting.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
items | Vec<DescriptionItem> | Yes | - | Key-value items |
columns | Option<u8> | No | None | Number of columns for layout |
DescriptionItem defines a key-value pair:
| Field | Type | Required | Description |
|---|---|---|---|
label | String | Yes | Item label |
value | String | Yes | Item value |
format | Option<ColumnFormat> | No | Display format |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, DescriptionListProps, DescriptionItem, ColumnFormat}; ComponentNode { key: "user-info".to_string(), component: Component::DescriptionList(DescriptionListProps { items: vec![ DescriptionItem { label: "Name".to_string(), value: "Alice Johnson".to_string(), format: None }, DescriptionItem { label: "Joined".to_string(), value: "2026-01-15".to_string(), format: Some(ColumnFormat::Date), }, DescriptionItem { label: "Active".to_string(), value: "true".to_string(), format: Some(ColumnFormat::Boolean), }, ], columns: Some(2), }), action: None, visibility: None, } }
JSON output:
{
"key": "user-info",
"type": "DescriptionList",
"columns": 2,
"items": [
{ "label": "Name", "value": "Alice Johnson" },
{ "label": "Joined", "value": "2026-01-15", "format": "date" },
{ "label": "Active", "value": "true", "format": "boolean" }
]
}
DetailForm
Split-mode detail page with inline edit. Renders the same description-list-style scaffold in two modes — View and Edit — driven by a URL query parameter.
When to use. Use DetailForm instead of a pair of DescriptionList (for
viewing) + Form (for editing) when you want the view and edit states to share
a single structural container, so the user sees the same layout whether they are
reading or editing. The mode toggle is URL-driven (?mode=edit); there is no
client-side JavaScript state.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
mode | EditMode | No | EditMode::View | Which mode to render. Typically derived from the URL via EditMode::from_query(req.query("mode").as_deref()) |
action | Action | Yes | - | Form submit target. Resolver populates action.url from action.handler |
fields | Vec<DetailField> | Yes | - | The rows |
edit_url | String | Yes | - | Href for the "Modifica" link in View mode. Emitted verbatim after html_escape; not resolved by the route registry |
cancel_url | String | Yes | - | Href for the "Annulla" link in Edit mode. Emitted verbatim after html_escape; not resolved by the route registry |
edit_label | Option<String> | No | "Modifica" | Override for the default "Modifica" label |
save_label | Option<String> | No | "Salva" | Override for the default "Salva" label |
cancel_label | Option<String> | No | "Annulla" | Override for the default "Annulla" label |
method | Option<HttpMethod> | No | - | HTTP method override (else uses action.method); PUT/PATCH/DELETE auto-emit <input type="hidden" name="_method"> spoofing |
DetailField defines one row:
| Field | Type | Required | Description |
|---|---|---|---|
label | String | Yes | Description term shown in both modes as the field label |
value | String | Yes | Display string shown in View mode (plain text, html-escaped at render) |
input | ComponentNode | Yes | Component rendered in Edit mode in place of value (typically Input, Select, Textarea, Switch, Checkbox, or a plugin) |
EditMode controls which mode to render:
| Variant | JSON | Description |
|---|---|---|
View | "view" | Read-only display with a "Modifica" link (default) |
Edit | "edit" | Inline-edit form with "Salva" / "Annulla" actions |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, DetailForm, DetailFormProps, DetailField, EditMode, InputProps, InputType, Action, HttpMethod}; let mode = EditMode::from_query(req.query("mode").as_deref()); let node = ComponentNode::detail_form( "user-detail", DetailFormProps { mode, action: Action::new("users.update"), fields: vec![ DetailField::new( "Name", user.name.clone(), // Option A: input label must be "" — the <dt> provides the visible label. ComponentNode::input("name", InputProps { field: "name".to_string(), label: "".to_string(), input_type: InputType::Text, default_value: Some(user.name.clone()), ..Default::default() }), ), DetailField::new( "Email", user.email.clone(), ComponentNode::input("email", InputProps { field: "email".to_string(), label: "".to_string(), input_type: InputType::Email, default_value: Some(user.email.clone()), ..Default::default() }), ), ], edit_url: format!("/users/{}?mode=edit", user.id), cancel_url: format!("/users/{}", user.id), edit_label: None, save_label: None, cancel_label: None, method: Some(HttpMethod::Put), }, ); }
JSON output (mode = Edit):
{
"key": "user-detail",
"type": "DetailForm",
"mode": "edit",
"action": { "handler": "users.update", "method": "PUT" },
"fields": [
{
"label": "Name",
"value": "Ada Lovelace",
"input": { "type": "Input", "field": "name", "label": "", "input_type": "text", "default_value": "Ada Lovelace" }
},
{
"label": "Email",
"value": "ada@example.com",
"input": { "type": "Input", "field": "email", "label": "", "input_type": "email", "default_value": "ada@example.com" }
}
],
"edit_url": "/users/1?mode=edit",
"cancel_url": "/users/1",
"method": "PUT"
}
Authoring rule (Option A). When a DetailField.input is an Input, Select,
Textarea, Checkbox, or Switch component, the caller MUST set its label prop
to the empty string "". The <dt> already provides the visible label; a non-empty
input label produces duplicate UI text. DetailForm does not mutate caller-supplied
props. For accessibility, callers SHOULD also set aria-label on each input derived
from the field's label value so screen readers retain the field name.
Not included in v1. Client-side mode toggle (no JS). Optimistic updates. Per-field mode override. Custom action buttons beyond Modifica/Salva/Annulla. Top-level error banner. i18n binding for default labels (currently Italian literals).
Badge
Small label with variant-based styling.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
label | String | Yes | - | Badge text |
variant | BadgeVariant | No | Default | Visual style |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, BadgeProps, BadgeVariant}; ComponentNode { key: "status".to_string(), component: Component::Badge(BadgeProps { label: "Active".to_string(), variant: BadgeVariant::Default, }), action: None, visibility: None, } }
JSON output:
{ "key": "status", "type": "Badge", "label": "Active", "variant": "default" }
Avatar
User avatar with image source, fallback text, and size variants.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
src | Option<String> | No | None | Image URL |
alt | String | Yes | - | Alt text (required for accessibility) |
fallback | Option<String> | No | None | Fallback initials when no image |
size | Option<Size> | No | Default | Avatar size: xs, sm, default, lg |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, AvatarProps, Size}; ComponentNode { key: "user-avatar".to_string(), component: Component::Avatar(AvatarProps { src: Some("/images/alice.jpg".to_string()), alt: "Alice Johnson".to_string(), fallback: Some("AJ".to_string()), size: Some(Size::Lg), }), action: None, visibility: None, } }
JSON output:
{ "key": "user-avatar", "type": "Avatar", "alt": "Alice Johnson", "src": "/images/alice.jpg", "fallback": "AJ", "size": "lg" }
Image
Renders a bounded visual asset — either an external image via URL, or a server-constructed inline SVG.
Props
| Name | Type | Required | Description |
|---|---|---|---|
src | String | one-of | Image source URL (URL variant). Attribute is HTML-escaped. |
svg | String | one-of | Inline SVG emitted verbatim (SVG variant). See Safety note below. |
alt | String | yes | Alt text for accessibility — required on both variants (compile-enforced). |
aspect_ratio | Option<String> | no | CSS aspect ratio (e.g., "16/9"). |
placeholder_label | Option<String> | no | Label shown in the skeleton placeholder (URL variant only). |
Exactly one of src or svg must be set. Backward-compatibility note: existing
JSON sending {"type":"Image","src":"…","alt":"…"} continues to work unchanged.
Safety note —
svgvariant: Thesvgvalue is emitted verbatim without HTML escaping. Intended for server-constructed SVG (charts, sparklines, icons). Not suitable for user-supplied strings. Callers that incorporate user data into the SVG output are responsible for sanitization before constructing thesvgvariant. Thealtattribute is HTML-escaped on both variants.
Rust
#![allow(unused)] fn main() { use ferro_json_ui::{ComponentNode, ImageProps}; // URL variant let url_node = ComponentNode::image( "hero", ImageProps::url("/img/hero.png", "Hero image"), ); // SVG variant — server-constructed chart (e.g. from a Rust helper) let svg = bar_chart_svg(&weekly_data, 800, 300); let chart_node = ComponentNode::image( "revenue-chart", ImageProps::inline_svg(svg, "Incassi settimanali: 150€ lun, 320€ mar, …"), ); }
JSON
URL variant:
{
"type": "Image",
"src": "/img/hero.png",
"alt": "Hero image",
"aspect_ratio": "16/9"
}
SVG variant:
{
"type": "Image",
"svg": "<svg viewBox=\"0 0 800 300\">…</svg>",
"alt": "Incassi settimanali: 150€ lun, 320€ mar, …"
}
Use cases for the SVG variant
- Server-rendered charts (bar, line, sparkline)
- Diagrams assembled from typed data on the server
- Decorative vector assets constructed by Rust code
- Server-rendered icon sets
No generic HTML escape hatch. For rendering HTML (not SVG), no generic
HtmlEmbed-style component exists — by design. If a real use-case demands
HTML embedding, author a narrower component scoped to the specific content
shape rather than a generic string-to-HTML escape hatch.
Text
Renders text content with semantic HTML element selection.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
content | String | Yes | - | Text content |
element | TextElement | No | P | HTML element: p, h1, h2, h3, span, div, section |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, TextProps, TextElement}; ComponentNode { key: "heading".to_string(), component: Component::Text(TextProps { content: "Welcome to the dashboard".to_string(), element: TextElement::H1, }), action: None, visibility: None, } }
JSON output:
{ "key": "heading", "type": "Text", "content": "Welcome to the dashboard", "element": "h1" }
Progress
Progress bar with percentage value.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
value | u8 | Yes | - | Percentage value (0-100) |
max | Option<u8> | No | None | Maximum value |
label | Option<String> | No | None | Label text above the bar |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, ProgressProps}; ComponentNode { key: "upload-progress".to_string(), component: Component::Progress(ProgressProps { value: 75, max: Some(100), label: Some("Uploading...".to_string()), }), action: None, visibility: None, } }
JSON output:
{ "key": "upload-progress", "type": "Progress", "value": 75, "max": 100, "label": "Uploading..." }
Breadcrumb
Navigation breadcrumb trail. The last item typically has no URL (current page).
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
items | Vec<BreadcrumbItem> | Yes | - | Breadcrumb items |
BreadcrumbItem defines a breadcrumb entry:
| Field | Type | Required | Description |
|---|---|---|---|
label | String | Yes | Breadcrumb text |
url | Option<String> | No | Link URL (omit for the current page) |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, BreadcrumbProps, BreadcrumbItem}; ComponentNode { key: "breadcrumbs".to_string(), component: Component::Breadcrumb(BreadcrumbProps { items: vec![ BreadcrumbItem { label: "Home".to_string(), url: Some("/".to_string()) }, BreadcrumbItem { label: "Users".to_string(), url: Some("/users".to_string()) }, BreadcrumbItem { label: "Edit User".to_string(), url: None }, ], }), action: None, visibility: None, } }
JSON output:
{
"key": "breadcrumbs",
"type": "Breadcrumb",
"items": [
{ "label": "Home", "url": "/" },
{ "label": "Users", "url": "/users" },
{ "label": "Edit User" }
]
}
Pagination
Page navigation for paginated data. Computes page count from total and per_page.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
current_page | u32 | Yes | - | Current page number |
per_page | u32 | Yes | - | Items per page |
total | u32 | Yes | - | Total number of items |
base_url | Option<String> | No | None | Base URL for page links |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, PaginationProps}; ComponentNode { key: "users-pagination".to_string(), component: Component::Pagination(PaginationProps { current_page: 1, per_page: 25, total: 150, base_url: Some("/users".to_string()), }), action: None, visibility: None, } }
JSON output:
{ "key": "users-pagination", "type": "Pagination", "current_page": 1, "per_page": 25, "total": 150, "base_url": "/users" }
StatCard
Live-updatable metric card with an optional SSE target for real-time value updates. Used in dashboards to display KPIs, counts, and monetary totals.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
label | String | Yes | - | Metric label (e.g., "Total Revenue") |
value | String | Yes | - | Current metric value (e.g., "€12,345") |
icon | Option<String> | No | None | Icon name |
subtitle | Option<String> | No | None | Secondary text below the value |
sse_target | Option<String> | No | None | SSE event target key for live updates |
The sse_target field connects this card to the JS runtime's SSE listener. When the server emits a Server-Sent Event with a matching key, the runtime updates the displayed value without a page reload:
#![allow(unused)] fn main() { use ferro::{ComponentNode, StatCardProps}; let node = ComponentNode::stat_card("revenue", StatCardProps { label: "Total Revenue".to_string(), value: "€12,345".to_string(), icon: Some("currency-euro".to_string()), subtitle: Some("This month".to_string()), sse_target: Some("revenue_total".to_string()), }); }
The server sends updates via SSE as JSON:
event: live-value
data: {"target": "revenue_total", "value": "€13,210"}
JSON output:
{
"key": "revenue",
"type": "StatCard",
"label": "Total Revenue",
"value": "€12,345",
"icon": "currency-euro",
"subtitle": "This month",
"sse_target": "revenue_total"
}
Forms Components
Form
Form container with action binding and field components. The action defines the submit endpoint.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
action | Action | Yes | - | Action to execute on form submit |
fields | Vec<ComponentNode> | Yes | - | Form field components (Input, Select, Checkbox, etc.) |
method | Option<HttpMethod> | No | None | HTTP method override (GET, POST, PUT, PATCH, DELETE) |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, FormProps, InputProps, InputType, Action}; ComponentNode { key: "create-form".to_string(), component: Component::Form(FormProps { action: Action::new("users.store"), fields: vec![ ComponentNode { key: "name-input".to_string(), component: Component::Input(InputProps { field: "name".to_string(), label: "Name".to_string(), input_type: InputType::Text, placeholder: Some("Enter name".to_string()), required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: None, step: None, }), action: None, visibility: None, }, ], method: None, }), action: None, visibility: None, } }
JSON output:
{
"key": "create-form",
"type": "Form",
"action": { "handler": "users.store", "method": "POST" },
"fields": [
{ "key": "name-input", "type": "Input", "field": "name", "label": "Name", "placeholder": "Enter name", "required": true }
]
}
Input
Text input field with type variants, validation error display, and data binding.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
field | String | Yes | - | Form field name for data binding |
label | String | Yes | - | Input label text |
input_type | InputType | No | Text | Input type |
placeholder | Option<String> | No | None | Placeholder text |
required | Option<bool> | No | None | Whether the field is required |
disabled | Option<bool> | No | None | Whether the field is disabled |
error | Option<String> | No | None | Validation error message |
description | Option<String> | No | None | Help text below the input |
default_value | Option<String> | No | None | Pre-filled value |
data_path | Option<String> | No | None | Data path for pre-filling from handler data (e.g., "/data/user/name") |
step | Option<String> | No | None | HTML step attribute for number inputs (e.g., "any", "0.01") |
InputType variants:
| Value | Serialized |
|---|---|
InputType::Text | "text" |
InputType::Email | "email" |
InputType::Password | "password" |
InputType::Number | "number" |
InputType::Textarea | "textarea" |
InputType::Hidden | "hidden" |
InputType::Date | "date" |
InputType::Time | "time" |
InputType::Url | "url" |
InputType::Tel | "tel" |
InputType::Search | "search" |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, InputProps, InputType}; ComponentNode { key: "email-input".to_string(), component: Component::Input(InputProps { field: "email".to_string(), label: "Email Address".to_string(), input_type: InputType::Email, placeholder: Some("user@example.com".to_string()), required: Some(true), disabled: None, error: None, description: Some("Your work email".to_string()), default_value: None, data_path: Some("/data/user/email".to_string()), step: None, }), action: None, visibility: None, } }
JSON output:
{
"key": "email-input",
"type": "Input",
"field": "email",
"label": "Email Address",
"input_type": "email",
"placeholder": "user@example.com",
"required": true,
"description": "Your work email",
"data_path": "/data/user/email"
}
Select
Dropdown select field with options, validation error, and data binding.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
field | String | Yes | - | Form field name for data binding |
label | String | Yes | - | Select label text |
options | Vec<SelectOption> | Yes | - | Options list |
placeholder | Option<String> | No | None | Placeholder text |
required | Option<bool> | No | None | Whether the field is required |
disabled | Option<bool> | No | None | Whether the field is disabled |
error | Option<String> | No | None | Validation error message |
description | Option<String> | No | None | Help text below the select |
default_value | Option<String> | No | None | Pre-selected value |
data_path | Option<String> | No | None | Data path for pre-filling from handler data |
SelectOption defines a value-label pair:
| Field | Type | Required | Description |
|---|---|---|---|
value | String | Yes | Option value submitted with the form |
label | String | Yes | Display text shown to the user |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, SelectProps, SelectOption}; ComponentNode { key: "role-select".to_string(), component: Component::Select(SelectProps { field: "role".to_string(), label: "Role".to_string(), options: vec![ SelectOption { value: "admin".to_string(), label: "Administrator".to_string() }, SelectOption { value: "editor".to_string(), label: "Editor".to_string() }, SelectOption { value: "viewer".to_string(), label: "Viewer".to_string() }, ], placeholder: Some("Select a role".to_string()), required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: Some("/data/user/role".to_string()), }), action: None, visibility: None, } }
JSON output:
{
"key": "role-select",
"type": "Select",
"field": "role",
"label": "Role",
"placeholder": "Select a role",
"required": true,
"data_path": "/data/user/role",
"options": [
{ "value": "admin", "label": "Administrator" },
{ "value": "editor", "label": "Editor" },
{ "value": "viewer", "label": "Viewer" }
]
}
Checkbox
Boolean checkbox field with label, description, and data binding.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
field | String | Yes | - | Form field name for data binding |
label | String | Yes | - | Checkbox label text |
description | Option<String> | No | None | Help text below the checkbox |
checked | Option<bool> | No | None | Default checked state |
data_path | Option<String> | No | None | Data path for pre-filling from handler data |
required | Option<bool> | No | None | Whether the field is required |
disabled | Option<bool> | No | None | Whether the field is disabled |
error | Option<String> | No | None | Validation error message |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, CheckboxProps}; ComponentNode { key: "terms-checkbox".to_string(), component: Component::Checkbox(CheckboxProps { field: "terms".to_string(), label: "Accept Terms of Service".to_string(), description: Some("You must accept to continue.".to_string()), checked: None, data_path: None, required: Some(true), disabled: None, error: None, }), action: None, visibility: None, } }
JSON output:
{ "key": "terms-checkbox", "type": "Checkbox", "field": "terms", "label": "Accept Terms of Service", "description": "You must accept to continue.", "required": true }
Switch
Toggle switch — a visual alternative to Checkbox with identical props. The frontend renderer handles the visual difference.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
field | String | Yes | - | Form field name for data binding |
label | String | Yes | - | Switch label text |
description | Option<String> | No | None | Help text below the switch |
checked | Option<bool> | No | None | Default checked state |
data_path | Option<String> | No | None | Data path for pre-filling from handler data |
required | Option<bool> | No | None | Whether the field is required |
disabled | Option<bool> | No | None | Whether the field is disabled |
error | Option<String> | No | None | Validation error message |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, SwitchProps}; ComponentNode { key: "notifications-switch".to_string(), component: Component::Switch(SwitchProps { field: "notifications".to_string(), label: "Enable Notifications".to_string(), description: Some("Receive email notifications".to_string()), checked: Some(true), data_path: Some("/data/user/notifications_enabled".to_string()), required: None, disabled: None, error: None, }), action: None, visibility: None, } }
JSON output:
{ "key": "notifications-switch", "type": "Switch", "field": "notifications", "label": "Enable Notifications", "description": "Receive email notifications", "checked": true, "data_path": "/data/user/notifications_enabled" }
Button
Interactive button with visual variants, sizing, and optional icon.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
label | String | Yes | - | Button label text |
variant | ButtonVariant | No | Default | Visual style |
size | Size | No | Default | Button size |
disabled | Option<bool> | No | None | Whether the button is disabled |
icon | Option<String> | No | None | Icon name |
icon_position | Option<IconPosition> | No | Left | Icon placement: left or right |
Buttons are typically combined with an action on the ComponentNode to bind click behavior:
#![allow(unused)] fn main() { use ferro::{ComponentNode, ButtonProps, ButtonVariant, Size, IconPosition}; let node = ComponentNode::button("save-btn", ButtonProps { label: "Save Changes".to_string(), variant: ButtonVariant::Default, size: Size::Default, disabled: None, icon: Some("save".to_string()), icon_position: Some(IconPosition::Left), }); }
JSON output:
{ "key": "save-btn", "type": "Button", "label": "Save Changes", "variant": "default", "size": "default", "icon": "save", "icon_position": "left" }
Feedback Components
Alert
Alert message with variant-based styling and optional title.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
message | String | Yes | - | Alert message content |
variant | AlertVariant | No | Info | Visual style |
title | Option<String> | No | None | Alert title |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, AlertProps, AlertVariant}; ComponentNode { key: "warning".to_string(), component: Component::Alert(AlertProps { message: "Your trial expires in 3 days.".to_string(), variant: AlertVariant::Warning, title: Some("Trial Ending".to_string()), }), action: None, visibility: None, } }
JSON output:
{ "key": "warning", "type": "Alert", "message": "Your trial expires in 3 days.", "variant": "warning", "title": "Trial Ending" }
Toast
Declarative notification intent rendered by the JS runtime. When a Toast component is included in a view, the runtime displays it as an overlay notification (top-right corner) and dismisses it after the configured timeout.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
message | String | Yes | - | Toast message content |
variant | ToastVariant | No | Info | Visual style (info, success, warning, error) |
timeout | Option<u32> | No | None | Seconds before auto-dismiss (default: 5) |
dismissible | bool | No | true | Whether the user can dismiss the toast |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, ToastProps, ToastVariant}; ComponentNode { key: "save-toast".to_string(), component: Component::Toast(ToastProps { message: "Changes saved successfully.".to_string(), variant: ToastVariant::Success, timeout: Some(3), dismissible: true, }), action: None, visibility: None, } }
JSON output:
{ "key": "save-toast", "type": "Toast", "message": "Changes saved successfully.", "variant": "success", "timeout": 3, "dismissible": true }
Navigation Components
Sidebar
Sidebar navigation shell with fixed top items, grouped items, and fixed bottom items. Typically used as a component inside a DashboardLayout. When used standalone it renders as a vertical navigation panel.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
fixed_top | Vec<SidebarNavItem> | No | [] | Items pinned at the top (e.g., logo/home link) |
groups | Vec<SidebarGroup> | No | [] | Collapsible navigation groups |
fixed_bottom | Vec<SidebarNavItem> | No | [] | Items pinned at the bottom (e.g., settings, logout) |
SidebarNavItem defines a navigation link:
| Field | Type | Required | Description |
|---|---|---|---|
label | String | Yes | Link text |
href | String | Yes | Link URL |
icon | Option<String> | No | Icon name |
active | bool | No (default: false) | Whether this is the current page |
SidebarGroup defines a labeled, collapsible group:
| Field | Type | Required | Description |
|---|---|---|---|
label | String | Yes | Group heading text |
collapsed | bool | No (default: false) | Whether the group starts collapsed |
items | Vec<SidebarNavItem> | Yes | Navigation links in this group |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, SidebarProps, SidebarNavItem, SidebarGroup}; ComponentNode { key: "sidebar".to_string(), component: Component::Sidebar(SidebarProps { fixed_top: vec![ SidebarNavItem { label: "Dashboard".to_string(), href: "/".to_string(), icon: Some("home".to_string()), active: true }, ], groups: vec![ SidebarGroup { label: "Management".to_string(), collapsed: false, items: vec![ SidebarNavItem { label: "Users".to_string(), href: "/users".to_string(), icon: Some("users".to_string()), active: false }, SidebarNavItem { label: "Orders".to_string(), href: "/orders".to_string(), icon: Some("shopping-bag".to_string()), active: false }, ], }, ], fixed_bottom: vec![ SidebarNavItem { label: "Settings".to_string(), href: "/settings".to_string(), icon: Some("cog".to_string()), active: false }, ], }), action: None, visibility: None, } }
JSON output:
{
"key": "sidebar",
"type": "Sidebar",
"fixed_top": [{ "label": "Dashboard", "href": "/", "icon": "home", "active": true }],
"groups": [
{
"label": "Management",
"collapsed": false,
"items": [
{ "label": "Users", "href": "/users", "icon": "users", "active": false },
{ "label": "Orders", "href": "/orders", "icon": "shopping-bag", "active": false }
]
}
],
"fixed_bottom": [{ "label": "Settings", "href": "/settings", "icon": "cog", "active": false }]
}
Header
Application header shell with business name, user info, notification count, and logout link. Typically used inside DashboardLayout.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
business_name | String | Yes | - | Application name shown in the header |
notification_count | Option<u32> | No | None | Unread notification count for badge display |
user_name | Option<String> | No | None | Current user's name |
user_avatar | Option<String> | No | None | Current user's avatar URL |
logout_url | Option<String> | No | None | URL for the logout link |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, HeaderProps}; ComponentNode { key: "header".to_string(), component: Component::Header(HeaderProps { business_name: "My App".to_string(), notification_count: Some(3), user_name: Some("Alice Johnson".to_string()), user_avatar: None, logout_url: Some("/logout".to_string()), }), action: None, visibility: None, } }
JSON output:
{
"key": "header",
"type": "Header",
"business_name": "My App",
"notification_count": 3,
"user_name": "Alice Johnson",
"logout_url": "/logout"
}
NotificationDropdown
A dropdown list of notification items, typically rendered inside a Header. Displays a list of recent notifications with read/unread state, timestamps, and optional action URLs.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
notifications | Vec<NotificationItem> | Yes | - | List of notifications |
empty_text | Option<String> | No | None | Text to show when the list is empty |
NotificationItem defines a single notification:
| Field | Type | Required | Description |
|---|---|---|---|
text | String | Yes | Notification message |
icon | Option<String> | No | Icon name |
timestamp | Option<String> | No | Time string (e.g., "2 minutes ago") |
read | bool | No (default: false) | Whether the notification has been read |
action_url | Option<String> | No | URL to navigate to on click |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, NotificationDropdownProps, NotificationItem}; ComponentNode { key: "notifications".to_string(), component: Component::NotificationDropdown(NotificationDropdownProps { notifications: vec![ NotificationItem { icon: Some("bell".to_string()), text: "New order received".to_string(), timestamp: Some("5 minutes ago".to_string()), read: false, action_url: Some("/orders/123".to_string()), }, NotificationItem { icon: None, text: "Payment processed".to_string(), timestamp: Some("1 hour ago".to_string()), read: true, action_url: None, }, ], empty_text: Some("No new notifications".to_string()), }), action: None, visibility: None, } }
JSON output:
{
"key": "notifications",
"type": "NotificationDropdown",
"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 }
]
}
Onboarding Components
Checklist
Step-by-step onboarding or task checklist with dismissal and optional server-side state persistence.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
title | String | Yes | - | Checklist title |
items | Vec<ChecklistItem> | Yes | - | Checklist items |
dismissible | bool | No | true | Whether the checklist can be dismissed |
dismiss_label | Option<String> | No | None | Custom dismiss button label |
data_key | Option<String> | No | None | Server-side state persistence key |
ChecklistItem defines a checklist step:
| Field | Type | Required | Description |
|---|---|---|---|
label | String | Yes | Step description |
checked | bool | No (default: false) | Whether this step is complete |
href | Option<String> | No | Link to take the step |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, ChecklistProps, ChecklistItem}; ComponentNode { key: "setup-checklist".to_string(), component: Component::Checklist(ChecklistProps { title: "Get Started".to_string(), items: vec![ ChecklistItem { label: "Create your account".to_string(), checked: true, href: None }, ChecklistItem { label: "Set up billing".to_string(), checked: false, href: Some("/billing".to_string()) }, ChecklistItem { label: "Invite your team".to_string(), checked: false, href: Some("/team/invite".to_string()) }, ], dismissible: true, dismiss_label: Some("Done".to_string()), data_key: Some("onboarding_checklist".to_string()), }), action: None, visibility: None, } }
JSON output:
{
"key": "setup-checklist",
"type": "Checklist",
"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" }
]
}
Extensible Components
Plugin
Passes through to a registered plugin component. The plugin_type field selects the plugin from the global registry.
| Prop | Type | Required | Description |
|---|---|---|---|
plugin_type | String | Yes | Registered plugin type name (e.g., "Map") |
props | serde_json::Value | Yes | Raw props passed to the plugin's render function |
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, PluginProps}; ComponentNode { key: "office-map".to_string(), component: Component::Plugin(PluginProps { plugin_type: "Map".to_string(), props: serde_json::json!({ "center": [51.505, -0.09], "zoom": 13, "markers": [ { "lat": 51.505, "lng": -0.09, "popup": "Our office" } ] }), }), action: None, visibility: None, } }
See Plugins for the full plugin guide — how to build, register, and use custom plugin components.
JSON output:
{
"key": "office-map",
"type": "Map",
"center": [51.505, -0.09],
"zoom": 13,
"markers": [{ "lat": 51.505, "lng": -0.09, "popup": "Our office" }]
}
JSON Serialization
Every component tree serializes to JSON via serde. The Component enum uses serde's tagged representation, producing a "type" field that identifies the component:
{
"key": "welcome-card",
"type": "Card",
"title": "Welcome",
"description": "Your dashboard",
"children": [
{
"key": "greeting",
"type": "Text",
"content": "Hello, Alice!",
"element": "h2"
}
]
}
The ComponentNode fields (key, action, visibility) are flattened into the same JSON object alongside the component-specific props. This produces clean, predictable JSON that frontend renderers can consume directly.
Optional fields with None values are omitted from serialization (skip_serializing_if = "Option::is_none"). Default values for enums (e.g., ButtonVariant::Default) are always included.
Actions
Actions connect UI elements to Ferro handlers for navigation, form submission, and destructive operations.
How Actions Work
Every interactive element in JSON-UI uses an Action to declare what happens when the user interacts with it. 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
- Success and error outcomes control what happens after the server responds
Creating Actions
The Action struct provides builder methods for common HTTP methods:
#![allow(unused)] fn main() { use ferro::Action; // Form submission (POST, the default) Action::new("users.store") // Navigation (GET) Action::get("users.index") // Deletion (DELETE) Action::delete("users.destroy") }
To override the HTTP method explicitly:
#![allow(unused)] fn main() { use ferro::{Action, HttpMethod}; Action::new("users.update").method(HttpMethod::Put) }
Available methods: Get, Post, Put, Patch, Delete.
JSON Equivalent
{
"handler": "users.store",
"method": "POST"
}
The method field serializes as uppercase ("GET", "POST", "DELETE", etc.) and defaults to "POST" when omitted.
Route Parameters
Pass route parameters using the handler's registered route pattern. The framework resolves "users.show" to its registered path (e.g., /users/{user}), and the frontend renderer substitutes parameters from row data.
#![allow(unused)] fn main() { // Table row actions receive parameters from each row's data Action::get("users.show") Action::delete("users.destroy") .confirm_danger("Delete this user?") }
Confirmations
Actions can show a confirmation dialog before executing. Two variants are available:
#![allow(unused)] fn main() { use ferro::Action; // Standard confirmation Action::new("users.store") .confirm("Save changes?") // Destructive confirmation (danger styling) Action::delete("users.destroy") .confirm_danger("Delete this user?") }
The ConfirmDialog struct behind these builders has three fields:
| Field | Type | Description |
|---|---|---|
title | String | Dialog heading text |
message | Option<String> | Optional detail text |
variant | DialogVariant | Default or Danger |
JSON Equivalent
{
"handler": "users.destroy",
"method": "DELETE",
"confirm": {
"title": "Delete this user?",
"variant": "danger"
}
}
Success Outcomes
The on_success field controls what happens after the action completes. Four outcome types are available:
| Outcome | Description |
|---|---|
Redirect { url } | Navigate to a URL |
Refresh | Reload the current page |
ShowErrors | Display validation errors on form fields |
Notify { message, variant } | Show a notification toast |
#![allow(unused)] fn main() { use ferro::{Action, ActionOutcome, NotifyVariant}; // Redirect after successful creation Action::new("users.store") .on_success(ActionOutcome::Redirect { url: "/users".to_string(), }) // Refresh the current page Action::new("settings.update") .on_success(ActionOutcome::Refresh) // Show a notification Action::new("users.store") .on_success(ActionOutcome::Notify { message: "User created".to_string(), variant: NotifyVariant::Success, }) }
Notification variants: Success, Info, Warning, Error.
Error Outcomes
The on_error field works identically and controls behavior when the action fails:
#![allow(unused)] fn main() { Action::new("users.store") .on_error(ActionOutcome::ShowErrors) }
JSON Equivalent
{
"handler": "users.store",
"method": "POST",
"on_success": {
"type": "redirect",
"url": "/users"
},
"on_error": {
"type": "show_errors"
}
}
Outcome types serialize with a type discriminator: "redirect", "refresh", "show_errors", "notify".
Actions on Components
Actions attach to components in three places:
ComponentNode Action
Any component can have an action via the action field on ComponentNode. This makes the entire component interactive:
#![allow(unused)] fn main() { use ferro::{ ComponentNode, Component, ButtonProps, ButtonVariant, Size, Action, }; ComponentNode { key: "create-btn".to_string(), component: Component::Button(ButtonProps { label: "Create User".to_string(), variant: ButtonVariant::Default, size: Size::Default, disabled: None, icon: None, icon_position: None, }), action: Some(Action::get("users.create")), visibility: None, } }
Form Action
Forms have a dedicated action field on FormProps that defines the submission endpoint:
#![allow(unused)] fn main() { use ferro::{Component, FormProps, Action}; Component::Form(FormProps { action: Action::new("users.store"), fields: vec![/* ... */], method: None, }) }
Table Row Actions
Tables support per-row actions via row_actions on TableProps:
#![allow(unused)] fn main() { use ferro::Action; let row_actions = vec![ Action::get("users.show"), Action::delete("users.destroy") .confirm_danger("Delete this user?"), ]; }
URL Resolution
The framework resolves action handler names to URLs automatically during rendering. When JsonUi::render() or JsonUi::render_json() is called, the view is cloned and all actions are walked recursively. Each handler name (e.g., "users.store") is looked up in the route registry and the resolved URL is set on the action's url field.
If a handler cannot be resolved, its url remains None. The original view is never mutated.
#![allow(unused)] fn main() { use ferro::{JsonUi, JsonUiView}; // Actions are resolved automatically during render let view = JsonUiView::new() .title("Users") .component(/* component with action */); JsonUi::render(&view, &serde_json::json!({})) }
Data Binding & Visibility
Pre-fill form fields from handler data and conditionally show or hide components based on data state.
Handler Data
JSON-UI views receive data from the handler at render time. This data drives form pre-filling and visibility conditions.
#![allow(unused)] fn main() { use ferro::{JsonUi, JsonUiView}; let view = JsonUiView::new() .title("Edit User") .component(/* ... */); let data = serde_json::json!({ "user": { "name": "Alice", "email": "alice@example.com", "role": "admin" } }); // Data passed as second argument JsonUi::render(&view, &data) }
Components reference this data via slash-separated paths to pre-fill values or control visibility.
Data Paths
Data paths are slash-separated strings that resolve against the handler's JSON data. They follow the format /segment/segment/... where each segment is an object key or array index.
/user/name -> "Alice"
/user/email -> "alice@example.com"
/users/0/name -> first user's name (array index)
/meta/total -> numeric value
Path Resolution Rules
- Leading slash is required for non-empty paths
- Empty path or
"/"returns the root value - Object keys are matched by name
- Array elements are accessed by numeric index
- Missing keys or out-of-bounds indices return
None
Form Field Pre-filling
Form field components (Input, Select, Checkbox, Switch) support the data_path field. At render time, the path is resolved against the handler data and the result pre-fills the field.
#![allow(unused)] fn main() { use ferro::{Component, InputProps, InputType}; Component::Input(InputProps { field: "name".to_string(), label: "Name".to_string(), input_type: InputType::Text, placeholder: None, required: None, disabled: None, error: None, description: None, default_value: None, data_path: Some("/user/name".to_string()), }) }
JSON Equivalent
{
"type": "Input",
"field": "name",
"label": "Name",
"input_type": "text",
"data_path": "/user/name"
}
When the handler data contains {"user": {"name": "Alice"}}, the input is pre-filled with "Alice".
View Data
Data can come from two sources:
Embedded Data
Views can carry their own data via JsonUiView::data(). This is useful for self-contained views that don't need handler data:
#![allow(unused)] fn main() { let view = JsonUiView::new() .title("Dashboard") .data(serde_json::json!({ "stats": { "total_users": 150, "active": 42 } })) .component(/* ... */); }
Explicit Handler Data
When rendering, explicit data is passed as the second argument to JsonUi::render():
#![allow(unused)] fn main() { let data = serde_json::json!({"users": users_list}); JsonUi::render(&view, &data) }
When both sources exist, explicit handler data takes priority. This is the "live data override" pattern: embedded data provides defaults, explicit data provides current state.
For JsonUi::render_json(), if the explicit data is null, the view's embedded data is used as fallback.
Visibility Rules
Components can be conditionally shown or hidden based on data conditions. Attach a visibility rule via the visibility field on ComponentNode.
Simple Conditions
A VisibilityCondition checks a data path against an operator and optional value:
#![allow(unused)] fn main() { use ferro::{JsonUiVisibility, VisibilityCondition, VisibilityOperator}; // Show component only when users array is not empty JsonUiVisibility::Condition(VisibilityCondition { path: "/data/users".to_string(), operator: VisibilityOperator::NotEmpty, value: None, }) }
JSON Equivalent
{
"path": "/data/users",
"operator": "not_empty"
}
Available Operators
| Operator | Serialized | Value Required | Description |
|---|---|---|---|
Exists | exists | No | Path resolves to a non-null value |
NotExists | not_exists | No | Path does not resolve |
Eq | eq | Yes | Value equals |
NotEq | not_eq | Yes | Value does not equal |
Gt | gt | Yes | Greater than |
Lt | lt | Yes | Less than |
Gte | gte | Yes | Greater than or equal |
Lte | lte | Yes | Less than or equal |
Contains | contains | Yes | String or array contains value |
NotEmpty | not_empty | No | Value is not empty (non-null, non-empty string/array) |
Empty | empty | No | Value is empty or null |
Condition with Value
For comparison operators, pass the value to compare against:
#![allow(unused)] fn main() { JsonUiVisibility::Condition(VisibilityCondition { path: "/auth/user/role".to_string(), operator: VisibilityOperator::Eq, value: Some(serde_json::json!("admin")), }) }
{
"path": "/auth/user/role",
"operator": "eq",
"value": "admin"
}
Compound Visibility
Visibility rules support logical composition with And, Or, and Not operators.
And
All conditions must be true:
#![allow(unused)] fn main() { use ferro::{JsonUiVisibility, VisibilityCondition, VisibilityOperator}; JsonUiVisibility::And { and: vec![ JsonUiVisibility::Condition(VisibilityCondition { path: "/auth/user".to_string(), operator: VisibilityOperator::Exists, value: None, }), JsonUiVisibility::Condition(VisibilityCondition { path: "/auth/user/role".to_string(), operator: VisibilityOperator::Eq, value: Some(serde_json::json!("admin")), }), ], } }
{
"and": [
{ "path": "/auth/user", "operator": "exists" },
{ "path": "/auth/user/role", "operator": "eq", "value": "admin" }
]
}
Or
Any condition must be true:
{
"or": [
{ "path": "/data/status", "operator": "eq", "value": "active" },
{ "path": "/data/status", "operator": "eq", "value": "pending" }
]
}
Not
Negate a condition:
{
"not": { "path": "/data/is_deleted", "operator": "exists" }
}
Compound rules can be nested arbitrarily. The Visibility enum uses serde's untagged representation, so the JSON format is clean without a type discriminator.
Attaching to Components
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, TextProps, TextElement, JsonUiVisibility, VisibilityCondition, VisibilityOperator}; ComponentNode { key: "admin-notice".to_string(), component: Component::Text(TextProps { content: "Admin access granted".to_string(), element: TextElement::P, }), action: None, visibility: Some(JsonUiVisibility::Condition(VisibilityCondition { path: "/auth/user/role".to_string(), operator: VisibilityOperator::Eq, value: Some(serde_json::json!("admin")), })), } }
Validation Errors
The framework integrates validation errors with form field components. When a form submission fails validation, errors are resolved onto matching form fields automatically.
Rendering with Errors
Use JsonUi::render_with_errors() to populate error messages on form fields:
#![allow(unused)] fn main() { use std::collections::HashMap; use ferro::{JsonUi, JsonUiView}; let mut errors = HashMap::new(); errors.insert("email".to_string(), vec!["Email is required".to_string()]); errors.insert("name".to_string(), vec!["Name is too short".to_string()]); JsonUi::render_with_errors(&view, &data, &errors) }
Or use the ValidationError type directly:
#![allow(unused)] fn main() { use ferro::{JsonUi, JsonUiView, ValidationError}; let validation_error: ValidationError = /* from validator */; JsonUi::render_validation_error(&view, &data, &validation_error) }
How Error Resolution Works
The framework walks the component tree and matches error keys to form field field names:
- For each
Input,Select,Checkbox, andSwitchcomponent, the resolver checks if the errors map contains the field name - If found, the first error message is set on the component's
errorprop - Existing explicit errors are never overwritten (do-not-overwrite rule)
- The full errors map is also set on
view.errorsfor global display
View-Level Errors
After error resolution, view.errors contains the complete error map. Frontend renderers can use this for displaying a global error summary above the form:
{
"errors": {
"email": ["Email is required"],
"name": ["Name is too short", "Name must be alphanumeric"]
}
}
Individual field components receive the first error message on their error prop. Use resolve_errors_all() at the crate level to join all messages with ". " instead.
Layouts
Layouts wrap JSON-UI pages with consistent navigation, headers, and page structure.
How Layouts Work
Each JSON-UI view can specify a layout name. At render time, the framework looks up the layout in a LayoutRegistry and wraps the rendered component HTML in a full HTML page shell.
- View specifies a layout:
JsonUiView::new().layout("dashboard") - Components are rendered to HTML
- The layout wraps the HTML in a complete page with
<head>, navigation, and<body>structure - The view JSON and data are embedded as
data-viewanddata-propsattributes for potential frontend hydration
Using a Layout
Set the layout name on the view builder:
#![allow(unused)] fn main() { use ferro::JsonUiView; let view = JsonUiView::new() .title("Dashboard") .layout("dashboard"); }
If no layout is set, the "default" layout is used. If a named layout is not found in the registry, rendering falls back to the default layout.
Default Layout
The built-in "default" layout produces a minimal HTML page with no navigation or sidebar. Use it for simple pages, reports, or content that does not require persistent navigation.
#![allow(unused)] fn main() { // No .layout() call — uses "default" automatically: let view = JsonUiView::new() .title("Report"); // Or explicitly: let view = JsonUiView::new() .title("Simple Page") .layout("default"); }
DashboardLayout
DashboardLayout is the primary layout for application dashboards. It renders a persistent sidebar on the left (collapsible on mobile), a sticky header at the top, and a content area in the main panel.
Unlike the default layout, DashboardLayout requires per-application configuration (sidebar navigation and header data) and must be registered at startup. The layout also injects the ferro JS runtime automatically, enabling SSE live-value updates, toast notifications, and sidebar toggle behavior.
DashboardLayoutConfig
| Field | Type | Required | Description |
|---|---|---|---|
sidebar | SidebarProps | Yes | Sidebar navigation data |
header | HeaderProps | Yes | Header data (business name, user info, notifications) |
sse_url | Option<String> | No | SSE endpoint URL for live updates |
SidebarProps fields:
| Field | Type | Description |
|---|---|---|
fixed_top | Vec<SidebarNavItem> | Items pinned at the top (logo, home link) |
groups | Vec<SidebarGroup> | Collapsible navigation groups |
fixed_bottom | Vec<SidebarNavItem> | Items pinned at the bottom (settings, logout) |
HeaderProps fields:
| Field | Type | Description |
|---|---|---|
business_name | String | Application name displayed in the header |
notification_count | Option<u32> | Unread notification count for badge display |
user_name | Option<String> | Current user's name |
user_avatar | Option<String> | Current user's avatar URL |
logout_url | Option<String> | URL for the logout link |
Registering the Dashboard Layout
Register DashboardLayout at application startup, before the server handles requests:
#![allow(unused)] fn main() { use ferro::{ DashboardLayout, DashboardLayoutConfig, HeaderProps, SidebarProps, SidebarGroup, SidebarNavItem, register_layout, }; register_layout("dashboard", DashboardLayout::new(DashboardLayoutConfig { sidebar: SidebarProps { fixed_top: vec![ SidebarNavItem { label: "Dashboard".to_string(), href: "/".to_string(), icon: Some("home".to_string()), active: false, // set per-request in handler }, ], groups: vec![ SidebarGroup { label: "Management".to_string(), collapsed: false, items: vec![ SidebarNavItem { label: "Users".to_string(), href: "/users".to_string(), icon: Some("users".to_string()), active: false }, SidebarNavItem { label: "Orders".to_string(), href: "/orders".to_string(), icon: Some("shopping-bag".to_string()), active: false }, ], }, ], fixed_bottom: vec![ SidebarNavItem { label: "Settings".to_string(), href: "/settings".to_string(), icon: Some("cog".to_string()), active: false }, ], }, header: HeaderProps { business_name: "My App".to_string(), notification_count: None, user_name: Some("Alice".to_string()), user_avatar: None, logout_url: Some("/logout".to_string()), }, sse_url: Some("/dashboard/events".into()), })); }
Using It in a View
#![allow(unused)] fn main() { use ferro::{JsonUiView, ComponentNode, ComponentNode::stat_card, StatCardProps}; let view = JsonUiView::new() .title("Dashboard") .layout("dashboard") .component(ComponentNode::stat_card("revenue", StatCardProps { label: "Total Revenue".to_string(), value: "€12,345".to_string(), icon: Some("currency-euro".to_string()), subtitle: Some("This month".to_string()), sse_target: Some("revenue_total".to_string()), })); }
Mobile Behavior
On screens narrower than the md breakpoint (768px):
- The sidebar is hidden by default (
hidden md:flexTailwind class) - A hamburger button appears in the header (
data-sidebar-toggle) - Clicking the hamburger toggles the
data-sidebar-openattribute on the<body>element - The JS runtime toggles sidebar visibility in response
No additional JavaScript configuration is needed. The runtime handles this automatically.
JS Runtime
The DashboardLayout injects the ferro JS runtime as a <script> tag before </body>. The runtime is a small self-contained IIFE that activates on DOMContentLoaded and handles three behaviors:
Sidebar toggle — The hamburger button toggles mobile sidebar visibility.
SSE live-value updates — If sse_url is set on DashboardLayoutConfig, the runtime opens an EventSource connection. Incoming live-value events update elements with matching data-sse-target attributes. Use this with StatCard.sse_target to update metric values without page reloads.
Server-sent event format:
event: live-value
data: {"target": "revenue_total", "value": "€13,210"}
Toast notifications — Incoming toast events display overlay notifications. A data-toast-container div is injected by the layout for mounting toasts.
Server-sent event format:
event: toast
data: {"message": "New order received", "variant": "success"}
You can also display toasts declaratively by including a Toast component in any view rendered by DashboardLayout.
Creating Custom Layouts
Implement the Layout trait to create a custom layout:
#![allow(unused)] fn main() { use ferro::{Layout, LayoutContext}; pub struct CustomLayout; impl Layout for CustomLayout { fn render(&self, ctx: &LayoutContext) -> String { format!( r#"<!DOCTYPE html> <html> <head> <title>{title}</title> {head} </head> <body> <header>My App</header> <main>{content}</main> <footer>Copyright 2026</footer> {scripts} </body> </html>"#, title = ctx.title, head = ctx.head, content = ctx.content, scripts = ctx.scripts, ) } } }
The Layout trait requires Send + Sync for thread-safe access from the global registry.
Registering Custom Layouts
Register layouts at application startup:
#![allow(unused)] fn main() { use ferro::register_layout; register_layout("custom", CustomLayout); }
Or register directly on a LayoutRegistry:
#![allow(unused)] fn main() { use ferro::LayoutRegistry; let mut registry = LayoutRegistry::new(); registry.register("custom", CustomLayout); }
Registering with an existing name replaces the previous layout.
Layout Context
The LayoutContext struct provides all data a layout needs to produce a complete HTML page:
| Field | Type | Description |
|---|---|---|
title | &str | Page title from the view (defaults to "Ferro") |
content | &str | Rendered component HTML fragment |
head | &str | Additional <head> content (Tailwind CDN, custom styles) |
body_class | &str | CSS classes for the <body> element |
view_json | &str | Serialized view JSON for the data-view attribute |
data_json | &str | Serialized data JSON for the data-props attribute |
scripts | &str | JS assets and init scripts for plugins, injected before closing body tag |
The view_json and data_json fields enable frontend JavaScript to hydrate the page from the server-rendered HTML. All built-in layouts embed these in a <div id="ferro-json-ui"> wrapper.
Always include ctx.scripts in custom layouts — it contains plugin JS assets and the ferro runtime when render_to_html_with_plugins is used.
Navigation Helpers
The layout module provides partial rendering functions for building navigation:
NavItem
#![allow(unused)] fn main() { use ferro::NavItem; let items = vec![ NavItem::new("Home", "/").active(), NavItem::new("Users", "/users"), NavItem::new("Settings", "/settings"), ]; }
Active items are highlighted with distinct styling. The active() builder method marks an item as the current page.
SidebarSection
#![allow(unused)] fn main() { use ferro::{SidebarSection, NavItem}; let sections = vec![ SidebarSection::new("Main Menu", vec![ NavItem::new("Dashboard", "/"), NavItem::new("Users", "/users"), ]), SidebarSection::new("Settings", vec![ NavItem::new("Profile", "/settings/profile"), NavItem::new("Security", "/settings/security"), ]), ]; }
The built-in navigation() and sidebar() functions render these into HTML with Tailwind classes. Use them in fully custom layout implementations to build consistent navigation.
Render Configuration
JsonUiConfig controls rendering behavior:
#![allow(unused)] fn main() { use ferro::JsonUiConfig; let config = JsonUiConfig::new() .tailwind_cdn(false) // Disable Tailwind CDN (default: true) .body_class("dark bg-black") // Custom body CSS classes .custom_head(r#"<link rel="stylesheet" href="/custom.css">"#); }
| Field | Default | Description |
|---|---|---|
tailwind_cdn | true | Include Tailwind CDN <script> in <head> |
custom_head | None | Custom HTML to inject into <head> |
body_class | "bg-white text-gray-900" | CSS classes for <body> |
Pass the config to the render call:
#![allow(unused)] fn main() { use ferro::{JsonUi, JsonUiView, JsonUiConfig}; let view = JsonUiView::new() .title("Dashboard") .layout("dashboard"); let config = JsonUiConfig::new().tailwind_cdn(false); JsonUi::render_with_config(&view, &serde_json::json!({}), &config) }
For production, disable the Tailwind CDN and serve your own compiled CSS via custom_head.
Plugins
Plugins extend the JSON-UI component catalog with custom interactive components that require client-side JavaScript or CSS. A plugin registers itself under a unique type name; the renderer calls it whenever a Component::Plugin with that type name is encountered.
What Plugins Are
The 26 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 and page data
- CSS and JS asset declarations collected once per page and deduplicated
The JsonUiPlugin Trait
#![allow(unused)] fn main() { pub trait JsonUiPlugin: Send + Sync { /// Unique component type name. Must not collide with built-in component names. fn component_type(&self) -> &str; /// JSON Schema describing accepted props. /// Used by MCP/agents for discovery and validation. fn props_schema(&self) -> serde_json::Value; /// Render the component to an HTML string. /// Receives raw props JSON and the view's data for data_path resolution. fn render(&self, props: &serde_json::Value, data: &serde_json::Value) -> String; /// CSS assets to load in <head>. Deduplicated by URL across the page. fn css_assets(&self) -> Vec<Asset>; /// JS assets to load before </body>. Deduplicated by URL across the page. fn js_assets(&self) -> Vec<Asset>; /// Inline initialization JS emitted once per page after assets load. /// Returns None if no initialization is needed. fn init_script(&self) -> Option<String>; } }
The Asset type represents a CSS or JS URL with optional Subresource Integrity (SRI) attributes:
#![allow(unused)] fn main() { Asset::new("https://cdn.example.com/lib.js") .integrity("sha256-...") .crossorigin("") }
Registering a Plugin
Register plugins at application startup, before handling any requests:
#![allow(unused)] fn main() { use ferro::{register_plugin, JsonUiPlugin, Asset}; struct ChartPlugin; impl JsonUiPlugin for ChartPlugin { fn component_type(&self) -> &str { "Chart" } // ... other methods } // In your app startup (e.g., main.rs or bootstrap): register_plugin(ChartPlugin); }
register_plugin writes into a global RwLock<PluginRegistry>. Registering a type name that already exists replaces the previous plugin. Registration is idempotent-safe: the last registration wins.
If you have multiple plugins, register them all before the server starts accepting connections:
#![allow(unused)] fn main() { register_plugin(ChartPlugin); register_plugin(RichTextPlugin); register_plugin(VideoPlugin); }
Using a Plugin in a View
Use Component::Plugin with the registered type name:
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, PluginProps}; let node = ComponentNode { key: "sales-chart".to_string(), component: Component::Plugin(PluginProps { plugin_type: "Chart".to_string(), props: serde_json::json!({ "type": "bar", "data_path": "/data/sales", "x_key": "month", "y_key": "revenue" }), }), action: None, visibility: None, }; }
The framework serializes this as:
{
"key": "sales-chart",
"type": "Chart",
"type": "bar",
"data_path": "/data/sales",
"x_key": "month",
"y_key": "revenue"
}
The plugin_type field becomes the "type" discriminant in JSON. The remaining props fields are merged at the same level.
How Assets Are Collected and Injected
When rendering a page with render_to_html_with_plugins, the renderer:
- Renders all components in order
- Collects the plugin type names encountered during rendering
- Calls
css_assets()andjs_assets()on each unique plugin type - Deduplicates assets by URL (two Map components on the same page load Leaflet once)
- Injects CSS
<link>tags into<head>, JS<script>tags before</body> - Emits
init_script()output inline after the JS assets
#![allow(unused)] fn main() { use ferro::{JsonUi, JsonUiView}; // render_to_html_with_plugins collects and injects plugin assets automatically let response = JsonUi::render_with_plugins(&view, &data); }
If you use the bare JsonUi::render, plugin HTML is still rendered but assets are not injected — use render_with_plugins in production.
Example: Building a Chart Plugin
This example shows the full trait implementation pattern for a hypothetical Chart plugin using Chart.js:
#![allow(unused)] fn main() { use ferro::{JsonUiPlugin, Asset}; use serde::{Deserialize, Serialize}; 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", "description": "A Chart.js bar or line chart.", "required": ["data_path"], "properties": { "chart_type": { "type": "string", "enum": ["bar", "line", "pie"], "default": "bar", "description": "Chart type" }, "data_path": { "type": "string", "description": "JSON pointer to the data array (e.g., '/data/sales')" }, "x_key": { "type": "string", "description": "Key in each data object for the X-axis" }, "y_key": { "type": "string", "description": "Key in each data object for the Y-axis" }, "height": { "type": "string", "default": "300px", "description": "CSS height of the chart container" } } }) } fn render(&self, props: &serde_json::Value, _data: &serde_json::Value) -> String { let chart_type = props.get("chart_type").and_then(|v| v.as_str()).unwrap_or("bar"); let data_path = props.get("data_path").and_then(|v| v.as_str()).unwrap_or("/data"); let x_key = props.get("x_key").and_then(|v| v.as_str()).unwrap_or("label"); let y_key = props.get("y_key").and_then(|v| v.as_str()).unwrap_or("value"); let height = props.get("height").and_then(|v| v.as_str()).unwrap_or("300px"); let config = serde_json::json!({ "type": chart_type, "data_path": data_path, "x_key": x_key, "y_key": y_key }); format!( "<canvas data-ferro-chart='{}' style=\"height: {}; width: 100%;\"></canvas>", serde_json::to_string(&config).unwrap_or_default(), height ) } fn css_assets(&self) -> Vec<Asset> { // Chart.js has no CSS dependency vec![] } fn js_assets(&self) -> Vec<Asset> { vec![ Asset::new("https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js") .integrity("sha256-...") .crossorigin("anonymous"), ] } 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 using cfg.type, cfg.data_path, etc. }); "#.to_string()) } } }
Register it at startup:
#![allow(unused)] fn main() { register_plugin(ChartPlugin); }
Use it in a view:
#![allow(unused)] fn main() { ComponentNode { key: "monthly-sales".to_string(), component: Component::Plugin(PluginProps { plugin_type: "Chart".to_string(), props: serde_json::json!({ "chart_type": "bar", "data_path": "/data/sales", "x_key": "month", "y_key": "revenue", "height": "400px" }), }), action: None, visibility: None, } }
Built-in Plugins
MapPlugin (Leaflet Integration)
The MapPlugin is auto-registered in the global plugin registry when the first view is rendered. It uses Leaflet 1.9.4 to render interactive maps.
Component type: "Map"
Props:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
center | [f64; 2] | No | - | Map center [lat, lng]. Optional with fit_bounds: true |
zoom | u8 | No | 13 | Initial zoom level (0-18) |
height | String | No | "400px" | CSS height of the map container |
fit_bounds | bool | No | false | Auto-zoom to fit all markers; ignores center/zoom if markers present |
markers | Vec<MapMarker> | 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 | u8 | No | 19 | Maximum zoom level |
MapMarker fields:
| Field | Type | Required | Description |
|---|---|---|---|
lat | f64 | Yes | Marker latitude |
lng | f64 | Yes | Marker 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 a colored pin icon (e.g., "#3B82F6") |
href | String | No | URL to navigate to on marker click |
Assets loaded: Leaflet CSS and JS from unpkg CDN with SRI hashes. Both assets include crossorigin="" for SRI verification.
Example:
#![allow(unused)] fn main() { use ferro::{ComponentNode, Component, PluginProps}; ComponentNode { key: "office-locations".to_string(), component: Component::Plugin(PluginProps { plugin_type: "Map".to_string(), props: serde_json::json!({ "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" } ] }), }), action: None, visibility: None, } }
Discovering Registered Plugins
To see all currently registered plugin types (useful in MCP tools and diagnostics):
#![allow(unused)] fn main() { use ferro::registered_plugin_types; let types = registered_plugin_types(); // Returns a sorted Vec<String> e.g. ["Map", "Chart"] }
The ferro-mcp server also exposes plugin types via the list_json_ui_plugins tool for agent discovery.
CLI Reference
Ferro provides a powerful CLI tool for project scaffolding, code generation, database management, and development workflow automation.
Installation
cargo install ferro-cli
Or build from source:
git clone https://github.com/albertogferrario/ferro
cd ferro/ferro-cli
cargo install --path .
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.rs
#![allow(unused)] fn main() { use ferro::{ Action, Component, ComponentNode, JsonUiView, TableColumn, TableProps, TextElement, TextProps, }; pub fn view() -> JsonUiView { JsonUiView::new() .title("User Index") .layout("app") .component(ComponentNode { key: "heading".to_string(), component: Component::Text(TextProps { content: "User Index".to_string(), element: TextElement::H1, }), action: None, visibility: None, }) // ... additional components based on AI context or static template } }
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 from existing database
ferro db:sync
# Run migrations first, then sync
ferro db:sync --migrate
Options:
| Option | Description |
|---|---|
--migrate | Run pending migrations before syncing |
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 (nine 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 nine 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 | generated_artifacts | General | Dockerfile, .dockerignore, .do/app.yaml present |
| 8 | database_url_sqlite_in_prod | General | Warns if DATABASE_URL in .env.production points at SQLite |
| 9 | git_clean_and_pushed | General | Working tree clean and HEAD pushed to the tracked remote |
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 nine 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.
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.
Migration Guide: cancer to ferro
This guide documents the upgrade path from cancer to ferro (v2.0).
Overview
The framework has been renamed from "cancer" to "ferro" for crates.io publication. The API remains identical - only package names and imports have changed.
Migration Steps
1. Update Cargo.toml Dependencies
Replace all cancer dependencies with their ferro equivalents:
# Before
[dependencies]
cancer = "1.0"
cancer-events = "1.0"
cancer-queue = "1.0"
cancer-notifications = "1.0"
cancer-broadcast = "1.0"
cancer-storage = "1.0"
cancer-cache = "1.0"
# After
[dependencies]
ferro = "2.0"
ferro-events = "2.0"
ferro-queue = "2.0"
ferro-notifications = "2.0"
ferro-broadcast = "2.0"
ferro-storage = "2.0"
ferro-cache = "2.0"
2. Update Rust Imports
Replace cancer with ferro in all import statements:
#![allow(unused)] fn main() { // Before use cancer::prelude::*; use cancer_events::Event; use cancer_queue::Job; // After use ferro::prelude::*; use ferro_events::Event; use ferro_queue::Job; }
Use find-and-replace across your project:
use cancer::touse ferro::use cancer_touse ferro_cancer::toferro::(in type paths)
3. Update CLI Commands
The CLI binary has been renamed:
# Before
cancer serve
cancer make:model User
cancer migrate
# After
ferro serve
ferro make:model User
ferro db:migrate
Update any scripts, CI configurations, or documentation that reference the CLI.
4. Update MCP Server Configuration
If using the MCP server for IDE integration:
// Before
{
"mcpServers": {
"cancer-mcp": {
"command": "cancer-mcp",
"args": ["serve"]
}
}
}
// After
{
"mcpServers": {
"ferro-mcp": {
"command": "ferro-mcp",
"args": ["serve"]
}
}
}
5. Update Environment Variables (Optional)
If you have custom environment variable prefixes, consider updating them for consistency:
# Optional - these still work but consider updating
CANCER_APP_KEY=... -> FERRO_APP_KEY=...
Breaking Changes
None. The v2.0 release contains only naming changes. All APIs, behaviors, and features remain identical to v1.x.
Verification
After migration, verify your setup:
# Check CLI installation
ferro --version
# Run tests
cargo test
# Start development server
ferro serve
Gradual Migration
For large projects, you can migrate gradually using Cargo aliases:
[dependencies]
# Temporary: use new crate with old import name
cancer = { package = "ferro", version = "2.0" }
This allows keeping existing imports while transitioning. Remove the alias once all code is updated.