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:

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

Options

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

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

Starting Development

cd my-app
ferro serve

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

Server Options

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

# Backend only
ferro serve --backend-only

# Frontend only
ferro serve --frontend-only

# Skip TypeScript generation
ferro serve --skip-types

AI Development Setup

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

ferro boost:install

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

Next Steps

Quick Start

Build a simple user listing feature in 5 minutes.

1. Create Migration

ferro make:migration create_users_table

Edit src/migrations/m_YYYYMMDD_create_users_table.rs:

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

#[derive(DeriveMigrationName)]
pub struct Migration;

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

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

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

Run the migration:

ferro db:migrate

2. Sync Database to Models

ferro db:sync

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

3. Create Controller

ferro make:controller users

Edit src/controllers/users_controller.rs:

#![allow(unused)]
fn main() {
use ferro::*;
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?

Directory Structure

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

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

Key Directories

src/actions/

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

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

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

src/controllers/

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

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

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

src/models/

SeaORM entity definitions. Generated with ferro db:sync.

src/middleware/

Custom middleware for request/response processing.

src/events/ and src/listeners/

Event-driven architecture. Events dispatch to multiple listeners.

src/jobs/

Background job definitions for queue processing.

frontend/src/pages/

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

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

Configuration Files

.env

Environment-specific configuration:

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

Cargo.toml

Rust dependencies. Ferro crates are added here.

frontend/package.json

Node.js dependencies for the React frontend.

Generated Directories

These directories are created automatically:

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

Storage

The storage/ directory holds application files:

# Create public storage symlink
ferro storage:link

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

Routing

Ferro provides an expressive routing API similar to Laravel.

Basic Routes

Define routes in src/routes.rs:

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

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:

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

Route Middleware

Apply middleware to specific routes:

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

Or to groups:

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

Fallback Routes

Handle 404s:

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

Route Constraints

Validate route parameters:

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

API Routes

For JSON APIs, typically group under /api:

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

View Routes

For simple views without controller logic:

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

Redirect Routes

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

Route Caching

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

Middleware

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

Generating Middleware

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

ferro make:middleware Auth

This command will:

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

Examples:

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

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

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

Generated file:

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

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

/// Auth middleware
pub struct AuthMiddleware;

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

Overview

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

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

Creating Middleware

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

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

pub struct LoggingMiddleware;

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

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

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

        response
    }
}
}

The handle Method

The handle method receives:

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

You can:

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

Short-Circuiting Requests

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

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

pub struct AuthMiddleware;

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

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

Registering Middleware

Ferro supports three levels of middleware:

1. Global Middleware

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

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

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

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

2. Route Middleware

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

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

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

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

3. Route Group Middleware

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

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

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

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

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

Middleware Execution Order

Middleware executes in the following order:

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

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

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

Practical Examples

CORS Middleware

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

pub struct CorsMiddleware;

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

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

Rate Limiting Middleware

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

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

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

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

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

        next(request).await
    }
}
}

Request Timing Middleware

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

pub struct TimingMiddleware;

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

        let response = next(request).await;

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

        response
    }
}
}

Security Headers

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

Default Headers

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

Customization

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

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

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

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

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

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

HSTS

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

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

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

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

Notes

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

File Organization

The recommended file structure for middleware:

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

src/middleware/mod.rs:

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

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

Summary

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

Controllers

Controllers group related request handling logic.

Creating Controllers

ferro make:controller Users

This creates src/controllers/users_controller.rs:

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

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

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

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

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

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

The Handler Macro

The #[handler] macro provides:

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

Route Registration

Register controller methods in src/routes.rs:

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

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

Inertia Controllers

For Inertia.js responses:

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

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

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

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

Form Handling with SavedInertiaContext

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

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

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

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

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

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

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

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

Key points:

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

Form Validation

Use form requests for validation:

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

#[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:

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

API Controllers

For JSON APIs:

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

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

With pagination:

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

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

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

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

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 listeners
  • Send + Sync + 'static - For async safety
  • Event trait - Provides the event name

Creating Listeners

Using the CLI

Generate a new listener:

ferro make:listener SendOrderConfirmation

This creates src/listeners/send_order_confirmation.rs:

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

pub struct SendOrderConfirmation;

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

Listener Trait Methods

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

Registering Listeners

Register listeners in src/bootstrap.rs:

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

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

    let dispatcher = App::event_dispatcher();

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

Closure Listeners

For simple cases, use closures:

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

Dispatching Events

Call .dispatch() directly on events:

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

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

Fire and Forget

Dispatch without waiting for listeners:

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

Using the Dispatcher Directly

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

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

Queued Listeners

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

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

pub struct GenerateInvoicePDF;

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

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

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

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

Stopping Propagation

Stop subsequent listeners from running:

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

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

Example: Order Processing

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

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

// listeners/send_order_confirmation.rs
pub struct SendOrderConfirmation;

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

// listeners/update_inventory.rs
pub struct UpdateInventory;

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

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

Best Practices

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

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

MethodDescriptionDefault
handle()Job execution logicRequired
name()Job identifier for loggingType name
max_retries()Retry attempts on failure3
retry_delay(attempt)Delay before retry5 seconds
timeout()Maximum execution time60 seconds
failed(error)Called when all retries exhaustedLogs 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

  1. Keep jobs small - Jobs should do one thing well
  2. Make jobs idempotent - Safe to run multiple times
  3. Use appropriate timeouts - Set timeout() based on expected duration
  4. Handle failures gracefully - Implement failed() for cleanup
  5. Use dedicated queues - Separate critical jobs from bulk processing
  6. Monitor queue depth - Alert on growing backlogs

Environment Variables Reference

VariableDescriptionDefault
QUEUE_CONNECTION"sync" or "redis"sync
QUEUE_DEFAULTDefault queue namedefault
QUEUE_PREFIXRedis key prefixferro_queue
QUEUE_BLOCK_TIMEOUTWorker polling timeout (seconds)5
QUEUE_MAX_CONCURRENTMax parallel jobs per worker10
REDIS_URLFull Redis URL (overrides individual settings)-
REDIS_HOSTRedis server host127.0.0.1
REDIS_PORTRedis server port6379
REDIS_PASSWORDRedis password-
REDIS_DATABASERedis database number0

Notifications

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

Configuration

Environment Variables

Configure notifications in your .env file:

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

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

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

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

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

Bootstrap Setup

In src/bootstrap.rs, initialize notifications:

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

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

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

Manual Configuration

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

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

NotificationDispatcher::configure(config);

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

NotificationDispatcher::configure(config);
}

Creating Notifications

Using the CLI

Generate a new notification:

ferro make:notification OrderShipped

This creates src/notifications/order_shipped.rs:

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

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

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

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

Notification Trait Methods

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

Making Entities Notifiable

Implement Notifiable on your User model:

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

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

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

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

Notifiable Trait Methods

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

Sending Notifications

Basic Usage

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

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

Available Channels

Mail Channel

Send emails via SMTP or Resend:

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

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

MailMessage Methods

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

Database Channel

Store notifications for in-app display:

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

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

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

DatabaseMessage Methods

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

Slack Channel

Send Slack webhook notifications:

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

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

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

SlackMessage Methods

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

SlackAttachment Methods

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

Multi-Channel Notifications

Send to multiple channels at once:

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

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

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

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

Example: Complete Notification

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

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

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

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

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

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

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

Environment Variables Reference

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

Best Practices

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

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:

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

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

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

Channel Authorization

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

Implementing a Channel Authorizer

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

pub struct AppChannelAuth;

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

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

Registering the Authorizer

Chain .with_authorizer() when creating the broadcaster:

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

Auth Endpoint

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

Registering the Auth Route

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

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

The handler:

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

Private Channel Auth Flow

The full authorization flow for private and presence channels:

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

Broadcasting from Handlers

Fluent Builder API

The Broadcast builder provides a chainable interface for sending events:

#![allow(unused)]
fn main() {
use ferro::{Broadcast, Broadcaster};
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 -- includes user_id and user_info
  • member_removed -- includes user_id

Querying Channel Members

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

Configuration

Environment Variables

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

Connection Management

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

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

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

Monitoring

#![allow(unused)]
fn main() {
let broadcaster = App::get::<Broadcaster>().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

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

Best Practices

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

Caching

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

Configuration

Environment Variables

Configure caching in your .env file:

# Cache driver (memory or redis)
CACHE_DRIVER=memory

# Key prefix for all cache entries
CACHE_PREFIX=myapp

# Default TTL in seconds
CACHE_TTL=3600

# Memory store capacity (max entries)
CACHE_MEMORY_CAPACITY=10000

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

Bootstrap Setup

In src/bootstrap.rs, configure caching:

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

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

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

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

Manual Configuration

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

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

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

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

Basic Usage

Storing Values

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

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

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

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

Retrieving Values

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

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

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

Removing Values

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

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

Pull (Get and Remove)

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

Remember Pattern

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

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

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

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

This pattern is excellent for:

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

Cache Tags

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

Storing with Tags

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

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

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

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

Flushing Tags

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

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

Tag Use Cases

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

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

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

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

Atomic Operations

Increment and Decrement

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

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

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

Cache Backends

Memory Store

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

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

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

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

Redis Store

Distributed caching with Redis. Best for:

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

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

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

Enable the Redis backend in Cargo.toml:

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

Example: API Response Caching

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

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

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

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

    Response::json(&products)
}
}

Example: User Session Caching

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

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

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

Example: Rate Limiting with Cache

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

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

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

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

    Ok(count <= limit)
}
}

Environment Variables Reference

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

Best Practices

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

Authentication

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

Quick Start

Scaffold a complete auth system with one command:

ferro make:auth

This generates:

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

Configuration

Session

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

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

Load configuration from environment:

