Ferro Framework
A Laravel-inspired web framework for Rust.
Ferro brings the developer experience of Laravel to Rust, providing familiar patterns and conventions while leveraging Rust's safety and performance.
Features
- Routing - Expressive route definitions with middleware support
- Database - SeaORM integration with migrations and models
- Validation - Laravel-style validation with declarative rules
- Authentication - Session-based auth with guards
- Inertia.js - Full-stack React/TypeScript with compile-time validation
- Events - Event dispatcher with sync/async listeners
- Queues - Background job processing with Redis
- Notifications - Multi-channel notifications (mail, database, slack)
- Broadcasting - WebSocket channels with authorization
- Storage - File storage abstraction (local, S3)
- Caching - Cache with tags support
- Testing - Test utilities and factories
Quick Example
#![allow(unused)] fn main() { use ferro::*; #[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) } }
Philosophy
Ferro aims to be the "Laravel of Rust" - a batteries-included framework that lets you build web applications quickly without sacrificing Rust's guarantees.
Convention over configuration - Sensible defaults that work out of the box.
Developer experience - Clear error messages, helpful CLI, and comprehensive documentation.
Type safety - Compile-time validation of routes, components, and queries.
Performance - Async-first design built on Tokio.
Getting Started
Ready to start building? Head to the Installation guide.
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::*; 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::*; 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
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::*; 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) }) } }
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 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] 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::*; #[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::*; #[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::new(StatusCode::ACCEPTED) .json(data)) }
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
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 |
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
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}; use ferro::container::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}; use ferro::container::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>().unwrap(); 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>().unwrap(); // 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>().unwrap(); // 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()); } }
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
Requires the s3 feature (coming soon):
[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
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
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::session::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::session::{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::auth::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::auth::{UserProvider, Authenticatable}; use ferro::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().unwrap(); 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}; use ferro::validation::{Validator, rules}; #[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_hash = ferro::hash(data["password"].as_str().unwrap())?; // Insert user let user = User::insert(NewUser { name: data["name"].as_str().unwrap().to_string(), email: data["email"].as_str().unwrap().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().unwrap(); let password = data["password"].as_str().unwrap(); // 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) |
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 }
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"]).unwrap())), ..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>().unwrap(); 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::middleware::{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::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 Integration
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>().unwrap(); 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::routing::*; 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::middleware::{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::middleware::{RateLimiter, Limit}; use ferro::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::middleware::{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::middleware::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::middleware::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.
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::database::{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::database::Model for Entity {} impl ferro::database::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::database::RouteBinding; use ferro::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::testing::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
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::validation::{Validator, rules}; 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::validation::{validate, rules}; 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() { use ferro::validation::rules::*; // 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::validation::{Validator, rules}; 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::validation::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::http::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::http::{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}$").unwrap(); } // 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::validation::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}; use ferro::validation::{Validator, rules}; #[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
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.
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::testing::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::testing::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?.unwrap(); 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; #[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::testing::{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::testing::{Factory, FactoryTraits}; 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::testing::{DatabaseFactory, Factory, Fake}; 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::testing::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::testing::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::testing::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::testing::{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::testing::{TestClient, TestDatabase, Expect, Fake}; 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}; use ferro::inertia::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}; use ferro::inertia::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}; use ferro::inertia::{Inertia, SavedInertiaContext}; use ferro::validation::{Validator, rules}; #[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}; use ferro::inertia::{Inertia, SavedInertiaContext}; use ferro::validation::{Validator, rules}; #[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::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::{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}; use ferro::inertia::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}; use ferro::inertia::{Inertia, SavedInertiaContext}; use ferro::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::{Inertia, Request, Response, Auth}; pub async fn login(req: Request) -> Response { // ... validation and auth logic ... Auth::login(user.id); Inertia::redirect(&req, "/dashboard") } 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::{Inertia, Request, Response, SavedInertiaContext}; 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'] });
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.
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, Response}; #[handler] pub async fn dashboard() -> Response { let view = JsonUiView::new() .title("Dashboard") .layout("app") .component(ComponentNode { key: "welcome".to_string(), component: Component::Card(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![], }), action: None, visibility: None, }); 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 app 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("app") .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, }), 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, }), 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("app") .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()), }), 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()), }), 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::validation::{Validator, rules}; #[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("app") .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 }, 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, }), action: None, visibility: 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 20 built-in component types
- Actions -- Navigation, form submission, confirmations, and outcomes
- Data Binding & Visibility -- Data paths and conditional rendering
- Layouts -- Page structure, built-in layouts, and custom layouts
Components
JSON-UI includes 20 built-in components for building complete application interfaces from Rust handlers.
Component Overview
| Category | Components |
|---|---|
| Display | Card, Table, Badge, Alert, Separator, DescriptionList, Text, Button |
| Form | Form, Input, Select, Checkbox, Switch |
| Navigation | Tabs, Breadcrumb, Pagination |
| Feedback | Progress, Avatar, Skeleton |
| Layout | Modal |
Every component is wrapped in a ComponentNode that provides a unique key, an optional action binding, and optional visibility rules:
#![allow(unused)] fn main() { use ferro::*; ComponentNode { key: "my-card".to_string(), component: Component::Card(CardProps { /* ... */ }), action: None, visibility: None, } }
Shared Types
These enums are used across multiple components.
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 the Alert component.
| 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> |
Display 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 { key: "user-card".to_string(), component: Component::Card(CardProps { title: "User Details".to_string(), description: Some("Account information".to_string()), children: vec![ ComponentNode { key: "name".to_string(), component: Component::Text(TextProps { content: "Alice Johnson".to_string(), element: TextElement::H3, }), action: None, visibility: None, }, ], footer: vec![ ComponentNode { key: "edit-btn".to_string(), component: Component::Button(ButtonProps { label: "Edit".to_string(), variant: ButtonVariant::Outline, size: Size::Default, disabled: None, icon: None, icon_position: None, }), action: Some(Action::get("users.edit")), visibility: None, }, ], }), action: None, visibility: None, } }
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 { 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, }, 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, }), action: None, visibility: None, } }
The data_path points to an array in the handler data. Each object in the array maps its keys to column key fields.
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 { key: "status".to_string(), component: Component::Badge(BadgeProps { label: "Active".to_string(), variant: BadgeVariant::Default, }), action: None, visibility: None, } }
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 { 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, } }
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 { key: "divider".to_string(), component: Component::Separator(SeparatorProps { orientation: None, // defaults to horizontal }), action: None, visibility: None, } }
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 { 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, } }
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 |
#![allow(unused)] fn main() { use ferro::*; ComponentNode { key: "heading".to_string(), component: Component::Text(TextProps { content: "Welcome to the dashboard".to_string(), element: TextElement::H1, }), action: None, visibility: None, } }
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 { key: "save-btn".to_string(), component: Component::Button(ButtonProps { label: "Save Changes".to_string(), variant: ButtonVariant::Default, size: Size::Default, disabled: None, icon: Some("save".to_string()), icon_position: Some(IconPosition::Left), }), action: Some(Action::new("users.update")), visibility: None, } }
Form 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 { 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, }), action: None, visibility: None, }, ComponentNode { key: "email-input".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, }), action: None, visibility: None, }, ], method: None, }), action: None, visibility: None, } }
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"). Controls valid increment granularity. |
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 { 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, } }
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 { 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, } }
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 { 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, } }
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 { 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, } }
Navigation Components
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 { 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![ ComponentNode { key: "name-input".to_string(), component: 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: None, }), action: None, visibility: None, }, ], }, Tab { value: "security".to_string(), label: "Security".to_string(), children: vec![ ComponentNode { key: "password-input".to_string(), component: Component::Input(InputProps { field: "password".to_string(), label: "Password".to_string(), input_type: InputType::Password, placeholder: None, required: None, disabled: None, error: None, description: None, default_value: None, data_path: None, }), action: None, visibility: None, }, ], }, ], }), action: None, visibility: None, } }
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 { 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, } }
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 { 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, } }
Feedback Components
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 { key: "upload-progress".to_string(), component: Component::Progress(ProgressProps { value: 75, max: Some(100), label: Some("Uploading...".to_string()), }), action: None, visibility: None, } }
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 { 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, } }
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 { 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, } }
Layout Components
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 { 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![ ComponentNode { key: "confirm-text".to_string(), component: Component::Text(TextProps { content: "Are you sure you want to delete this item?".to_string(), element: TextElement::P, }), action: None, visibility: None, }, ], footer: vec![ ComponentNode { key: "cancel-btn".to_string(), component: Component::Button(ButtonProps { label: "Cancel".to_string(), variant: ButtonVariant::Outline, size: Size::Default, disabled: None, icon: None, icon_position: None, }), action: None, visibility: None, }, 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
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"
}
],
"footer": []
}
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.
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_rs::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_rs::{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_rs::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_rs::{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_rs::{ 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_rs::{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_rs::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_rs::{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_rs::{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_rs::{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_rs::{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_rs::{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_rs::{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_rs::{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_rs::{JsonUi, JsonUiView}; use ferro_rs::validation::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("app") - 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_rs::JsonUiView; let view = JsonUiView::new() .title("Dashboard") .layout("app"); }
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 Layouts
Three layouts are included out of the box:
default
Minimal HTML page. Wraps content in a valid document with doctype, meta tags, title, and the ferro-json-ui wrapper div. No navigation or sidebar.
#![allow(unused)] fn main() { // No .layout() call, or explicit: let view = JsonUiView::new() .title("Simple Page"); }
app
Dashboard-style layout with a horizontal navigation bar, a sidebar on the left, and a main content area on the right. Uses a flex layout. By default renders empty navigation and sidebar placeholders -- create a custom layout to populate them.
#![allow(unused)] fn main() { let view = JsonUiView::new() .title("Dashboard") .layout("app"); }
auth
Centered card layout for authentication pages. Centers content vertically and horizontally within a max-width container. No navigation or sidebar.
#![allow(unused)] fn main() { let view = JsonUiView::new() .title("Login") .layout("auth"); }
Creating Custom Layouts
Implement the Layout trait to create a custom layout:
#![allow(unused)] fn main() { use ferro_rs::{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> </body> </html>"#, title = ctx.title, head = ctx.head, content = ctx.content, ) } } }
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_rs::register_layout; // Register globally register_layout("custom", CustomLayout); }
Or register directly on a LayoutRegistry:
#![allow(unused)] fn main() { use ferro_rs::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 |
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.
Navigation Helpers
The layout module provides partial rendering functions for building navigation:
NavItem
#![allow(unused)] fn main() { use ferro_rs::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_rs::{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 custom layouts to build consistent navigation.
Render Configuration
JsonUiConfig controls rendering behavior:
#![allow(unused)] fn main() { use ferro_rs::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_rs::{JsonUi, JsonUiView, JsonUiConfig}; let view = JsonUiView::new() .title("Dashboard") .layout("app"); 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.
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 with hot reloading for both backend and frontend.
# Start both backend and frontend
ferro serve
# 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 | 3000 | 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 | Don't regenerate TypeScript types |
What it does:
- Starts the Rust backend with
cargo watchfor hot reloading - Starts the Vite frontend dev server
- Watches Rust files to regenerate TypeScript types automatically
- Proxies frontend requests to the backend
- Auto-resolves port conflicts — if the frontend port is already 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/.
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 { // TODO: Implement json_response!({ "message": "index" }) } #[handler] pub async fn show(req: Request) -> Response { // TODO: Implement 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> { // TODO: Implement 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>> { // TODO: Implement listener 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>> { // TODO: Implement job 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; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { // TODO: Implement migration Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { // TODO: Implement rollback Ok(()) } } }
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::scheduling::{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>> { // TODO: Implement 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::database::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::testing::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
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 the app spec and Dockerfile in one step.
ferro do:init --repo owner/repo
Generated files:
.do/app.yaml— App Platform spec with service config (add env vars via dashboard)Dockerfile— Production-ready multi-stage build (if not present).dockerignore— Excludes build artifacts (if not present)
Options:
--repo,-r— GitHub repository inowner/repoformat (required)
Docker Commands
ferro docker:init
Generate a production-ready Dockerfile. Also called automatically by do:init if no Dockerfile exists.
ferro docker:init
Generated files:
Dockerfile.dockerignore
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.
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 |
make:controller | Create a controller |
make:middleware | Create middleware |
make:action | Create an action class |
make:auth | Scaffold authentication system |
make:event | Create an event |
make:listener | Create a listener |
make:job | Create a background job |
make:notification | Create a notification |
make:migration | Create a migration |
make:inertia | Create an Inertia page |
make:json-view | Create a JSON-UI view (AI-powered) |
make:task | Create a scheduled task |
make:seeder | Create a database seeder |
make:factory | Create a model factory |
make:error | Create a custom error |
make:policy | Create an authorization policy |
make:resource | Create an API resource |
make:scaffold | Create complete CRUD scaffold |
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 | Initialize DigitalOcean App Platform deployment |
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 |
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 |
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.