Introduction
Ferra is a Rust framework for building HTTP APIs from a single source of truth. You annotate a struct once — Ferra derives the routes, the SQL queries, the OpenAPI spec, the documentation UI, and the hypermedia automatically, at compile time. If the struct changes, everything downstream changes with it. If the generated code is inconsistent, the build fails.
The problem Ferra solves
Building a conventional REST API means describing the same data structure multiple times:
- once in a migration file (the database schema),
- once as a Rust struct (the model),
- once in each handler (request body, response type, SQL query),
- once in OpenAPI annotations (
#[utoipa::path(...)], response schemas), - once in validation rules.
Every copy can drift from the others — and none of these mismatches are caught at compile time. A field renamed in the struct but not in the OpenAPI annotation is a silent documentation lie. A validation rule missing from one handler is a silent security gap.
Ferra collapses all five copies into one struct:
use ferra::*;
#[derive(FerraModel)]
pub struct Film {
#[id]
pub id: Id,
pub title: String,
pub year: i32,
}
What you get from twelve lines
#[tokio::main]
async fn main() {
let conn = sea_orm::Database::connect(std::env::var("DATABASE_URL").unwrap()).await.unwrap();
let app = Foundry::new(conn).mount::<Film>().build();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Running cargo run gives you all of the following — no additional code required.
Five CRUD routes
| Method | Path | Description |
|---|---|---|
GET | /films | Paginated collection — ?page=1&per_page=20 |
GET | /films/:id | Single resource by ID |
POST | /films | Create — returns 201 + Location header |
PUT | /films/:id | Full update — returns 200 or 404 |
DELETE | /films/:id | Delete — returns 204 or 404 |
Pagination is first-class, not a bolt-on. The collection endpoint always returns
a page envelope with total, page, per_page, and the HAL _links for
next and prev.
Hypermedia _links in every response
Every item response includes a _links block following the HAL standard. Clients
and AI agents can navigate the API without prior knowledge of its URL structure:
{
"id": "01JVZ2RFJK3N7XQKE4M8P6SWTD",
"title": "Amélie",
"year": 2001,
"_links": {
"self": { "href": "https://api.example.com/films/01JVZ2RFJK3N7XQKE4M8P6SWTD" },
"collection": { "href": "https://api.example.com/films" }
}
}
The _links.self href is always absolute and always correct — built from the
Host header at request time, not hardcoded at build time.
OpenAPI 3.1 spec that cannot drift
The spec is served at /docs/openapi.json and the interactive Scalar UI at /docs.
Both are derived from the same struct the routes are derived from — they cannot
disagree with the running code. There are no annotations to forget, no
#[utoipa::path] attributes to keep in sync.
Add a field to Film and it appears in the spec, the Scalar UI, the request
validation, and the SQL insert — all at the next cargo build.
Typed SQL — injection-proof by construction
Every generated query is parameterized. There is no mechanism in the framework
to concatenate user input into a SQL string. SQL injection is structurally
excluded, not just discouraged — the framework has #![forbid(unsafe_code)] on
every crate.
RFC 7807 errors — typed and machine-readable
Every error response follows RFC 7807 problem details. The type field is a URI
from a closed set the framework maintains as a constant — not a free-form
string. SDK generators and AI agents can produce a typed branch for every
possible error because the set is expressed as a closed enum constraint in the
OpenAPI spec.
{
"type": "https://ferra.rs/errors/not_found",
"title": "Resource Not Found",
"status": 404,
"detail": "films/01JVZ2RFJK3N7XQKE4M8P6SWTD not found"
}
The complete error URI table is documented in Error Handling.
Security defaults, not security options
- Rate limiting on mutation routes (
POST,PUT,DELETE) — out of the box, no middleware to remember to add. - Body size cap — requests over 1 MiB are rejected before the body is read.
- CORS — restrictive by default. You opt in to origins, not out of open access.
#![forbid(unsafe_code)]— on every crate in the workspace, no exceptions.
Extending beyond the generated surface
The generated CRUD surface is a floor, not a ceiling. Ferra routes are standard Axum handlers — hand-written routes compose freely alongside them:
async fn top_rated(State(conn): State<DatabaseConnection>) -> Result<Json<Vec<Film>>, FerraError> {
let films = Film::find()
.filter(film::Column::Year.gt(2000))
.order_by_desc(film::Column::Rating)
.limit(10)
.all(&conn)
.await
.map_err(|e| FerraError::Internal(e.to_string()))?;
Ok(Json(films))
}
let app = Foundry::new(conn)
.mount::<Film>()
.route("/films/top-rated", get(top_rated))
.build();
Custom handlers reuse the same FerraError enum and emit the same RFC 7807
bodies — clients handle one error shape across the entire API surface.
What’s coming — Cast Iron to Hardened Steel
Ferra is in its Cast Iron phase (v0.5–v0.14): each version adds one major capability on the way to a stable 1.0.
v0.5 — Rolling · OpenAPI 3.1 + Scalar UI
The OpenAPI 3.1 spec and the Scalar interactive UI land here. The spec is a deterministic compiler output — if a field type changes, the spec changes. If the spec would be wrong, the code does not compile.
Also ships: ferra::DateTime and ferra::Date newtypes over jiff for
timestamp fields — serialized as RFC 3339, correctly handled in OpenAPI schemas.
v0.6 — Welding · Field validation
Declarative validation rules directly on struct fields, generated by the
proc-macro into garde validators. Rules are applied automatically on POST
and PUT before the query runs:
#[derive(FerraModel)]
pub struct Film {
#[id]
pub id: Id,
#[field(min_length = 1, max_length = 255)]
pub title: String,
#[field(min = 1888, max = 2100)]
pub year: i32,
#[field(email)]
pub contact: String,
#[field(url)]
pub poster: Option<String>,
}
Violations return 422 Unprocessable Content with a per-field errors map.
Every failing field is reported in a single response — clients never retry
to discover further errors.
{
"type": "https://ferra.rs/errors/validation_failed",
"title": "Validation Failed",
"status": 422,
"detail": "validation failed",
"errors": {
"title": ["must be at least 1 character"],
"year": ["must be at most 2100"],
"contact": ["must be a valid email address"]
}
}
v0.7 — Pre-tempering · Model behaviors
Common database patterns available as struct-level annotations:
#[derive(FerraModel)]
#[behavior(timestampable, soft_delete)]
pub struct Film {
#[id]
pub id: Id,
pub title: String,
}
timestampable—created_atandupdated_atare injected automatically.INSERTsets both;UPDATErefreshesupdated_at. No field declarations needed in the struct.soft_delete—DELETE /films/:idsetsdeleted_atinstead of removing the row. AllSELECTqueries automatically filterWHERE deleted_at IS NULL. A soft-deleted item is invisible to the API — it returns404, not a deleted record.
v0.8 — Forging · Authentication + authorization
Auth is deny-by-default. Forgetting to configure it is a startup panic, not a silent open endpoint.
You implement one trait to bridge your JWT claims to Ferra’s permission system:
impl FerraIdentity for MyClaims {
fn id(&self) -> &str { &self.sub }
fn has_role(&self, role: &str) -> bool { self.roles.contains(role) }
}
Then declare access rules on the model:
#[derive(FerraModel)]
#[authorize(
read = "IsAuthenticated",
write = "IsOwner | IsAdmin",
delete = "IsAdmin"
)]
pub struct Film {
#[id] pub id: Id,
pub title: String,
#[field(owner)]
pub owner_id: Id,
}
IsOwner is resolved automatically by comparing identity.id() to the
#[field(owner)] field — no custom handler code needed.
v0.8.5 extends this with external JWKS providers (Auth0, Keycloak, Cognito,
Google), API key authentication, and a FerraAuthProvider trait for bringing
any custom auth mechanism.
v0.9 — Forging III · Role-based field projections
Different callers see different fields of the same model, declared statically:
#[derive(FerraModel)]
#[project(read = ["id", "title", "year"], admin_read = ["id", "title", "year", "internal_notes"])]
#[authorize(read_as = { IsAdmin => "admin_read", _ => "read" })]
pub struct Film { /* ... */ }
The OpenAPI spec emits a oneOf for the two response shapes — SDK generators
produce typed variants, not a union of optional fields. The projection mismatch
is caught at compile time.
v0.10 — Assembly · Relations
IRI-based relations between models, declared in the struct:
#[derive(FerraModel)]
pub struct Film {
#[id] pub id: Id,
pub title: String,
#[field(belongs_to = Director)]
pub director_id: Id,
#[field(has_many = Review)]
pub reviews: Vec<Id>,
}
Foreign keys are stored as IDs. In API responses, they are rendered as IRI
references ("/directors/01HXYZ"). Write endpoints accept the same IRI format.
The OpenAPI spec documents the relation type correctly.
v0.12 — Sheen · Test client + observability
FerraTestClient for integration testing without a running server:
let client = FerraTestClient::new(state);
let res = client.create(Film { title: "Amélie".into(), year: 2001 }).await;
assert_eq!(res.status(), 201);
let res = client.with_auth(admin_claims).delete(id).await;
assert_eq!(res.status(), 204);
Also ships: built-in tracing spans per request and per DB query, and an
optional OpenTelemetry export via --features otel.
v0.14 — Deployment · Serverless
ferra = { package = "ferra-rs", version = "0.14", features = ["lambda"] }
// The same app code runs on Axum or Lambda — the entry point is the same.
ferra::run(app).await.unwrap();
FerraState::lazy() defers the DB connection to the first request for Lambda
cold-start optimization.
v1.0 — Hardened Steel · Stable API
Security audit, benchmarks, crates.io publication. The public API is
Semver-stable from this point. The invariant holds from the first beta: a
#[derive(FerraModel)] written at v1.0 compiles unchanged in v2, v3, v4, and
v5.
What Ferra is not
Not an ORM. Database schema management (migrations, table creation) is handled by Sea-ORM CLI and lives outside the framework. Ferra generates the queries; you manage the schema.
Not a general-purpose web framework. No template engines, no session handling, no WebSockets yet. Ferra is narrowly focused on REST + hypermedia + typed errors. When you need to go further, Axum handlers compose directly alongside Ferra routes.
Not stable yet. The public API follows Semver once 1.0 ships. Until then, minor versions may introduce breaking changes — each release documents them.
How to read this guide
- Getting Started — zero to running API in five minutes. Start here.
- Architecture — Ferra’s cohabitation principle: what the framework wraps, what it re-exports verbatim, and where the upstream crate docs are the canonical reference.
- Foundry — the router builder: configuration, middleware stack, mounting multiple models.
- Ferra DB —
PgRepository<M>, query methods, and how Sea-ORM sits underneath. - Ferra HTTP — extractors, the
FerraErrorenum, the built-in middleware stack. - Ferra OpenAPI — the generated spec, the Scalar UI, schema customization.
- Ferra Forge — the
#[derive(FerraModel)]proc-macro: field attributes, deferred keys, compile-time diagnostics. - Custom Handlers — Axum handlers alongside generated routes, sharing the same error taxonomy.
- Error Handling — the closed RFC 7807 error URI set, per-variant wire shape, client branching patterns.