#![allow(unused)]
fn main() {
use ferro::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 LevelIdle TimeoutAbsolute Timeout
Standard web app30-60 min4-8 hours
Financial/medical5-15 min1-2 hours
Framework default120 min30 days

Session Invalidation

Destroy sessions for security-sensitive operations like password changes:

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

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

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

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

#![allow(unused)]
fn main() {
use ferro::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

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

User Provider

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

#![allow(unused)]
fn main() {
use ferro::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

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

Registration

Register the provider in bootstrap.rs:

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

Auth Facade

The Auth struct provides static methods for all authentication operations.

Checking Authentication State

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

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

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

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

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

Logging In

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

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

Logging Out

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

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

Credential Validation

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

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

// Validate credentials without logging in (e.g., password confirmation)
let valid = Auth::validate(|| async {
    let user_id = Auth::id().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

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

Handler Extractors

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

AuthUser<T>

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

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

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

OptionalUser<T>

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

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

#[handler]
pub async fn home(user: OptionalUser<users::Model>) -> Response {
    let greeting = match user.as_ref() {
        Some(u) => format!("Welcome back, {}!", u.name),
        None => "Welcome, guest!".to_string(),
    };
    Ok(HttpResponse::json(serde_json::json!({"greeting": greeting})))
}
}

Deref Behavior

Both AuthUser<T> and OptionalUser<T> implement Deref, so you access fields directly on the wrapper:

  • AuthUser<T> derefs to T -- use user.name, not user.0.name
  • OptionalUser<T> derefs to Option<T> -- use user.as_ref(), user.is_some(), etc.

Limitations

AuthUser and OptionalUser count as a FromRequest parameter. Only one FromRequest parameter is allowed per handler signature, so they cannot be combined with FormRequest types in the same handler. If you need both the authenticated user and the request body, use Request and call Auth::user_as::<T>() manually:

#![allow(unused)]
fn main() {
#[handler]
pub async fn update_profile(req: Request) -> Response {
    let user: users::Model = Auth::user_as::<users::Model>().await?
        .ok_or_else(|| HttpResponse::json(serde_json::json!({"error": "Unauthenticated"})).status(401))?;
    let input: UpdateInput = req.json().await?;
    // ... use both user and input
}
}

Password Hashing

Ferro provides bcrypt hashing functions re-exported at the crate root.

#![allow(unused)]
fn main() {
use ferro::{hash, verify, needs_rehash};

// Hash a password (bcrypt, cost 12)
let hashed = ferro::hash("my_password")?;

// Verify a password against a stored hash (constant-time comparison)
let valid = ferro::verify("my_password", &hashed)?;

// Check if a hash was created with a lower cost factor
if ferro::needs_rehash(&stored_hash) {
    let new_hash = ferro::hash(&password)?;
    // Update stored hash
}
}

Custom Cost Factor

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

// Hash with a specific cost (higher = slower + more secure)
let hashed = hashing::hash_with_cost("my_password", 14)?;
}

Middleware

AuthMiddleware

Protects routes that require authentication.

#![allow(unused)]
fn main() {
use ferro::{AuthMiddleware, group, get};

// API routes: returns 401 JSON for unauthenticated requests
group!("/api")
    .middleware(AuthMiddleware::new())
    .routes([...]);

// Web routes: redirects to login page
group!("/dashboard")
    .middleware(AuthMiddleware::redirect_to("/login"))
    .routes([...]);
}

Inertia-aware: When an Inertia request hits AuthMiddleware::redirect_to(), it returns a 409 response with X-Inertia-Location header instead of a 302 redirect. This tells the Inertia client to perform a full page visit to the login URL.

GuestMiddleware

Protects routes that should only be accessible to unauthenticated users (login, register pages).

#![allow(unused)]
fn main() {
use ferro::{GuestMiddleware, group, get};

group!("/")
    .middleware(GuestMiddleware::redirect_to("/dashboard"))
    .routes([
        get!("/login", auth::show_login),
        get!("/register", auth::show_register),
    ]);
}

Authenticated users visiting these routes are redirected to the specified path. Also Inertia-aware.

Login 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:

ProtectionMechanism
Session fixationSession ID regenerated on Auth::login()
CSRFToken regenerated on login and logout
Timing attacksBcrypt uses constant-time comparison
Cookie theftHttpOnly flag set by default (not accessible via JavaScript)
Cross-site requestsSameSite=Lax cookie attribute by default
HTTPS enforcementSecure cookie flag on by default
Session hijackingDatabase-backed sessions with configurable lifetime
Session expiryDual idle + absolute timeouts per OWASP
Session invalidationDirect DB deletion on password change via Auth::logout_other_devices()
Password storageBcrypt with cost factor 12 (adaptive hashing)

API Resources

API Resources provide a transformation layer between your database models and the JSON responses your API returns. They decouple your database schema from your API contract, letting you control exactly which fields are exposed, how they're named, and under what conditions they appear.

Basic Usage

Derive Macro

The simplest way to create a resource is with #[derive(ApiResource)]:

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

#[derive(ApiResource)]
pub struct UserResource {
    pub id: i32,
    pub name: String,
    pub email: String,
}
}

This generates a Resource trait implementation that serializes id, name, and email into a JSON object.

Model Conversion

Link a resource to a database model with the model attribute to generate a From<Model> implementation:

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

#[derive(ApiResource)]
#[resource(model = "crate::models::entities::users::Model")]
pub struct UserResource {
    pub id: i32,
    pub name: String,
    pub email: String,
    #[resource(skip)]
    pub password: String,
    #[resource(skip)]
    pub remember_token: Option<String>,
    #[resource(skip)]
    pub created_at: String,
    #[resource(skip)]
    pub updated_at: String,
}
}

The struct must include all fields from the model. Use #[resource(skip)] to exclude fields from JSON output while keeping them available programmatically.

Field Attributes

AttributeEffectExample
#[resource(skip)]Exclude field from JSON outputPasswords, internal tokens
#[resource(rename = "display_name")]Use a different key in JSONAPI naming conventions
#![allow(unused)]
fn main() {
use ferro::ApiResource;

#[derive(ApiResource)]
pub struct ProfileResource {
    pub id: i32,
    #[resource(rename = "display_name")]
    pub name: String,
    pub email: String,
    #[resource(skip)]
    pub password_hash: String,
}
}

Output: {"id": 1, "display_name": "Alice", "email": "alice@example.com"}

ResourceMap Builder

For conditional fields or complex logic, implement the Resource trait manually using ResourceMap:

#![allow(unused)]
fn main() {
use ferro::{Resource, ResourceMap, Request};
use serde_json::json;

struct UserResource {
    id: i32,
    name: String,
    email: String,
    is_admin: bool,
    internal_notes: Option<String>,
}

impl Resource for UserResource {
    fn to_resource(&self, _req: &Request) -> serde_json::Value {
        ResourceMap::new()
            .field("id", json!(self.id))
            .field("name", json!(self.name))
            .when("email", self.is_admin, || json!(self.email))
            .when_some("notes", &self.internal_notes)
            .merge_when(self.is_admin, || vec![
                ("role", json!("admin")),
                ("permissions", json!(["read", "write", "delete"])),
            ])
            .build()
    }
}
}

Builder Methods

MethodDescription
field(key, value)Always include this field
when(key, condition, value_fn)Include only when condition is true
unless(key, condition, value_fn)Include only when condition is false
when_some(key, option)Include only when Option is Some
merge_when(condition, fields_fn)Conditionally include multiple fields

All methods preserve insertion order in the output.

Response Helpers

The Resource trait provides response methods for common patterns:

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

// Direct JSON response
let response = resource.to_response(&req);
// Output: {"id": 1, "name": "Alice", "email": "alice@example.com"}

// Wrapped in data envelope
let response = resource.to_wrapped_response(&req);
// Output: {"data": {"id": 1, "name": "Alice", "email": "alice@example.com"}}

// Wrapped with additional top-level fields
let response = resource.to_response_with(&req, json!({"meta": {"version": "v1"}}));
// Output: {"data": {"id": 1, ...}, "meta": {"version": "v1"}}
}
MethodOutput Shape
to_response(&req){fields...}
to_wrapped_response(&req){"data": {fields...}}
to_response_with(&req, extra){"data": {fields...}, ...extra}

Handler Integration

Use resources in handlers by converting models and calling response helpers:

#![allow(unused)]
fn main() {
use ferro::{handler, Auth, HttpResponse, Request, Resource, Response};
use crate::resources::UserResource;
use crate::models::users;

#[handler]
pub async fn profile(req: Request) -> Response {
    let user = Auth::user_as::<users::Model>()
        .await?
        .ok_or_else(|| HttpResponse::json(
            serde_json::json!({"message": "Unauthenticated."})
        ).status(401))?;

    let resource = UserResource::from(user);
    Ok(resource.to_wrapped_response(&req))
}
}

The From<Model> implementation (generated by the model attribute) handles the conversion. The to_wrapped_response method produces a {"data": {...}} envelope.

CLI Scaffolding

Generate a new resource with the CLI:

# Basic resource
ferro make:resource UserResource

# With model attribute for From<Model> generation
ferro make:resource UserResource --model entities::users::Model

# Name without "Resource" suffix is auto-appended
ferro make:resource User
# Creates UserResource in src/resources/user_resource.rs

The generated file includes the derive macro template with commented examples for rename and skip attributes.

When to Use

Derive macro (#[derive(ApiResource)]):

  • Simple field selection from a model
  • Static field renaming
  • Excluding sensitive fields (passwords, tokens)

Manual ResourceMap:

  • Conditional fields based on user role or request context
  • Computed fields not present in the model
  • Merging data from multiple sources
  • Dynamic field inclusion logic

Resource Collections

ResourceCollection wraps a Vec<T: Resource> and produces a standard JSON envelope. Use it for any endpoint returning a list of resources.

Simple Collection

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

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

    let resources: Vec<UserResource> = users.into_iter()
        .map(UserResource::from)
        .collect();

    let collection = ResourceCollection::new(resources);
    Ok(collection.to_response(&req))
}
}

Output:

{
  "data": [
    {"id": 1, "name": "Alice", "email": "alice@example.com"},
    {"id": 2, "name": "Bob", "email": "bob@example.com"}
  ]
}

Additional Metadata

Add extra top-level fields alongside data with .additional():

#![allow(unused)]
fn main() {
let collection = ResourceCollection::new(resources)
    .additional(json!({"meta": {"version": "v1"}}));

Ok(collection.to_response(&req))
// Output: {"data": [...], "meta": {"version": "v1"}}
}

Collection Mapping Shortcut

Resource::collection() maps a slice of resources to their JSON representations without constructing a full ResourceCollection:

#![allow(unused)]
fn main() {
let users: Vec<UserResource> = /* ... */;
let json_values = UserResource::collection(&users, &req);
// Returns: Vec<serde_json::Value>
}
ConstructorOutput
ResourceCollection::new(items){"data": [...]}
ResourceCollection::paginated(items, meta){"data": [...], "meta": {...}, "links": {...}}
.additional(json!({...}))Merges fields at top level

Pagination

PaginationMeta computes page metadata and ResourceCollection::paginated() produces the standard paginated envelope. Integrates with SeaORM's PaginatorTrait.

PaginationMeta

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

let meta = PaginationMeta::new(page, per_page, total);
}

PaginationMeta::new() accepts a 1-indexed page number (the value from API query parameters). It computes last_page, from, and to automatically. SeaORM's fetch_page() is 0-indexed -- pass page - 1 to SeaORM and the raw page to PaginationMeta.

Paginated Handler

#![allow(unused)]
fn main() {
use ferro::{handler, PaginationMeta, Request, Resource, ResourceCollection, Response};
use sea_orm::PaginatorTrait;
use crate::resources::UserResource;

#[handler]
pub async fn index(req: Request) -> Response {
    let db = req.db();
    let page: u64 = req.query("page").unwrap_or(1);
    let per_page: u64 = req.query("per_page").unwrap_or(15);

    let paginator = User::find()
        .order_by_desc(users::Column::Id)
        .paginate(db, per_page);

    let items = paginator.fetch_page(page - 1).await?;  // 0-indexed
    let total = paginator.num_items().await?;

    let resources: Vec<UserResource> = items.into_iter()
        .map(UserResource::from)
        .collect();

    let meta = PaginationMeta::new(page, per_page, total);  // 1-indexed
    Ok(ResourceCollection::paginated(resources, meta).to_response(&req))
}
}

JSON Output Format

{
  "data": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}],
  "meta": {
    "current_page": 1,
    "per_page": 15,
    "total": 42,
    "last_page": 3,
    "from": 1,
    "to": 15
  },
  "links": {
    "first": "/users?page=1",
    "last": "/users?page=3",
    "prev": null,
    "next": "/users?page=2"
  }
}

Pagination links are relative URLs. Existing query parameters (e.g., sort=name) are preserved in links.

Relationship Inclusion

Ferro uses explicit batch-loading for relationships. All related data is loaded before resource construction -- never inside to_resource(). This prevents N+1 queries by design.

when_loaded (belongs_to / has_one)

when_loaded() looks up a key in a HashMap. If the key exists, the field is included in the output. If absent, the field is omitted.

#![allow(unused)]
fn main() {
use ferro::{Resource, ResourceMap, Request};
use std::collections::HashMap;
use serde_json::{json, Value};

struct PostResource {
    post: posts::Model,
    authors: HashMap<i32, users::Model>,
}

impl Resource for PostResource {
    fn to_resource(&self, req: &Request) -> Value {
        ResourceMap::new()
            .field("id", json!(self.post.id))
            .field("title", json!(self.post.title))
            .when_loaded("author", &self.post.author_id, &self.authors, |user| {
                json!({"id": user.id, "name": &user.name})
            })
            .build()
    }
}
}

when_loaded_many (has_many)

when_loaded_many() operates on HashMap<K, Vec<M>>. An empty vec is still included (loaded but empty); a missing key means the field is omitted entirely.

#![allow(unused)]
fn main() {
struct UserResource {
    user: users::Model,
    posts: HashMap<i32, Vec<posts::Model>>,
}

impl Resource for UserResource {
    fn to_resource(&self, req: &Request) -> Value {
        ResourceMap::new()
            .field("id", json!(self.user.id))
            .field("name", json!(self.user.name))
            .when_loaded_many("posts", &self.user.id, &self.posts, |items| {
                json!(items.iter().map(|p| {
                    json!({"id": p.id, "title": &p.title})
                }).collect::<Vec<_>>())
            })
            .build()
    }
}
}

Complete Paginated Handler with Relationships

#![allow(unused)]
fn main() {
use ferro::{handler, PaginationMeta, Request, Resource, ResourceCollection, ResourceMap, Response};
use sea_orm::PaginatorTrait;
use std::collections::HashMap;
use serde_json::{json, Value};

struct UserWithPostsResource {
    user: users::Model,
    posts_map: HashMap<i32, Vec<posts::Model>>,
}

impl Resource for UserWithPostsResource {
    fn to_resource(&self, _req: &Request) -> Value {
        ResourceMap::new()
            .field("id", json!(self.user.id))
            .field("name", json!(self.user.name))
            .when_loaded_many("posts", &self.user.id, &self.posts_map, |items| {
                json!(items.iter().map(|p| {
                    json!({"id": p.id, "title": &p.title})
                }).collect::<Vec<_>>())
            })
            .build()
    }
}

#[handler]
pub async fn index(req: Request) -> Response {
    let db = req.db();
    let page: u64 = req.query("page").unwrap_or(1);
    let per_page: u64 = 15;

    // 1. Paginate parent entity
    let paginator = User::find()
        .order_by_asc(users::Column::Id)
        .paginate(db, per_page);
    let users = paginator.fetch_page(page - 1).await?;
    let total = paginator.num_items().await?;

    // 2. Batch load relations for this page
    let user_ids: Vec<i32> = users.iter().map(|u| u.id).collect();
    let posts_map: HashMap<i32, Vec<posts::Model>> = Post::find()
        .filter(posts::Column::UserId.is_in(user_ids))
        .all(db)
        .await?
        .into_iter()
        .fold(HashMap::new(), |mut map, post| {
            map.entry(post.user_id).or_default().push(post);
            map
        });

    // 3. Map to resources with relations
    let resources: Vec<UserWithPostsResource> = users.into_iter()
        .map(|u| UserWithPostsResource {
            user: u,
            posts_map: posts_map.clone(),
        })
        .collect();

    // 4. Return paginated collection
    let meta = PaginationMeta::new(page, per_page, total);
    Ok(ResourceCollection::paginated(resources, meta).to_response(&req))
}
}

Anti-Patterns

N+1 inside to_resource(): Never call database queries inside to_resource(). All data must be loaded before resource construction.

#![allow(unused)]
fn main() {
// BAD: queries the database for every resource
impl Resource for UserResource {
    fn to_resource(&self, req: &Request) -> Value {
        let posts = Post::find()  // N+1 query!
            .filter(posts::Column::UserId.eq(self.user.id))
            .all(db).await;
        // ...
    }
}
}

Paginating joined queries: SeaORM's find_with_related() (SelectTwoMany) does not support .paginate(). Always paginate the parent entity first, then batch-load relations for the fetched page.

#![allow(unused)]
fn main() {
// BAD: won't compile
let results = User::find()
    .find_with_related(Post)
    .paginate(db, 15);  // SelectTwoMany has no PaginatorTrait

// GOOD: paginate parent, then batch load
let users = User::find().paginate(db, 15).fetch_page(0).await?;
// Then load posts for these users in a second query
}

REST API Scaffold

Ferro generates a production-ready REST API from existing models with one command. The scaffold includes CRUD controllers, API key authentication, rate limiting, OpenAPI documentation, and request validation types.

Quick Start

# Generate API for all models
ferro make:api --all

# Generate for specific models
ferro make:api User Post

# Skip confirmation prompts
ferro make:api --all --yes

After generation, wire the scaffold into your application:

  1. Add mod api; to src/main.rs or src/lib.rs
  2. Register api_routes() in your route configuration
  3. Register docs_routes() for API documentation
  4. Register ApiKeyProviderImpl as a service: App::bind::<dyn ApiKeyProvider>(Box::new(ApiKeyProviderImpl));
  5. Run ferro db:migrate to create the api_keys table
  6. Generate your first API key programmatically

Generated Files

ferro make:api generates the following files for each model:

FilePurpose
src/api/{model}_api.rsCRUD controller with index, show, store, update, destroy handlers
src/resources/{model}_resource.rsAPI resource with Resource trait implementation and From<Model> conversion
src/requests/{model}_request.rsCreate{Model}Request and Update{Model}Request with validation

Infrastructure files (generated once):

FilePurpose
src/api/mod.rsModule declarations for all API controllers
src/api/routes.rsRoute group with ApiKeyMiddleware and Throttle middleware
src/api/docs.rsOpenAPI JSON and ReDoc HTML handlers
src/models/api_key.rsSeaORM entity for the api_keys table
src/providers/api_key_provider.rsApiKeyProvider implementation with revocation and expiry checks
src/migrations/m*_create_api_keys_table.rsMigration for the api_keys table

Existing files are never overwritten. If a file already exists, it is skipped with an info message.

API Key Authentication

Key Format

API keys follow the fe_{env}_{random} pattern:

  • fe_live_ prefix for production keys
  • fe_test_ prefix for test/development keys
  • 43 random base62 characters for the secret portion

Full key length: 51 characters (e.g., fe_live_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcde).

Generating Keys

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

let key = generate_api_key("live");

// Show the raw key to the user exactly once
println!("Your API key: {}", key.raw_key);

// Store these in the database
// key.prefix — first 16 chars, used for DB lookup (unhashed)
// key.hashed_key — SHA-256 hex digest, used for verification
}

The raw key is never stored. After creation, only the prefix and hash are persisted. If the user loses the key, a new one must be generated.

Storage Schema

The generated api_keys migration creates:

ColumnTypeDescription
idBIGINT PKAuto-increment identifier
nameVARCHARHuman-readable label (e.g., "Production Bot")
prefixVARCHAR(16)First 16 characters for indexed lookup
hashed_keyVARCHAR(64)SHA-256 hex digest
scopesTEXT NULLJSON array of permission scopes
last_used_atTIMESTAMPTZ NULLLast request timestamp
expires_atTIMESTAMPTZ NULLExpiration timestamp
revoked_atTIMESTAMPTZ NULLRevocation timestamp
created_atTIMESTAMPTZCreation timestamp

An index on prefix enables fast key lookup.

Verification Flow

  1. Extract Bearer {key} from the Authorization header
  2. Look up the key record by prefix (first 16 characters)
  3. Check revocation (revoked_at IS NULL)
  4. Check expiry (expires_at not passed)
  5. Constant-time SHA-256 hash comparison via subtle::ConstantTimeEq
  6. Check required scopes against granted scopes
  7. Store ApiKeyInfo in request extensions for downstream handlers

ApiKeyProvider Trait

The middleware resolves an ApiKeyProvider from the service container. Implement this trait to connect to any key store:

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

pub struct MyKeyProvider;

#[async_trait]
impl ApiKeyProvider for MyKeyProvider {
    async fn verify_key(&self, raw_key: &str) -> Result<ApiKeyInfo, ()> {
        let prefix = &raw_key[..16.min(raw_key.len())];
        // Look up by prefix, verify hash, return metadata
        // ...
        Ok(ApiKeyInfo {
            id: record.id,
            name: record.name,
            scopes: vec!["read".to_string(), "write".to_string()],
        })
    }
}
}

Register in bootstrap:

#![allow(unused)]
fn main() {
App::bind::<dyn ApiKeyProvider>(Box::new(MyKeyProvider));
}

The generated ApiKeyProviderImpl in src/providers/api_key_provider.rs provides a complete database-backed implementation with revocation and expiry checks.

API Key Management

CLI Key Generation

Generate API keys from the command line without writing code:

ferro make:api-key "My App"

Options:

FlagDescriptionDefault
--envKey environment: live or testlive

Example with test environment:

ferro make:api-key "Dev Bot" --env test

Output includes:

  • Raw key (e.g., fe_live_aBcDeFg...) -- shown once, store securely
  • Prefix -- first 16 characters, used for database lookup
  • Hashed key -- SHA-256 hex digest for verification
  • SQL insert -- ready-to-run INSERT statement
  • Rust snippet -- copy-paste SeaORM code

The raw key is displayed only once. If lost, generate a new key.

Creating Keys Programmatically

#![allow(unused)]
fn main() {
use ferro::generate_api_key;
use sea_orm::{EntityTrait, Set};
use crate::models::api_key;

let key = generate_api_key("live");

let record = api_key::ActiveModel {
    name: Set("Production Bot".to_string()),
    prefix: Set(key.prefix),
    hashed_key: Set(key.hashed_key),
    scopes: Set(Some(serde_json::to_string(&["read", "write"]).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_password
  • secret, token, api_key, hashed_key
  • remember_token

Matching is case-insensitive, exact match only. A field named token_count is not excluded.

Custom Exclusion

Exclude additional fields with --exclude:

ferro make:api --all --exclude password_hash,secret_token

Multiple fields are comma-separated. Custom exclusions stack with auto-exclusion.

Including All Fields

Override auto-exclusion with --include-all:

ferro make:api --all --include-all

When --include-all is set, no auto-exclusion is applied. Custom --exclude fields are still honored:

# Include all fields except internal_notes
ferro make:api --all --include-all --exclude internal_notes

Verifying Your API

After scaffolding and wiring routes, verify the setup with ferro api:check:

ferro api:check

The command runs four sequential checks:

  1. Server connectivity -- can the CLI reach your server?
  2. Spec available -- does /api/openapi.json return a response?
  3. Spec valid -- is the response a valid OpenAPI 3.x document?
  4. Auth working -- does the API key authenticate successfully?

With Authentication

ferro api:check --api-key fe_live_...

Custom URL

ferro api:check --url http://localhost:3000

Custom Spec Path

ferro api:check --spec-path /api/docs/openapi.json

On success, api:check prints a ready-to-copy ferro-api-mcp command for MCP integration.

Endpoints

The generated routes follow standard REST conventions under /api/v1/:

List Resources

GET /api/v1/{resource}?page=1&per_page=15
curl -H "Authorization: Bearer fe_live_..." \
     "https://example.com/api/v1/users?page=1&per_page=15"

Response:

{
  "data": [
    {"id": 1, "name": "Alice", "email": "alice@example.com"},
    {"id": 2, "name": "Bob", "email": "bob@example.com"}
  ],
  "meta": {
    "current_page": 1,
    "per_page": 15,
    "total": 42,
    "last_page": 3,
    "from": 1,
    "to": 15
  },
  "links": {
    "first": "/api/v1/users?page=1",
    "last": "/api/v1/users?page=3",
    "prev": null,
    "next": "/api/v1/users?page=2"
  }
}

per_page is capped at 100.

Create Resource

POST /api/v1/{resource}
curl -X POST -H "Authorization: Bearer fe_live_..." \
     -H "Content-Type: application/json" \
     -d '{"name": "Charlie", "email": "charlie@example.com"}' \
     "https://example.com/api/v1/users"

Response (201):

{
  "data": {"id": 3, "name": "Charlie", "email": "charlie@example.com"}
}

Show Resource

GET /api/v1/{resource}/{id}
curl -H "Authorization: Bearer fe_live_..." \
     "https://example.com/api/v1/users/1"

Response:

{
  "data": {"id": 1, "name": "Alice", "email": "alice@example.com"}
}

Update Resource

PUT /api/v1/{resource}/{id}
curl -X PUT -H "Authorization: Bearer fe_live_..." \
     -H "Content-Type: application/json" \
     -d '{"name": "Alice Smith"}' \
     "https://example.com/api/v1/users/1"

Response:

{
  "data": {"id": 1, "name": "Alice Smith", "email": "alice@example.com"}
}

Update handlers use conditional field assignment (if let Some), so only included fields are modified.

Delete Resource

DELETE /api/v1/{resource}/{id}
curl -X DELETE -H "Authorization: Bearer fe_live_..." \
     "https://example.com/api/v1/users/1"

Response:

{"message": "Deleted"}

OpenAPI Documentation

The scaffold includes auto-generated OpenAPI 3.0 documentation:

EndpointDescription
/api/docsInteractive ReDoc UI
/api/openapi.jsonRaw OpenAPI specification

How Specs Are Built

The OpenAPI spec builder reads from the Ferro route registry:

  1. Filters routes matching the /api/ prefix
  2. Generates operations with auto-summaries (e.g., GET /api/v1/users becomes "List users")
  3. Extracts path parameters from {param} patterns
  4. Groups endpoints by resource name as tags
  5. Adds API key security scheme (Authorization header)

Specs and HTML are cached via OnceLock -- generated once on first request, zero cost per subsequent request.

Configuration

#![allow(unused)]
fn main() {
use ferro::{OpenApiConfig, build_openapi_spec, get_registered_routes};

let config = OpenApiConfig {
    title: "My API".to_string(),
    version: "1.0.0".to_string(),
    description: Some("Application API".to_string()),
    api_prefix: "/api/".to_string(),
};

let spec = build_openapi_spec(&config, &get_registered_routes());
}

The generated src/api/docs.rs reads the APP_NAME environment variable for the title.

Rate Limiting

Generated API routes include Throttle::named("api") middleware. Define the limiter in bootstrap.rs:

#![allow(unused)]
fn main() {
use ferro::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:

  1. Parse model metadata via syn AST visitor (same pattern as ferro make:api)
  2. Validate field names against the model struct definition
  3. Build parameterized SQL using sea_orm::Statement::from_sql_and_values
  4. Execute against the project's configured database
  5. Use RETURNING * on Postgres, last_insert_rowid() fallback on SQLite

All queries use parameterized statements to prevent SQL injection. Timestamp fields (created_at, updated_at) are excluded from required-field validation since they typically have database defaults.

Customization

Adding Custom Endpoints

Add routes to the generated src/api/routes.rs:

#![allow(unused)]
fn main() {
pub fn api_routes() -> GroupBuilder {
    group!("/api/v1")
        .middleware(ApiKeyMiddleware::new())
        .middleware(Throttle::named("api"))
        .routes([
            // Generated CRUD routes...
            get!("/users", user_api::index),
            post!("/users", user_api::store),
            // Custom endpoints
            post!("/users/:id/activate", user_api::activate),
            get!("/stats", stats_api::overview),
        ])
}
}

Modifying Response Format

Edit the generated resource in src/resources/{model}_resource.rs. Use ResourceMap for conditional fields:

#![allow(unused)]
fn main() {
impl Resource for UserResource {
    fn to_resource(&self, _req: &Request) -> serde_json::Value {
        ResourceMap::new()
            .field("id", json!(self.id))
            .field("name", json!(self.name))
            .when("email", self.is_admin, || json!(self.email))
            .build()
    }
}
}

See API Resources for full documentation on ResourceMap, conditional fields, and relationship inclusion.

Adding Relationships

Load related data in the controller before constructing resources:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show(req: Request, user: user::Model) -> Response {
    let db = ferro::DB::connection()
        .map_err(|e| HttpResponse::json(json!({"error": e.to_string()})).status(500))?;
    let posts = Post::find()
        .filter(posts::Column::UserId.eq(user.id))
        .all(&db)
        .await
        .map_err(|e| HttpResponse::json(json!({"error": e.to_string()})).status(500))?;

    let mut resource = UserResource::from(&user);
    // Add posts to the response
    // ...
    Ok(resource.to_wrapped_response(&req))
}
}

Custom Validation Rules

Edit the generated request types in src/requests/{model}_request.rs:

#![allow(unused)]
fn main() {
#[request]
pub struct CreateUserRequest {
    #[validate(length(min = 1, max = 255))]
    pub name: String,
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 8))]
    pub password: String,
}
}

See Validation for all available validation rules.

Custom Scopes

Define application-specific scopes and check them in handlers:

#![allow(unused)]
fn main() {
// In route registration
group!("/api/v1/admin")
    .middleware(ApiKeyMiddleware::scopes(&["admin"]))
    .routes([...]);

// In handler, check additional granular permissions
#[handler]
pub async fn destroy(req: Request, user: user::Model) -> Response {
    let key = req.get::<ApiKeyInfo>().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

ProtectionMechanism
Key storageSHA-256 hash only; raw key never persisted
Timing attacksConstant-time comparison via subtle::ConstantTimeEq
Key rotationRevocation via revoked_at timestamp
Key expiryexpires_at checked on every request
SQL injectionParameterized queries in all CRUD operations
Rate limitingPer-key or per-IP throttling with configurable windows
Scope enforcementMiddleware-level scope checking with wildcard support

MCP Bridge (ferro-api-mcp)

ferro-api-mcp is a standalone binary that bridges any Ferro REST API to the Model Context Protocol (MCP). AI agents can discover and call your API endpoints as MCP tools without custom integration code.

Quick Start Workflow

From scaffold to working MCP integration in seven steps:

  1. Scaffold the API:

    ferro make:api --all
    
  2. Wire routes in src/main.rs:

    #![allow(unused)]
    fn main() {
    mod api;
    // In route registration:
    api::routes::api_routes()
    api::docs::docs_routes()
    }
  3. Run the migration:

    ferro db:migrate
    
  4. Generate an API key:

    ferro make:api-key "My Key"
    

    Save the raw key -- it is shown only once.

  5. Start the server:

    cargo run
    
  6. Verify the setup:

    ferro api:check --api-key fe_live_...
    
  7. Add MCP config to your AI agent (see MCP Host Configuration below).

How It Works

  1. Reads the OpenAPI spec from your Ferro app's /api/docs/openapi.json endpoint
  2. Converts each API operation into an MCP tool with typed input schemas
  3. Runs as a stdio MCP server that AI agents connect to
  4. Supports x-mcp vendor extensions for customizing tool names, descriptions, hints, and visibility

Prerequisites

  • A Ferro app with make:api scaffold (see REST API)
  • The API running and accessible (e.g., ferro serve on localhost:8080)
  • An API key generated via ferro make:api setup

Setup

Building

cargo build --release -p ferro-api-mcp

Binary location: target/release/ferro-api-mcp

CLI Options

ferro-api-mcp [OPTIONS] --spec-url <URL>

Options:
  --spec-url <URL>    URL to fetch the OpenAPI spec from
  --api-key <KEY>     API key for Authorization header (optional)
  --base-url <URL>    Override the base URL for API calls
  --log-level <LEVEL> Log level: debug, info, warn, error [default: info]
  --dry-run           Validate spec and print tool summary without starting server

Validating Setup

ferro-api-mcp --spec-url http://localhost:8080/api/docs/openapi.json \
  --api-key your-api-key \
  --dry-run

Expected output:

Fetched spec: 4521 bytes

ferro-api-mcp v0.1.0
API: My App
Base URL: http://localhost:8080/
Tools: 5 registered

Tools:
  - list_users: List all users with pagination.
  - create_user: Create a new user.
  - show_user: Retrieve a single user by ID.
  - update_user: Update an existing user.
  - delete_user: Delete a user by ID.

Dry run complete. 5 tools validated.

MCP Host Configuration

Claude Desktop

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

{
  "mcpServers": {
    "my-app": {
      "command": "/path/to/ferro-api-mcp",
      "args": [
        "--spec-url", "http://localhost:8080/api/docs/openapi.json",
        "--api-key", "your-api-key"
      ]
    }
  }
}

Claude Code

Add to .claude.json (project-level) or ~/.claude.json (global):

{
  "mcpServers": {
    "my-app": {
      "command": "/path/to/ferro-api-mcp",
      "args": [
        "--spec-url", "http://localhost:8080/api/docs/openapi.json",
        "--api-key", "your-api-key"
      ]
    }
  }
}

Cursor

Add via Settings > MCP Servers:

{
  "my-app": {
    "command": "/path/to/ferro-api-mcp",
    "args": [
      "--spec-url", "http://localhost:8080/api/docs/openapi.json",
      "--api-key", "your-api-key"
    ]
  }
}

x-mcp Extensions

Ferro's build_openapi_spec() automatically emits x-mcp vendor extensions on each operation. ferro-api-mcp reads these at startup to customize tool behavior.

ExtensionEffect
x-mcp-tool-nameAI-friendly snake_case tool name (e.g., list_users)
x-mcp-descriptionAI-optimized description for the tool
x-mcp-hintUsage hint appended to tool description
x-mcp-hiddenSet to true to exclude the operation from MCP tools

These are emitted automatically by the framework. No configuration is needed. ferro-api-mcp uses the extension values as overrides, falling back to auto-generated names and descriptions when extensions are absent.

Route Customization

Override the auto-generated MCP metadata on individual routes using builder methods:

#![allow(unused)]
fn main() {
use ferro::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

MethodEffectWhen to Use
.mcp_tool_name("name")Override auto-generated tool nameWhen the default name is unclear (e.g., store_user -> create_user_account)
.mcp_description("desc")Override auto-generated descriptionWhen the default summary needs more context for AI agents
.mcp_hint("hint")Append hint text to descriptionTo guide AI agents on parameter usage or expected behavior
.mcp_hidden()Exclude route from MCP toolsFor internal/admin endpoints that agents should not call

Group-Level Defaults

Set MCP defaults at the group level. Route-level overrides take precedence:

#![allow(unused)]
fn main() {
group!("/api/v1/internal")
    .mcp_hidden()  // Hide all routes in this group
    .routes([
        get!("/health", internal_api::health),
        get!("/metrics", internal_api::metrics),
    ])
}

How It Works

Customizations are stored in the route registry and emitted as x-mcp vendor extensions in the OpenAPI spec. ferro-api-mcp reads these extensions at startup, using them as overrides over auto-generated values.

Troubleshooting

ProblemCauseSolution
"Cannot connect to {url}"API server not runningStart the server with ferro serve
"HTTP 401" on tool callsMissing or invalid API keyCheck --api-key matches a key in the database
"HTTP 404" on tool callsEndpoint does not existVerify the API is running and the spec is current
"request timed out"API slow or network issueCheck server logs, verify connectivity
"spec parsed but 0 operations"Empty or malformed specCheck /api/docs/openapi.json manually
"unsupported OpenAPI version"Spec is not 3.0.xferro-api-mcp requires OpenAPI 3.0.x
Tool arguments rejectedMissing required fieldsCheck tool input schema for required params

Base URL Resolution

ferro-api-mcp resolves the API base URL in this order:

  1. --base-url flag (explicit override)
  2. servers[0].url from the OpenAPI spec
  3. Origin of the --spec-url (scheme + host + port)

This means most setups need only --spec-url. Use --base-url when the API server is behind a reverse proxy or on a different host than the spec endpoint.

Rate Limiting

Ferro provides cache-backed rate limiting through the Throttle middleware. Define named limiters with dynamic, per-request rules or apply inline limits directly on routes. Rate counters use the framework's Cache facade -- in-memory by default, Redis for multi-server deployments.

Defining Rate Limiters

Register named limiters in bootstrap.rs (or a service provider). Each limiter receives the incoming Request and returns one or more Limit values.

Basic Limiter

#![allow(unused)]
fn main() {
use ferro::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

MethodWindow
Limit::per_second(n)1 second
Limit::per_minute(n)60 seconds
Limit::per_hour(n)3600 seconds
Limit::per_day(n)86400 seconds

Key Segmentation

By default, rate limits are keyed by client IP (from X-Forwarded-For or X-Real-IP headers). Override with .by():

#![allow(unused)]
fn main() {
// Per-user limit
Limit::per_minute(120).by(format!("user:{}", user_id))

// Per-API-key limit
Limit::per_minute(1000).by(api_key)
}

Custom 429 Response

Override the default JSON error with .response():

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

Limit::per_minute(60).response(|| {
    HttpResponse::json(serde_json::json!({
        "error": "Quota exceeded",
        "upgrade_url": "https://example.com/pricing"
    })).status(429)
})
}

Response Headers

Every response from a throttled route includes rate limit headers:

HeaderDescription
X-RateLimit-LimitMaximum requests allowed in the window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetSeconds until the current window resets

When a request is rejected (429), an additional header is included:

HeaderDescription
Retry-AfterSeconds until the client should retry

Example Headers

Successful request:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 38

Rate limited request (429):

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 38
Retry-After: 38

Cache Backend

Rate limiting uses the framework's Cache facade for counter storage. The algorithm is fixed-window counters with atomic INCR + EXPIRE operations.

SetupConfiguration
Single server (default)No configuration needed. Uses in-memory cache.
Multi-serverSet CACHE_DRIVER=redis and REDIS_URL in .env
# .env for multi-server deployments
CACHE_DRIVER=redis
REDIS_URL=redis://127.0.0.1:6379

Cache keys follow the pattern rate_limit:{name}:{identifier}:{window} and expire automatically after each window.

Fail-Open Behavior

Rate limiting is designed to never cause application errors:

  • Cache unavailable: Requests are allowed with a warning logged to stderr.
  • Named limiter not registered: Requests are allowed with a warning logged to stderr.
  • Expire call fails: The counter still works; the key may persist longer than intended.

Rate limiting failures never produce 500 errors. The system prioritizes availability over strict enforcement.

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

  1. Route parameter {user} is extracted from the URL
  2. The parameter value is parsed as the model's primary key type
  3. The model is fetched from the database
  4. If not found, a 404 response is returned automatically
  5. If the parameter can't be parsed, a 400 response is returned

Custom Route Binding

For custom binding logic, implement the RouteBinding trait:

#![allow(unused)]
fn main() {
use ferro::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

VariableDescriptionDefault
DATABASE_URLDatabase connection URLsqlite://./database.db
DB_MAX_CONNECTIONSMaximum pool connections10
DB_MIN_CONNECTIONSMinimum pool connections1
DB_CONNECT_TIMEOUTConnection timeout (seconds)30
DB_LOGGINGEnable SQL query loggingfalse

Supported Databases

DatabaseURL FormatNotes
PostgreSQLpostgres://user:pass@host:5432/dbRecommended for production
SQLitesqlite://./path/to/db.sqliteGreat for development
SQLite (memory)sqlite::memory:For testing

Best Practices

  1. Use migrations - Never modify database schema manually
  2. Implement Model traits - Get convenient static methods for free
  3. Use QueryBuilder - Cleaner API than raw SeaORM queries
  4. Leverage route binding - Automatic 404 handling for missing models
  5. Test with test_database! - Isolated, repeatable tests
  6. Use dependency injection - Cleaner code with #[inject] db: Database
  7. Enable logging in development - DB_LOGGING=true for debugging
  8. Set appropriate pool sizes - Match your expected concurrency

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

RuleDescriptionExample
required()Field must be present and not emptyrequired()
required_if(field, value)Required if another field equals valuerequired_if("type", "business")
string()Must be a stringstring()
integer()Must be an integerinteger()
numeric()Must be numericnumeric()
boolean()Must be a booleanboolean()
array()Must be an arrayarray()
min(n)Minimum length/valuemin(8)
max(n)Maximum length/valuemax(255)
between(min, max)Value between min and maxbetween(1, 100)
email()Valid email formatemail()
url()Valid URL formaturl()
regex(pattern)Matches regex patternregex(r"^\d{5}$")
alpha()Only alphabetic charactersalpha()
alpha_num()Only alphanumericalpha_num()
alpha_dash()Alphanumeric, dashes, underscoresalpha_dash()
date()Valid date (YYYY-MM-DD)date()
confirmed()Must match {field}_confirmationconfirmed()
in_array(values)Must be one of valuesin_array(vec!["a", "b"])
not_in(values)Must not be one of valuesnot_in(vec!["x", "y"])
different(field)Must differ from fielddifferent("old_email")
same(field)Must match fieldsame("password")
nullable()Can be null (stops if null)nullable()
accepted()Must be "yes", "on", "1", trueaccepted()

Best Practices

  1. Use Form Requests for complex validation - Keeps controllers clean
  2. Provide custom messages - User-friendly error messages improve UX
  3. Use custom attributes - Replace technical field names with readable ones
  4. Validate early - Fail fast with clear error messages
  5. Use nullable() for optional fields - Prevents errors on missing optional data
  6. Create custom rules - Reuse validation logic across the application
  7. Return 422 status - Standard HTTP status for validation errors
  8. Structure errors as JSON - Easy to consume by frontend applications

Localization

Ferro provides JSON-based localization via the ferro-lang crate with per-request locale detection, parameter interpolation, pluralization, and automatic validation message translation.

Configuration

Environment Variables

Configure localization in your .env file:

VariableDefaultDescription
APP_LOCALEenDefault locale
APP_FALLBACK_LOCALEenFallback when key missing in requested locale
LANG_PATHlangDirectory containing translation files

Programmatic Configuration

Override defaults in your config_fn() before Application::run():

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

pub fn config_fn() {
    Config::register(LangConfig::builder()
        .locale("es")
        .fallback_locale("en")
        .path("resources/lang")
        .build());
}
}

Unset builder fields fall back to environment variables automatically.

Directory Structure

Translation files are organized by locale, with each locale in its own subdirectory:

lang/
  en/
    app.json
    validation.json
  es/
    app.json
    validation.json

JSON files support nested objects, flattened via dot notation:

{
  "auth": {
    "login": "Log in",
    "register": "Create account"
  }
}

Accessed as t("auth.login", &[]).

Multiple JSON files per locale are merged. File names are arbitrary -- use them to organize by domain (e.g., app.json, validation.json, auth.json).

Translation Helpers

t() / trans()

Basic translation with parameter interpolation:

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

// Simple translation
let msg = t("welcome", &[]);

// With parameters -- :param syntax
let msg = t("greeting", &[("name", "Alice")]);
// "Hello, :name!" becomes "Hello, Alice!"
}

trans() is an alias for t() for those who prefer the longer name.

Parameter Interpolation

Parameters use the :param placeholder syntax with three case variants:

  • :name -- value as-is
  • :Name -- first character uppercased
  • :NAME -- entire value uppercased
#![allow(unused)]
fn main() {
// Given translation: ":name :Name :NAME"
let msg = t("example", &[("name", "alice")]);
// Result: "alice Alice ALICE"
}

Parameters are processed longest-key-first to avoid partial replacement (e.g., :username is replaced before :user). Missing placeholders are left as-is.

lang_choice()

Pluralized translation:

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

let msg = lang_choice("items.count", 1, &[]);  // "One item"
let msg = lang_choice("items.count", 5, &[]);  // "5 items"
}

A :count parameter is added automatically.

Pluralization

Simple Forms

Pipe-separated values where count of 1 selects the first form, everything else selects the second:

{
  "items.count": "One item|:count items"
}

Explicit Ranges

For finer control, use range syntax:

{
  "cart.summary": "{0} Your cart is empty|{1} One item|[2,*] :count items"
}

Range syntax:

SyntaxMeaning
{N}Exact match for count N
[N,M]Inclusive range N through M
[N,*]N or more
Plain pipe |First form for count=1, second for everything else

Example

{
  "apples": "{0} No apples|{1} One apple|[2,5] A few apples|[6,*] :count apples"
}
#![allow(unused)]
fn main() {
lang_choice("apples", 0, &[]);   // "No apples"
lang_choice("apples", 1, &[]);   // "One apple"
lang_choice("apples", 3, &[]);   // "A few apples"
lang_choice("apples", 10, &[]);  // "10 apples"
}

Locale Detection

The LangMiddleware detects locale per-request with this priority:

  1. ?locale=xx query parameter (explicit override)
  2. Accept-Language header (first language tag)
  3. APP_LOCALE default from config

Setup

Register the middleware globally:

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

pub fn register() {
    global_middleware!(LangMiddleware);
}
}

Manual Override in Handlers

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

#[handler]
pub async fn show(req: Request) -> Response {
    let current = locale();  // e.g. "en"
    set_locale("fr");        // override for this request
    // subsequent t() calls use "fr"
}
}

Locale Normalization

Locale identifiers are normalized to lowercase with hyphens: en_US, EN-US, and en-us all resolve to en-us. This applies to directory names, query parameters, and Accept-Language headers.

Validation Messages

Validation error messages are automatically localized when translations are loaded. The framework registers a validation bridge at boot that routes message lookups through the Translator.

Translation keys follow the pattern validation.{rule_name}:

{
  "validation": {
    "required": "The :attribute field is required.",
    "email": "The :attribute field must be a valid email address.",
    "min": {
      "string": "The :attribute field must be at least :min characters.",
      "numeric": "The :attribute field must be at least :min.",
      "array": "The :attribute field must have at least :min items."
    }
  }
}

Default English validation messages are bundled with the framework. Custom messages in your translation files override them per-locale.

Size rules (min, max, between) use nested keys for type-specific messages: validation.min.string, validation.min.numeric, validation.min.array.

CLI Commands

Generate Translation Files

Create translation files for a new locale:

ferro make:lang es

This creates lang/es/ with app.json and validation.json starter templates.

New Project Scaffolding

ferro new includes lang/en/ by default with English starter translations and the locale environment variables in .env.example.

Fallback Chain

When looking up a translation key:

  1. Check the requested locale
  2. If not found, check the fallback locale (APP_FALLBACK_LOCALE)

Fallback keys are pre-merged into each locale at load time, so runtime lookup is a single hash map access with no fallback chain traversal.

Graceful Degradation

If no lang/ directory exists or translations fail to load:

  • t() and trans() return the key as-is (e.g., "welcome")
  • lang_choice() returns the key as-is
  • Validation rules fall back to hardcoded English messages
  • No panics, no errors -- the application runs normally without localization

This means localization is entirely opt-in. Applications work without any translation files present.

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

  1. Use test_database! for isolation - Each test gets a fresh database
  2. Use factories for test data - Consistent, readable test setup
  3. Test both success and failure cases - Validate error handling
  4. Use meaningful test names - test_user_cannot_access_admin_panel
  5. Keep tests focused - One assertion concept per test
  6. Use Expect for readable assertions - Fluent API improves clarity
  7. Mock external services - Use TestContainer to isolate from APIs
  8. Test validation thoroughly - Cover edge cases in input validation

Static Files

Ferro serves static files from the public/ directory for any request that doesn't match a registered route. This works automatically with zero configuration.

How It Works

When a GET or HEAD request doesn't match any route, Ferro checks if a corresponding file exists in public/. If found, it serves the file with the correct MIME type and cache headers. If not, the request falls through to the fallback handler (e.g., Inertia SPA catch-all) or returns 404.

Request: GET /assets/main-abc123.js
  1. Route matching → no match
  2. Static file check → public/assets/main-abc123.js exists
  3. Serve file with Content-Type: application/javascript

Only GET and HEAD requests trigger static file checks. POST, PUT, DELETE, and other methods skip filesystem checks entirely.

Cache Strategy

Ferro applies differentiated cache headers based on the request path:

Path patternCache-ControlRationale
/assets/*public, max-age=31536000, immutableVite hashed output — content hash in filename means the URL changes when content changes
Everything elsepublic, max-age=0, must-revalidateRoot files like favicon.ico, robots.txt may change without URL change

This means Vite build output (public/assets/) is cached for one year with no revalidation, while root-level files are always revalidated.

Security

Static file serving includes the following protections:

  • Dotfile rejection: Paths containing segments starting with . are rejected (prevents serving .env, .git/config, .planning/, etc.)
  • Directory traversal protection: Paths are canonicalized and verified to remain within public/. Symlinks and .. segments that resolve outside public/ are blocked.
  • Null byte rejection: Paths containing null bytes are rejected.
  • Directory listing disabled: Requests to directories return nothing (falls through to fallback/404).

Development vs Production

In development, ferro serve starts the Vite dev server which handles asset serving via HMR. The HTML references http://localhost:5173/src/main.tsx, not /assets/main.js. Since public/assets/ doesn't exist until vite build runs, static file serving is effectively a no-op in development.

In production, vite build outputs hashed files to public/assets/. The compiled Ferro binary serves these files directly.

Large Files

Static file serving reads entire files into memory. This is appropriate for typical web assets (JS, CSS, fonts, images under 1MB).

For large files (video, datasets, user uploads), use Storage with a CDN or object storage service instead of placing files in public/.

Inertia.js

Ferro provides first-class Inertia.js integration, enabling you to build modern single-page applications using React while keeping your routing and controllers on the server. This gives you the best of both worlds: the snappy feel of an SPA with the simplicity of server-side rendering.

How Inertia Works

Inertia.js is a protocol that connects your server-side framework to a client-side framework (React, Vue, or Svelte). Instead of returning HTML or building a separate API:

  1. Your controller returns an Inertia response with a component name and props
  2. On the first request, a full HTML page is rendered with the initial data
  3. On subsequent requests, only JSON is returned
  4. The client-side adapter swaps components without full page reloads

Configuration

Environment Variables

Configure Inertia in your .env file:

# Vite development server URL
VITE_DEV_SERVER=http://localhost:5173

# Frontend entry point
VITE_ENTRY_POINT=src/main.tsx

# Asset version for cache busting
INERTIA_VERSION=1.0

# Development mode (enables HMR)
APP_ENV=development

Bootstrap Setup

In src/bootstrap.rs, configure Inertia:

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

pub async fn register() {
    // Configure from environment
    let config = InertiaConfig::from_env();
    App::set_inertia_config(config);
}
}

Manual Configuration

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

let config = InertiaConfig {
    vite_dev_server: "http://localhost:5173".to_string(),
    entry_point: "src/main.tsx".to_string(),
    version: "1.0".to_string(),
    development: true,
    html_template: None,
};
}

Basic Usage

Rendering Responses

Use Inertia::render() to return an Inertia response:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response};
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() or req.input(), the body is consumed. But Inertia::render() needs request metadata (headers, URL). SavedInertiaContext captures this metadata before body consumption.

Frontend Setup

Project Structure

your-app/
├── src/                    # Rust backend
│   ├── controllers/
│   ├── middleware/
│   └── main.rs
├── frontend/               # React frontend
│   ├── src/
│   │   ├── pages/          # Inertia page components
│   │   │   ├── Home.tsx
│   │   │   ├── Dashboard.tsx
│   │   │   └── Users/
│   │   │       ├── Index.tsx
│   │   │       └── Show.tsx
│   │   ├── components/     # Shared components
│   │   ├── layouts/        # Layout components
│   │   └── main.tsx        # Entry point
│   ├── package.json
│   └── vite.config.ts
└── Cargo.toml

Entry Point (main.tsx)

import { createInertiaApp } from '@inertiajs/react';
import { createRoot } from 'react-dom/client';

createInertiaApp({
    resolve: (name) => {
        const pages = import.meta.glob(['./pages/**/*.tsx', '!**/*.test.tsx'], { eager: true });
        return pages[`./pages/${name}.tsx`];
    },
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />);
    },
});

Vite Configuration

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [react()],
    server: {
        port: 5173,
        strictPort: true,
    },
    build: {
        manifest: true,
        outDir: '../public/build',
        rollupOptions: {
            input: 'src/main.tsx',
        },
    },
});

Package Dependencies

{
    "dependencies": {
        "@inertiajs/react": "^1.0.0",
        "react": "^18.2.0",
        "react-dom": "^18.2.0"
    },
    "devDependencies": {
        "@types/react": "^18.2.0",
        "@types/react-dom": "^18.2.0",
        "@vitejs/plugin-react": "^4.0.0",
        "typescript": "^5.0.0",
        "vite": "^5.0.0"
    }
}

Use the Inertia Link component for client-side navigation:

import { Link } from '@inertiajs/react';

export default function Navigation() {
    return (
        <nav>
            <Link href="/">Home</Link>
            <Link href="/about">About</Link>
            <Link href="/users" method="get" as="button">Users</Link>
        </nav>
    );
}

Programmatic Navigation

import { router } from '@inertiajs/react';

function handleClick() {
    router.visit('/dashboard');
}

function handleSubmit(data) {
    router.post('/posts', data, {
        onSuccess: () => {
            // Handle success
        },
    });
}

Partial Reloads

Inertia supports partial reloads to refresh only specific props without a full page reload.

Requesting Partial Data

import { router } from '@inertiajs/react';

// Only reload the 'users' prop
router.reload({ only: ['users'] });

// Reload specific props
router.visit('/dashboard', {
    only: ['notifications', 'messages'],
});

Server-Side Handling

Ferro automatically handles partial reload requests. The X-Inertia-Partial-Data header specifies which props to return:

#![allow(unused)]
fn main() {
#[handler]
pub async fn dashboard(req: Request) -> Response {
    // All props are computed, but only requested ones are sent
    Inertia::render(&req, "Dashboard", DashboardProps {
        user: get_user().await?,          // Always sent on full load
        notifications: get_notifications().await?,  // Only if requested
        stats: get_stats().await?,        // Only if requested
    })
}
}

Version Conflict Handling

When your assets change (new deployment), Inertia uses versioning to force a full page reload.

Checking Version

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

#[handler]
pub async fn index(req: Request) -> Response {
    // Check if client version matches
    if let Some(response) = Inertia::check_version(&req, "1.0", "/") {
        return response;  // Returns 409 Conflict
    }

    Inertia::render(&req, "Home", HomeProps { ... })
}
}

Middleware Approach

#![allow(unused)]
fn main() {
pub struct InertiaVersionCheck;

#[async_trait]
impl Middleware for InertiaVersionCheck {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let current_version = std::env::var("INERTIA_VERSION")
            .unwrap_or_else(|_| "1.0".to_string());

        if let Some(response) = Inertia::check_version(&request, &current_version, "/") {
            return response;
        }

        next(request).await
    }
}
}

Forms

Basic Form Handling

import { useForm } from '@inertiajs/react';

export default function CreatePost() {
    const { data, setData, post, processing, errors } = useForm({
        title: '',
        content: '',
    });

    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        post('/posts');
    }

    return (
        <form onSubmit={handleSubmit}>
            <input
                value={data.title}
                onChange={e => setData('title', e.target.value)}
            />
            {errors.title && <span>{errors.title}</span>}

            <textarea
                value={data.content}
                onChange={e => setData('content', e.target.value)}
            />
            {errors.content && <span>{errors.content}</span>}

            <button type="submit" disabled={processing}>
                Create Post
            </button>
        </form>
    );
}

Server-Side Validation Response

#![allow(unused)]
fn main() {
use ferro::inertia::{Inertia, SavedInertiaContext};

#[handler]
pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from_request(&req);
    let data: CreatePostRequest = req.json().await?;

    let errors = validate_post(&data);
    if errors.fails() {
        // Return to form with errors
        return Inertia::render_ctx(&ctx, "Posts/Create", CreatePostProps {
            errors: errors.to_json(),
        });
    }

    let post = Post::create(&data).await?;
    redirect!(format!("/posts/{}", post.id))
}
}

TypeScript Generation

Ferro can generate TypeScript types from your Rust props:

ferro generate-types

This creates type definitions for your InertiaProps structs:

// Generated: frontend/src/types/props.d.ts
export interface HomeProps {
    title: string;
    message: string;
}

export interface DashboardProps {
    userName: string;
    totalPosts: number;
    isAdmin: boolean;
}

Automatic Type Generation

When running ferro serve, TypeScript types are automatically regenerated whenever you modify a file containing InertiaProps structs. Changes are debounced (500ms) to avoid excessive regeneration.

You'll see [types] Regenerated N type(s) in the console when types are updated.

To disable automatic regeneration:

ferro serve --skip-types

Note: Type watching is disabled in --backend-only mode since there's no frontend to update.

Custom Types

The type generator automatically discovers structs with #[derive(InertiaProps)]. For nested types that don't have this derive, you have two options.

Create manual TypeScript type files for complex domain types:

// frontend/src/types/theme-config.ts
export interface ThemeConfig {
  primaryColor?: string;
  fontFamily?: string;
  borderRadius?: number;
}

export interface BottomNavConfig {
  enabled: boolean;
  items: NavItem[];
}

Then import in your components:

import { ThemeConfig } from '@/types/theme-config';
import { DashboardProps } from '@/types/props';  // Auto-generated

interface Props extends DashboardProps {
  themeConfig: ThemeConfig;
}

Option 2: Add InertiaProps Derive

For shared types used in multiple props, add the derive:

#![allow(unused)]
fn main() {
#[derive(Serialize, InertiaProps)]
pub struct ThemeConfig {
    pub primary_color: Option<String>,
    pub font_family: Option<String>,
}
}

This will include ThemeConfig in the generated types.

Note: The generator only scans src/ directory for InertiaProps. Types in libraries or other locations need manual definitions.

Generated Type Utilities

The generated props.d.ts includes utility types:

// Arbitrary JSON values
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

// Validation errors
export type ValidationErrors = Record<string, string[]>;

Use these in your components:

import { JsonValue, ValidationErrors } from '@/types/props';

interface FormProps {
  errors: ValidationErrors | null;
  metadata: JsonValue;
}

Development vs Production

Development Mode

In development, Ferro serves the Vite dev server with HMR:

#![allow(unused)]
fn main() {
let config = InertiaConfig {
    development: true,
    vite_dev_server: "http://localhost:5173".to_string(),
    // ...
};
}

The rendered HTML includes:

<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main.tsx"></script>

Production Mode

In production, Ferro uses the built manifest:

#![allow(unused)]
fn main() {
let config = InertiaConfig {
    development: false,
    // ...
};
}

The rendered HTML includes hashed assets:

<script type="module" src="/build/assets/main-abc123.js"></script>
<link rel="stylesheet" href="/build/assets/main-def456.css">

JSON API Fallback

For testing or API clients that need raw JSON data from Inertia routes, enable JSON fallback:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response};
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: true header → Normal Inertia JSON response
  • Requests with Accept: application/json (no X-Inertia) → Raw props as JSON
  • Browser requests → Full HTML page

This is useful for:

  • API testing with curl or Postman
  • Hybrid apps that sometimes need raw JSON
  • Debug tooling

Example with curl:

# Get raw JSON props
curl -H "Accept: application/json" http://localhost:3000/posts/1

# Get normal Inertia response
curl -H "X-Inertia: true" http://localhost:3000/posts/1

# Get HTML page
curl http://localhost:3000/posts/1

Note: This is opt-in per route. Consider security implications before enabling on routes that return sensitive data.

Example: Complete CRUD

Routes

#![allow(unused)]
fn main() {
use ferro::{get, post, put, delete};

pub fn routes() -> Vec<Route> {
    vec![
        get!("/posts", controllers::posts::index),
        get!("/posts/create", controllers::posts::create),
        post!("/posts", controllers::posts::store),
        get!("/posts/{post}", controllers::posts::show),
        get!("/posts/{post}/edit", controllers::posts::edit),
        put!("/posts/{post}", controllers::posts::update),
        delete!("/posts/{post}", controllers::posts::destroy),
    ]
}
}

Controller

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, redirect};
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-Inertia header
  • Uses 303 status for POST/PUT/PATCH/DELETE (forces GET on redirect)
  • Includes proper X-Inertia: true response header

With Saved Context

If you've consumed the request with req.input(), use the saved context:

#![allow(unused)]
fn main() {
use ferro::{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

  1. Use InertiaProps derive - Automatic camelCase conversion matches JavaScript conventions
  2. Save context before consuming request - Use SavedInertiaContext for validation flows
  3. Share common data via middleware - Auth, flash, CSRF in ShareInertiaData
  4. Organize pages in folders - Posts/Index.tsx, Posts/Show.tsx for clarity
  5. Use compile-time validation - inertia_response! macro catches typos early
  6. Handle version conflicts - Ensure smooth deployments with version checking
  7. Keep props minimal - Only send what the page needs
  8. Use partial reloads - Optimize updates by requesting only changed data
  9. Use Inertia::redirect() for form success - Ensures proper 303 status for Inertia XHR requests

Troubleshooting

Request Body Already Consumed

Symptom: Error when calling Inertia::render() after req.json() or req.input().

Cause: The request body was consumed before rendering. In Rust, request bodies can only be read once.

Solution: Use SavedInertiaContext to capture request metadata before consuming the body:

#![allow(unused)]
fn main() {
let ctx = SavedInertiaContext::from_request(&req);  // Save first
let data = req.json().await?;                        // Then consume
Inertia::render_ctx(&ctx, "Component", props)        // Use saved context
}

Validation Errors Not Displaying

Symptom: Form validation errors are lost after redirect.

Cause: Using redirect!() after validation failure instead of re-rendering with errors.

Solution: On validation failure, render the form again with errors. On success, redirect:

#![allow(unused)]
fn main() {
if errors.fails() {
    // Re-render form with errors (don't redirect)
    return Inertia::render_ctx(&ctx, "Form", FormProps {
        errors: Some(errors.to_json()),
        old: Some(data),
    });
}

// Only redirect on success
Inertia::redirect_ctx(&ctx, "/success")
}

Props Not Updating After Navigation

Symptom: Page shows stale data after Inertia navigation.

Cause: Browser caching or partial reload configuration issue.

Solution: Check that your handler returns fresh data and consider using router.reload() on the frontend to force a refresh:

import { router } from '@inertiajs/react';

// Force reload current page data
router.reload();

// Reload only specific props
router.reload({ only: ['items'] });

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

  1. Define a JsonUiView containing a tree of ComponentNode values
  2. Attach data, actions, and visibility rules to components
  3. Call JsonUi::render() to produce a full HTML page with Tailwind classes
  4. 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 CaseJSON-UIInertia
Admin panels and dashboardsIdealOverkill
CRUD applicationsIdealWorks, but heavier setup
Rapid prototypingIdealSlower iteration
Server-rendered pagesBuilt for thisNot designed for this
Rich interactive UIsLimitedIdeal
Complex client stateNot suitedIdeal
SPA behaviorNot suitedIdeal

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 the Layout trait.

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

PropTypeRequiredDefaultDescription
centerOption<[f64; 2]>No*--Map center as [latitude, longitude]
zoomu8No13Zoom level (0-18)
heightStringNo"400px"CSS height of the map container
fit_boundsOption<bool>No--Auto-zoom to fit all markers. When true, center/zoom are ignored if markers exist
markersVec<MapMarker>No[]Markers to display
tile_urlStringNoOpenStreetMapCustom tile layer URL template
attributionStringNoOSM attributionTile layer attribution text
max_zoomu8No19Maximum zoom level

*center is optional when fit_bounds is true and markers are provided.

MapMarker

FieldTypeRequiredDescription
latf64YesLatitude
lngf64YesLongitude
popupOption<String>NoPlain text popup content
colorOption<String>NoHex color for a colored CSS pin (e.g., "#3B82F6"). Renders as a DivIcon instead of the default marker
popup_htmlOption<String>NoHTML content for the popup (alternative to plain text popup)
hrefOption<String>NoURL 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 IntersectionObserver calls invalidateSize() 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.com for scripts and https://*.tile.openstreetmap.org for 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_path tells the table where to find its row data in the handler response
  • row_actions adds action buttons to each row (Edit link, Delete with confirmation)
  • ColumnFormat::Date formats the created_at column as a date
  • Pagination renders 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

CategoryComponents
DisplayCard, Table, Badge, Alert, Separator, DescriptionList, Text, Button
FormForm, Input, Select, Checkbox, Switch
NavigationTabs, Breadcrumb, Pagination
FeedbackProgress, Avatar, Skeleton
LayoutModal

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.

ValueSerialized
Size::Xs"xs"
Size::Sm"sm"
Size::Default"default"
Size::Lg"lg"

ButtonVariant

Visual styles for the Button component (aligned to shadcn/ui).

ValueSerializedUse 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.

ValueSerialized
AlertVariant::Info"info"
AlertVariant::Success"success"
AlertVariant::Warning"warning"
AlertVariant::Error"error"

BadgeVariant

Visual styles for the Badge component (aligned to shadcn/ui).

ValueSerialized
BadgeVariant::Default"default"
BadgeVariant::Secondary"secondary"
BadgeVariant::Destructive"destructive"
BadgeVariant::Outline"outline"

ColumnFormat

Display format for Table columns and DescriptionList items.

ValueSerialized
ColumnFormat::Date"date"
ColumnFormat::DateTime"date_time"
ColumnFormat::Currency"currency"
ColumnFormat::Boolean"boolean"

TextElement

Semantic HTML element for the Text component.

ValueSerializedHTML
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.

PropTypeRequiredDefaultDescription
titleStringYes-Card title
descriptionOption<String>NoNoneDescription below the title
childrenVec<ComponentNode>No[]Nested components in the card body
footerVec<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.

PropTypeRequiredDefaultDescription
columnsVec<Column>Yes-Column definitions
data_pathStringYes-Path to the row data array (e.g., "/data/users")
row_actionsOption<Vec<Action>>NoNoneActions available per row
empty_messageOption<String>NoNoneMessage when no data
sortableOption<bool>NoNoneEnable column sorting
sort_columnOption<String>NoNoneCurrently sorted column key
sort_directionOption<SortDirection>NoNoneSort direction: asc or desc

Column defines a table column:

FieldTypeRequiredDescription
keyStringYesData field key matching the row object
labelStringYesColumn header text
formatOption<ColumnFormat>NoDisplay 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.

PropTypeRequiredDefaultDescription
labelStringYes-Badge text
variantBadgeVariantNoDefaultVisual 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.

PropTypeRequiredDefaultDescription
messageStringYes-Alert message content
variantAlertVariantNoInfoVisual style
titleOption<String>NoNoneAlert 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.

PropTypeRequiredDefaultDescription
orientationOption<Orientation>NoHorizontalDirection: 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.

PropTypeRequiredDefaultDescription
itemsVec<DescriptionItem>Yes-Key-value items
columnsOption<u8>NoNoneNumber of columns for layout

DescriptionItem defines a key-value pair:

FieldTypeRequiredDescription
labelStringYesItem label
valueStringYesItem value
formatOption<ColumnFormat>NoDisplay 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.

PropTypeRequiredDefaultDescription
contentStringYes-Text content
elementTextElementNoPHTML 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.

PropTypeRequiredDefaultDescription
labelStringYes-Button label text
variantButtonVariantNoDefaultVisual style
sizeSizeNoDefaultButton size
disabledOption<bool>NoNoneWhether the button is disabled
iconOption<String>NoNoneIcon name
icon_positionOption<IconPosition>NoLeftIcon 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.

PropTypeRequiredDefaultDescription
actionActionYes-Action to execute on form submit
fieldsVec<ComponentNode>Yes-Form field components (Input, Select, Checkbox, etc.)
methodOption<HttpMethod>NoNoneHTTP 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.

PropTypeRequiredDefaultDescription
fieldStringYes-Form field name for data binding
labelStringYes-Input label text
input_typeInputTypeNoTextInput type
placeholderOption<String>NoNonePlaceholder text
requiredOption<bool>NoNoneWhether the field is required
disabledOption<bool>NoNoneWhether the field is disabled
errorOption<String>NoNoneValidation error message
descriptionOption<String>NoNoneHelp text below the input
default_valueOption<String>NoNonePre-filled value
data_pathOption<String>NoNoneData path for pre-filling from handler data (e.g., "/data/user/name")
stepOption<String>NoNoneHTML step attribute for number inputs (e.g., "any", "0.01"). Controls valid increment granularity.

InputType variants:

ValueSerialized
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.

PropTypeRequiredDefaultDescription
fieldStringYes-Form field name for data binding
labelStringYes-Select label text
optionsVec<SelectOption>Yes-Options list
placeholderOption<String>NoNonePlaceholder text
requiredOption<bool>NoNoneWhether the field is required
disabledOption<bool>NoNoneWhether the field is disabled
errorOption<String>NoNoneValidation error message
descriptionOption<String>NoNoneHelp text below the select
default_valueOption<String>NoNonePre-selected value
data_pathOption<String>NoNoneData path for pre-filling from handler data

SelectOption defines a value-label pair:

FieldTypeRequiredDescription
valueStringYesOption value submitted with the form
labelStringYesDisplay 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.

PropTypeRequiredDefaultDescription
fieldStringYes-Form field name for data binding
labelStringYes-Checkbox label text
descriptionOption<String>NoNoneHelp text below the checkbox
checkedOption<bool>NoNoneDefault checked state
data_pathOption<String>NoNoneData path for pre-filling from handler data
requiredOption<bool>NoNoneWhether the field is required
disabledOption<bool>NoNoneWhether the field is disabled
errorOption<String>NoNoneValidation 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.

PropTypeRequiredDefaultDescription
fieldStringYes-Form field name for data binding
labelStringYes-Switch label text
descriptionOption<String>NoNoneHelp text below the switch
checkedOption<bool>NoNoneDefault checked state
data_pathOption<String>NoNoneData path for pre-filling from handler data
requiredOption<bool>NoNoneWhether the field is required
disabledOption<bool>NoNoneWhether the field is disabled
errorOption<String>NoNoneValidation 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,
}
}

Tabs

Tabbed content with multiple panels. Each tab contains its own set of child components.

PropTypeRequiredDefaultDescription
default_tabStringYes-Value of the initially active tab
tabsVec<Tab>Yes-Tab definitions

Tab defines a tab panel:

FieldTypeRequiredDescription
valueStringYesTab identifier (matches default_tab)
labelStringYesTab label text
childrenVec<ComponentNode>NoComponents 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.

PropTypeRequiredDefaultDescription
current_pageu32Yes-Current page number
per_pageu32Yes-Items per page
totalu32Yes-Total number of items
base_urlOption<String>NoNoneBase 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,
}
}

Navigation breadcrumb trail. The last item typically has no URL (current page).

PropTypeRequiredDefaultDescription
itemsVec<BreadcrumbItem>Yes-Breadcrumb items

BreadcrumbItem defines a breadcrumb entry:

FieldTypeRequiredDescription
labelStringYesBreadcrumb text
urlOption<String>NoLink 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.

PropTypeRequiredDefaultDescription
valueu8Yes-Percentage value (0-100)
maxOption<u8>NoNoneMaximum value
labelOption<String>NoNoneLabel 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.

PropTypeRequiredDefaultDescription
srcOption<String>NoNoneImage URL
altStringYes-Alt text (required for accessibility)
fallbackOption<String>NoNoneFallback initials when no image
sizeOption<Size>NoDefaultAvatar 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.

PropTypeRequiredDefaultDescription
widthOption<String>NoNoneCSS width (e.g., "100%", "200px")
heightOption<String>NoNoneCSS height (e.g., "40px")
roundedOption<bool>NoNoneWhether 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

Dialog overlay with title, content, footer, and trigger button.

PropTypeRequiredDefaultDescription
titleStringYes-Modal title
descriptionOption<String>NoNoneModal description
childrenVec<ComponentNode>No[]Content components inside the modal body
footerVec<ComponentNode>No[]Components in the modal footer
trigger_labelOption<String>NoNoneLabel 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:

FieldTypeDescription
titleStringDialog heading text
messageOption<String>Optional detail text
variantDialogVariantDefault 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:

OutcomeDescription
Redirect { url }Navigate to a URL
RefreshReload the current page
ShowErrorsDisplay 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

OperatorSerializedValue RequiredDescription
ExistsexistsNoPath resolves to a non-null value
NotExistsnot_existsNoPath does not resolve
EqeqYesValue equals
NotEqnot_eqYesValue does not equal
GtgtYesGreater than
LtltYesLess than
GtegteYesGreater than or equal
LtelteYesLess than or equal
ContainscontainsYesString or array contains value
NotEmptynot_emptyNoValue is not empty (non-null, non-empty string/array)
EmptyemptyNoValue 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:

  1. For each Input, Select, Checkbox, and Switch component, the resolver checks if the errors map contains the field name
  2. If found, the first error message is set on the component's error prop
  3. Existing explicit errors are never overwritten (do-not-overwrite rule)
  4. The full errors map is also set on view.errors for 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.

  1. View specifies a layout: JsonUiView::new().layout("app")
  2. Components are rendered to HTML
  3. The layout wraps the HTML in a complete page with <head>, navigation, and <body> structure
  4. The view JSON and data are embedded as data-view and data-props attributes 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:

FieldTypeDescription
title&strPage title from the view (defaults to "Ferro")
content&strRendered component HTML fragment
head&strAdditional <head> content (Tailwind CDN, custom styles)
body_class&strCSS classes for the <body> element
view_json&strSerialized view JSON for the data-view attribute
data_json&strSerialized 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.

The layout module provides partial rendering functions for building navigation:

#![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">"#);
}
FieldDefaultDescription
tailwind_cdntrueInclude Tailwind CDN <script> in <head>
custom_headNoneCustom 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:

OptionDescription
--no-interactionSkip prompts, use defaults
--no-gitDon't initialize git repository

Generated Structure:

my-app/
├── src/
│   ├── main.rs
│   ├── bootstrap.rs
│   ├── routes.rs
│   ├── controllers/
│   │   └── mod.rs
│   ├── middleware/
│   │   ├── mod.rs
│   │   └── cors.rs
│   ├── models/
│   │   └── mod.rs
│   ├── migrations/
│   │   └── mod.rs
│   ├── events/
│   │   └── mod.rs
│   ├── listeners/
│   │   └── mod.rs
│   ├── jobs/
│   │   └── mod.rs
│   ├── notifications/
│   │   └── mod.rs
│   ├── tasks/
│   │   └── mod.rs
│   ├── seeders/
│   │   └── mod.rs
│   └── factories/
│       └── mod.rs
├── frontend/
│   ├── src/
│   │   ├── pages/
│   │   │   └── Home.tsx
│   │   ├── layouts/
│   │   │   └── Layout.tsx
│   │   ├── types/
│   │   │   └── inertia.d.ts
│   │   ├── app.tsx
│   │   └── main.tsx
│   ├── package.json
│   ├── tsconfig.json
│   ├── vite.config.ts
│   └── tailwind.config.js
├── Cargo.toml
├── .env
├── .env.example
└── .gitignore

Development Commands

ferro serve

Start the development server 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:

OptionDefaultDescription
--port3000Backend server port
--frontend-port5173Frontend dev server port
--backend-onlyfalseRun only the backend
--frontend-onlyfalseRun only the frontend
--skip-typesfalseDon't regenerate TypeScript types

What it does:

  1. Starts the Rust backend with cargo watch for hot reloading
  2. Starts the Vite frontend dev server
  3. Watches Rust files to regenerate TypeScript types automatically
  4. Proxies frontend requests to the backend
  5. 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:

OptionDescription
--resourceGenerate index, show, create, store, edit, update, destroy methods
--apiGenerate API-style controller (JSON responses)

Generated file: src/controllers/user_controller.rs

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

#[handler]
pub async fn index(req: Request) -> Response {
    // 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:

OptionDescription
--force, -fOverwrite existing auth controller and migration

Generated Files:

  • src/migrations/m{timestamp}_add_auth_fields_to_users.rs -- ALTER TABLE migration adding password, remember_token, and email_verified_at fields to the existing users table
  • src/controllers/auth_controller.rs -- Controller with register, login, and logout handlers

What it does:

  1. Generates an ALTER TABLE migration (assumes users table already exists)
  2. Creates an auth controller with register/login/logout handlers
  3. Registers the controller module in src/controllers/mod.rs
  4. Prints setup instructions for the auth provider and route registration

The command uses an ALTER TABLE approach because most projects already have a users table with basic fields. The migration adds only the authentication-specific columns.

See also: Authentication guide for the complete auth setup walkthrough.

ferro make:event

Generate an event struct for the event dispatcher.

ferro make:event UserRegistered
ferro make:event OrderPlaced

Generated file: src/events/user_registered.rs

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

#[derive(Debug, Clone, Event)]
pub struct UserRegistered {
    pub user_id: i64,
}
}

ferro make:listener

Generate a listener that responds to events.

ferro make:listener SendWelcomeEmail
ferro make:listener NotifyAdmins

Generated file: src/listeners/send_welcome_email.rs

#![allow(unused)]
fn main() {
use ferro_events::{Listener, Event};
use async_trait::async_trait;

pub struct SendWelcomeEmail;

#[async_trait]
impl<E: Event + Send + Sync> Listener<E> for SendWelcomeEmail {
    async fn handle(&self, event: &E) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        // 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.tsx
  • src/controllers/ (props struct)

ferro make:json-view

Generate a JSON-UI view file with AI-powered component generation.

# Generate a view (AI-powered if ANTHROPIC_API_KEY is set)
ferro make:json-view UserIndex

# Generate with a description for AI context
ferro make:json-view UserEdit --description "Edit form for user profile"

# Specify a layout
ferro make:json-view Login --layout auth

# Skip AI generation, use static template
ferro make:json-view Dashboard --no-ai

Options:

OptionDescription
--description, -dDescription of the desired UI for AI generation
--layout, -lLayout to use (default: app)
--no-aiSkip AI generation, use static template

How it works:

  1. Scans your models and routes for project context
  2. Sends context to Anthropic API (Claude Sonnet) for intelligent view generation
  3. Falls back to a static template if no API key is configured or AI generation fails
  4. Generates a view file in src/views/ and updates mod.rs

Requirements:

  • Set ANTHROPIC_API_KEY in your environment for AI-powered generation
  • Without the key, a static template with common components is generated
  • Model override via FERRO_AI_MODEL environment variable

Generated file: src/views/user_index.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:

OptionDescription
--model, -mModel path for auto-generating From<Model> implementation

Generated file: src/resources/user_resource.rs

#![allow(unused)]
fn main() {
use ferro::{ApiResource, Resource, ResourceMap, Request};

#[derive(ApiResource)]
pub struct UserResource {
    pub id: i32,
    // Add fields from your model here
    // #[resource(rename = "display_name")]
    // pub name: String,
    // #[resource(skip)]
    // pub password_hash: String,
}
}

Field attributes:

AttributeDescription
#[resource(rename = "name")]Rename field in JSON output
#[resource(skip)]Exclude field from JSON output

The #[derive(ApiResource)] macro generates the Resource trait implementation, which provides to_resource() and to_json() methods. When --model is specified, a From<Model> implementation is generated to map model fields to resource fields.

After generation, add pub mod user_resource; to src/resources/mod.rs.

See also: API Resources guide for the complete resource system documentation.

ferro make:scaffold

Generate a complete CRUD scaffold: model, migration, controller, and Inertia pages.

# Basic scaffold
ferro make:scaffold Post

# With field definitions
ferro make:scaffold Post title:string content:text published:bool

# Complex example
ferro make:scaffold Product name:string description:text price:float stock:integer

Field Types:

TypeRust TypeDatabase Type
stringStringVARCHAR(255)
textStringTEXT
integeri32INTEGER
biginti64BIGINT
floatf64DOUBLE
boolboolBOOLEAN
datetimeDateTimeTIMESTAMP
dateDateDATE
uuidUuidUUID

Generated Files:

src/
├── models/post.rs           # SeaORM entity
├── migrations/m*_create_posts_table.rs
├── controllers/post_controller.rs
frontend/src/pages/
├── posts/
│   ├── Index.tsx
│   ├── Show.tsx
│   ├── Create.tsx
│   └── Edit.tsx

Database Commands

ferro db:migrate

Run all pending migrations.

ferro db:migrate

ferro db:rollback

Rollback the last batch of migrations.

ferro db:rollback

ferro db:status

Show the status of all migrations.

ferro db:status

Output:

+------+------------------------------------------------+-------+
| Ran? | Migration                                       | Batch |
+------+------------------------------------------------+-------+
| Yes  | m20240101_000001_create_users_table            | 1     |
| Yes  | m20240101_000002_create_posts_table            | 1     |
| No   | m20240115_143052_add_status_to_posts           |       |
+------+------------------------------------------------+-------+

ferro db:fresh

Drop all tables and re-run all migrations.

ferro db:fresh

Warning: This is destructive and will delete all data.

ferro db:seed

Run database seeders to populate the database with test data.

# Run all seeders
ferro db:seed

# Run a specific seeder
ferro db:seed --class UserSeeder

Options:

OptionDescription
--classRun only a specific seeder by name

How it works:

The command delegates to cargo run -- db:seed in your project, which executes the registered seeders. Seeders are Rust structs implementing the Seeder trait, located in src/seeders/.

If no seeders directory exists, the command will prompt you to create one with ferro make:seeder <name>.

See also: ferro make:seeder to generate new seeder files.

ferro db:sync

Synchronize the database schema and generate entity files.

# Sync entities from existing database
ferro db:sync

# Run migrations first, then sync
ferro db:sync --migrate

Options:

OptionDescription
--migrateRun pending migrations before syncing

This command:

  1. Discovers the database schema (tables, columns, types)
  2. Generates SeaORM entity files in src/models/entities/
  3. Creates user-friendly model wrappers with the Ferro Model API

ferro db:query

Execute a raw SQL query against the database.

# Simple SELECT query
ferro db:query "SELECT * FROM users LIMIT 5"

# Query with conditions
ferro db:query "SELECT id, name, email FROM users WHERE active = true"

# Count query
ferro db:query "SELECT COUNT(*) FROM posts"

Features:

  • Reads DATABASE_URL from .env file
  • Supports SQLite and PostgreSQL databases
  • Displays results in a formatted table
  • Handles NULL values gracefully
  • Shows row count after results

Example Output:

+-----+-------+-------------------+
| 1   | Alice | alice@example.com |
| 2   | Bob   | bob@example.com   |
+-----+-------+-------------------+

→ 2 row(s)

Use Cases:

  • Quick data inspection during development
  • Debugging database state
  • Verifying migration results
  • Ad-hoc queries without external tools

Deployment Commands

ferro do:init

Initialize DigitalOcean App Platform deployment. Generates 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 in owner/repo format (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

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:

OptionDescription
--force, -fOverwrite existing skill files
--list, -lList available skills without installing

Installed Location: ~/.claude/commands/ferro/

Available Skills:

CommandDescription
/ferro:helpShow all available Ferro commands
/ferro:infoDisplay project information
/ferro:routesList all registered routes
/ferro:route:explainExplain a specific route in detail
/ferro:modelGenerate a new model with migration
/ferro:modelsList all models with fields
/ferro:controllerGenerate a new controller
/ferro:middlewareGenerate new middleware
/ferro:dbDatabase operations (migrate, rollback, seed)
/ferro:testRun tests with coverage options
/ferro:serveStart the development server
/ferro:newCreate a new Ferro project
/ferro:tinkerInteractive database REPL
/ferro:diagnoseDiagnose errors using MCP introspection

Skills leverage ferro-mcp for intelligent code generation and project introspection.

Command Summary

CommandDescription
newCreate a new Ferro project
serveStart development server
generate-typesGenerate TypeScript types
make:controllerCreate a controller
make:middlewareCreate middleware
make:actionCreate an action class
make:authScaffold authentication system
make:eventCreate an event
make:listenerCreate a listener
make:jobCreate a background job
make:notificationCreate a notification
make:migrationCreate a migration
make:inertiaCreate an Inertia page
make:json-viewCreate a JSON-UI view (AI-powered)
make:taskCreate a scheduled task
make:seederCreate a database seeder
make:factoryCreate a model factory
make:errorCreate a custom error
make:policyCreate an authorization policy
make:resourceCreate an API resource
make:scaffoldCreate complete CRUD scaffold
db:migrateRun migrations
db:rollbackRollback migrations
db:statusShow migration status
db:freshFresh migrate (drop all)
db:seedRun database seeders
db:syncSync database schema
db:queryExecute raw SQL query
do:initInitialize DigitalOcean App Platform deployment
docker:initGenerate Dockerfile and .dockerignore
docker:composeManage Docker Compose
schedule:runRun due scheduled tasks
schedule:workStart scheduler worker
schedule:listList scheduled tasks
storage:linkCreate storage symlink
mcpStart MCP server
boost:installInstall AI boost features
claude:installInstall Claude Code skills

Environment Variables

The CLI respects these environment variables:

VariableDescription
DATABASE_URLDatabase connection string
APP_ENVApplication environment (development, production)
RUST_LOGLogging level

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:: to use ferro::
  • use cancer_ to use ferro_
  • cancer:: to ferro:: (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.