Getting started with Ferra
Ferra is a Rust framework for building HTTP APIs from a single
#[model] source of truth. One model annotation gives you a CRUD
router, an OpenAPI 3.1 spec, a Swagger / Scalar UI, hypermedia
responses, and typed RFC 7807 errors — all derived at compile time.
This page walks you from cargo new to a running, documented API. If
you only have five minutes, read Add Ferra to your project, The
five-line CRUD, and Run it.
Important: the crate is published as ferra-rs
The ferra name on crates.io belongs to an unrelated project, so the
framework’s facade crate is published as ferra-rs. To keep your
Rust code idiomatic, use Cargo’s package alias — declare the
dependency under the local name ferra while pulling ferra-rs from
crates.io:
[dependencies]
ferra = { package = "ferra-rs", version = "0.5" }
The alias is invisible from this point forward. Every use ferra::...
import, every example in this guide, every doc snippet on docs.rs uses
the ferra name — Cargo handles the indirection.
ferra.rs(the domain) andferra-rs(the crate) are two different identifiers. The framework’s official domain isferra.rs— that’s where the documentation site lives and where the closed error-type namespace (https://ferra.rs/errors/<variant>) resolves. The crate name on crates.io isferra-rs(with a hyphen), because the dotlessferraname was already taken. Both are correct and authoritative; do not substitute one for the other.
If you see a plain
ferra = "..."line in older snippets (with nopackage = "ferra-rs"key), that’s pre-rename documentation. Replace it with the form above — the plain line resolves to a different, unrelated crate on crates.io.
Add Ferra to your project
A consumer crate built on Ferra needs one direct framework dependency plus the surrounding async / HTTP / serde stack:
[package]
name = "my-api"
version = "0.1.0"
edition = "2024"
[dependencies]
ferra = { package = "ferra-rs", version = "0.5" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
That is the entire framework dep surface. ferra-core, ferra-db,
ferra-forge, ferra-http, ferra-openapi, and the surrounding
sea-orm ORM are all reached through the ferra facade — they do
not appear as direct dependencies. Adding them directly fights the
facade’s cohabitation guarantees and is flagged by the framework’s
example dep-shape gate.
The one-line opener
Open every Ferra source file with a single glob over the prelude:
#![allow(unused)]
fn main() {
use ferra::prelude::*;
}
That brings into scope, in one line:
- The
#[derive(FerraModel)]derive — the model annotation. - The typed time newtypes
DateandDateTime. - The typed error type
FerraError. - The runtime state and router primitives
FerraState,ferra_router, and theFoundrybuilder. - The typed-extraction wrappers
FerraJson,FerraPath. - The response envelopes
ItemResponse,CollectionResponseand thePaginationParamsextractor. - Sea-ORM’s entity-derive prelude (
DeriveEntityModel,EntityTrait,ColumnTrait,Uuid, …) reached via the cohabitation re-export. serde::{Deserialize, Serialize}so model structs declare their derive set with no extra imports.
Reaching Sea-ORM through the facade
When you need Sea-ORM types directly — typically the
Database::connect(...) constructor, or the #[sea_orm(...)]
attribute namespace on a model struct — write ferra::sea_orm::...:
#![allow(unused)]
fn main() {
let conn = ferra::sea_orm::Database::connect(&database_url).await?;
}
The prelude glob already brings the sea_orm module name into scope,
so the standard sibling-derive pair #[derive(DeriveEntityModel, FerraModel)] works with no extra use sea_orm::entity::prelude::*;
line.
The five-line CRUD
The smallest meaningful Ferra application — one resource, full CRUD,
auto-generated OpenAPI spec, Scalar docs UI, and HAL hypermedia
responses — fits in this src/main.rs:
use ferra::prelude::*;
mod film {
use ferra::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq,
DeriveEntityModel, FerraModel,
Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub year: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub use film::Model as Film;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let conn = ferra::sea_orm::Database::connect(&database_url).await?;
let app = Foundry::new(conn).mount::<Film>().with_docs().build();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
What you get on cargo run:
| URL | Behaviour |
|---|---|
GET /films | Paginated list of films with HAL _links. |
GET /films/{id} | Single film + self-link. |
POST /films | Create — validates, returns 201 Created. |
PUT /films/{id} | Update. |
DELETE /films/{id} | Delete. |
GET /docs/openapi.json | Generated OpenAPI 3.1 spec — schema names Film, CreateFilmInput, UpdateFilmInput, FilmCollection, ProblemDetails. |
GET /docs | Interactive Scalar UI rendered from the spec. |
Errors come back as RFC 7807 application/problem+json with typed
type URIs from a closed namespace (https://ferra.rs/errors/<variant>).
The example in
examples/hello-ferra/in the framework repository matches this layout one-to-one and is the canonical reference. If you’re reading this through docs.rs, the same example is published under that name on the Ferra repository.
Run it
You need PostgreSQL reachable at DATABASE_URL. The fastest local
setup uses Podman or Docker:
podman run --rm -d --name pg \
-e POSTGRES_USER=ferra -e POSTGRES_PASSWORD=ferra \
-e POSTGRES_DB=my_api \
-p 5432:5432 \
postgres:16
export DATABASE_URL=postgres://ferra:ferra@localhost:5432/my_api
Apply your schema migrations (Sea-ORM CLI) and start the server:
cargo run
You should see listening on 0.0.0.0:3000 — open http://localhost:3000/docs
in a browser.
MSRV and toolchain
Ferra’s MSRV is Rust 1.88 (edition 2024, resolver v3). let
chains, native async fn in traits, and the rest of the modern Rust
feature surface this guide assumes are stable on 1.88+.
You don’t need a pinned development toolchain in your own project — any stable Rust at or above 1.88 works. The framework itself pins a specific toolchain in its repository for reproducible CI; that pin does not propagate to consumers.
Next steps
- Foundry — the router-assembly facade. Multi-resource
chains, API versioning, the
with_docs_protected(...)auth-gated docs variant. - Ferra Forge — the
#[derive(FerraModel)]derive grammar; the full attribute set on model structs and fields. - Ferra OpenAPI — what the generated spec contains, schema-name derivation, and how SDK generators consume it.
- Custom Handlers — drop down to your own Axum handler when Ferra’s defaults don’t fit a specific endpoint.
- Error Handling — the closed
https://ferra.rs/errors/<variant>namespace, theProblemDetailsshape, and consumer-side branching.
Ferra and the Rust ecosystem
Ferra is a thin layer over a small set of upstream Rust crates that already solve specific concerns well. This page explains what is Ferra-defined and what is re-exported, so that you know where to look for documentation and which surface to import when you build an application.
If you are looking for “five-minute zero-to-API,” see
Getting started. This page is about the long-term
shape of the dependency graph you inherit when you depend on ferra.
The principle: cohabitation, not encapsulation
Ferra does not wrap the upstream crates it integrates. Instead, it does one of two things:
- Direct re-export — when an upstream crate already offers a stable,
namespaced surface, Ferra simply re-exports it under
ferra::*. You importferra::sea_orm::DatabaseConnection,ferra::biscuit_quote::check, and you call them with the API the upstream crate documents. There is no Ferra-renamed equivalent. - Default-locking + opt-in feature — when an upstream crate is correct for some applications but not all, Ferra locks the upstream-default configuration explicitly behind a Cargo feature, so the dependency weight only lands when you opt in.
Ferra introduces a wrapper or a Ferra-defined trait only when one of three things is true:
- There is concrete behaviour to inject at the call site (logging, validation, span propagation).
- A typed Ferra newtype prevents misuse that the upstream type does not
catch (
Email,UserId). - A Ferra trait abstracts over multiple backends with build-time or
runtime swap-out (
FerraAuthProvider,RateLimitStore).
If none of those apply, the upstream surface is exposed verbatim and the upstream documentation is the canonical reference.
What lives under ferra::*
Ferra-defined surface
The ferra:: namespace defines a small, stable user-facing API:
| Namespace | What it is |
|---|---|
ferra::Foundry | The router builder — Foundry::new(conn).mount::<M>().build(). |
ferra::Id | The framework’s identifier type used by #[id] fields. |
ferra::Router | The composed Axum router returned by Foundry::build(). |
ferra::FerraError | The framework-wide error enum surfaced in HTTP responses. |
ferra::FerraLayer | The Tower layer composition entry point. |
#[derive(FerraModel)] | The proc-macro that turns a struct into a Ferra resource. |
#[ferra(...)] | The attribute namespace for Ferra-specific concerns. |
#[id], #[field(...)] | Field-level attributes for primary keys and projection rules. |
#[authorize(...)] | Operation-level authorization rules. |
The Ingot, ferra-forge, ferra-anvil thematic names belong to internal
documentation. You will not see them in your application code.
Re-exported upstream surface
The rest of ferra::* is re-exports. The biggest namespaces:
| Namespace | Re-exports | Documentation |
|---|---|---|
ferra::sea_orm | All of sea_orm | sea_orm docs.rs |
ferra::biscuit_quote | All of biscuit-quote | biscuit-quote docs.rs |
ferra::tracing | All of tracing | tracing docs.rs |
When you write:
#![allow(unused)]
fn main() {
use ferra::sea_orm::Database;
let conn = Database::connect("postgres://...").await?;
}
…you are calling sea_orm::Database::connect. The upstream documentation
applies verbatim. There is no Ferra-side wrapping.
Crate graph: modules pub(crate), items glob-exported at root
Every framework implementation crate (ferra-core, ferra-db,
ferra-forge, ferra-http, ferra-openapi) keeps its modules
pub(crate) and re-exports its public items at its crate root. The
facade then writes pub use ferra_<crate>::*; once per implementation
crate. A consequence is that two implementation crates can share a
module name (emit, routes, schema, …) without collisions: only
the items at each crate’s root reach the facade, and the items
themselves are uniquely named across the workspace.
This rule is enforced mechanically. The cargo clippy --workspace -- -D ambiguous_glob_reexports lint fires the moment two glob
re-exports in the facade name the same item from different sources;
CI gates the lint as a release-blocker (FR-024). A future regression
that exposes a sub-module from any implementation crate as pub
would be caught the next time the facade picks up a sibling-crate
item with the same name.
If you are reading the source code: prefer the absence of pub mod
on every implementation crate’s lib.rs. The single exception is
ferra::prelude on the facade itself — the prelude is intended to be
opened directly by consumers, so its module visibility is pub (FR-025).
Cargo feature surface
Ferra’s default dependency graph is intentionally lean. Heavy or audience-partial features are opt-in.
| Feature flag | What it enables | When you want it |
|---|---|---|
| (default) | Core framework + tracing + structured stderr JSON logging | Always. |
otel | OpenTelemetry SDK + OTLP/HTTP exporter (locked to upstream-default transport) | You run an OTel collector and want OTLP exports. |
otel-grpc | otel + gRPC transport (instead of HTTP/protobuf) | Your collector requires gRPC and your ingress preserves HTTP/2. |
otel-fips | otel + runtime-installed FIPS-compliant rustls CryptoProvider | FIPS-compliant deployments. |
Activating a feature
[dependencies]
ferra = { package = "ferra-rs", version = "0.x", features = ["otel"] }
The
package = "ferra-rs"alias is required because theferraname on crates.io is owned by an unrelated project; the framework’s facade is published asferra-rs. See Getting Started for full details.
Adding the feature pulls the locked OTel SDK pin into your build; without the feature, none of those crates compile. There is no runtime check — the choice is at compile time.
Where to look for documentation
| Question | Source |
|---|---|
| How do I declare a model? | This user guide → ferra-forge.md |
What does Foundry::new(...) do? | This user guide → ferra-http.md |
| How do I run a database query? | Upstream — sea_orm docs.rs (Ferra re-exports verbatim) |
What are valid Datalog rules in #[authorize]? | Upstream — biscuit documentation + the rule grammar reference |
| How do I configure tracing subscribers? | Upstream — tracing-subscriber docs.rs (Ferra re-exports verbatim) |
When you ask an AI coding assistant about a Ferra application, the assistant should reach for upstream documentation for re-exported surfaces and for this user guide for Ferra-defined surface. The distinction matters because upstream training corpora are large and Ferra-specific corpora are small — the cohabitation pattern is what makes a Ferra application legible to AI agents trained on the broader Rust ecosystem.
A worked example
A complete Ferra application demonstrating both halves of the principle:
// Cargo.toml:
// ferra = { package = "ferra-rs", version = "0.x", features = ["otel"] }
// Direct re-export (Half A): upstream `sea_orm` reached without renaming.
use ferra::sea_orm::Database;
// Direct re-export (Half A): upstream `biscuit-quote::check` for compile-time
// Datalog. A typo here is a `cargo build` error, not a runtime panic.
use ferra::biscuit_quote::check;
// Ferra-defined surface: the model derive, the router builder.
use ferra::{Foundry, FerraModel, Id};
#[derive(FerraModel)]
struct Film {
#[id]
id: Id,
title: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Upstream API (sea_orm): no Ferra-side wrapper.
let conn = Database::connect("postgres://localhost/films").await?;
// Ferra-defined: the router composition surface.
let app = Foundry::new(conn).mount::<Film>().build();
// Upstream API (axum / hyper): Ferra returns a standard Axum Router.
axum::serve(tokio::net::TcpListener::bind("0.0.0.0:3000").await?, app).await?;
Ok(())
}
The same application without the otel feature compiles to a smaller
binary; the OTel SDK is not in the dependency graph at all.
When you need a Ferra wrapper that does not yet exist
If you find yourself wanting a Ferra-side wrapper around an upstream type — a Ferra newtype, a Ferra trait abstracting multiple backends, or a Ferra macro that does something the upstream macro does not — the wrapper is a feature request worth filing. The maintainers will ask which of the three justifications applies (concrete behaviour to inject, type-narrowing, swap-out insurance) and ship the wrapper at that point. Until then, the upstream surface is the documented path.
Foundry
Foundry is Ferra’s router-assembly facade. One chain — beginning
with a Sea-ORM DatabaseConnection and ending with .build() —
declares every resource on your API, mounts the OpenAPI 3.1 docs
surface, applies version prefixes, and produces the final
axum::Router.
use ferra::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let conn = sea_orm::Database::connect(&std::env::var("DATABASE_URL")?).await?;
let app = Foundry::new(conn)
.mount::<Film>()
.mount::<Actor>()
.with_docs()
.build();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
The chain replaces the per-resource
conn.clone() + FerraState::new + ferra_router::<M> + Router::merge
boilerplate. Two compile-time invariants are enforced by the type
system: no duplicate .mount::<M>() calls, and no mixing of
un-versioned with versioned mounts on a single chain.
Foundry::build() -> axum::Router is the sole method whose return
type names an axum::* type. Every intermediate value is a
Foundry-defined builder. Consumer-added .layer() calls on the
returned router land outside the framework’s Tower stack.
The chain at a glance
| Method | Default | Override |
|---|---|---|
Foundry::new(conn) | begins the chain | — |
.mount::<M>() | mounts the model’s CRUD on /{resource} | .mount_with::<M>(state) (custom FerraState) |
.with_docs() | publishes /docs/openapi.json + /docs (Scalar UI) | .with_docs_at(path) |
.with_docs_protected(verifier) | gates docs behind the verifier (RFC 7807 401 on rejection) | .with_docs_protected_at(path, verifier) |
.api_version("v1")? | transitions to a versioned chain (paths under /v1/) | — |
.deprecated(date) | adds Sunset: header + deprecated: true + x-sunset to the spec | — |
.build() | finalizes the chain into axum::Router | — |
api_version returns Result<VersionedFoundryBuilder, ApiVersionError>
— prefixes that are empty, contain /, contain whitespace, or
contain characters unsafe in a URL path segment are rejected at
construction time.
The no-duplicate-mount rule
Mounting the same model twice is a compile error. The
typestate-tracked Set: NotMounted<M> bound forecloses route
shadowing at the type level (FR-012a, ADR-0025). The
#[diagnostic::on_unimplemented] message names the duplicated type:
error[E0277]: the model `Film` is already mounted on this Foundry chain
--> src/main.rs:5:33
|
5 | .mount::<Film>().mount::<Film>().build();
| ^^^^^^^^^^^^^ duplicate `.mount::<Film>()` —
| remove this call or rename the model
|
= note: see https://ferra.rs/guide/foundry#duplicate-mount for the supported pattern
If you genuinely need two different shapes for the same underlying type, define two distinct Rust types — Ferra’s metadata travels with the type, not with the resource name.
The no-mixed-mode rule
.api_version(...) is callable only on an empty chain. Mounting an
un-versioned model first and then calling .api_version("v1") is a
compile error (FR-014a, ADR-0025):
error[E0277]: `.api_version(...)` cannot be called after un-versioned `.mount(...)` calls
--> src/main.rs:6:14
|
6 | .api_version("v1")?
| ^^^^^^^^^^^^^^^^ this builder already has un-versioned mounts
|
= note: to ship mixed-mode (legacy + versioned) routes, build two
assemblies and merge them via `axum::Router::merge`. See
https://ferra.rs/guide/foundry#mixed-mode for a worked example.
Mixed-mode pattern: two assemblies merged
If you want a top-level /films and a /v1/films, build two
Foundry chains and merge them with axum::Router::merge:
#![allow(unused)]
fn main() {
use ferra::*;
let conn = /* … */;
let legacy = Foundry::new(conn.clone())
.mount::<Film>()
.with_docs() // serves /docs/openapi.json
.build();
let v1 = Foundry::new(conn)
.api_version("v1")?
.mount::<Film>()
.deprecated(date!(2027-01-05))
.with_docs() // serves /docs/v1/openapi.json
.build();
let app: axum::Router = legacy.merge(v1);
}
Each Foundry::build() produces its own axum::Router; merging
them is the supported way to expose multiple-version surfaces from
a single binary. The OpenAPI documents stay separate — one per
build.
Versioning + deprecation
.api_version("v1")? nests every mounted resource under /v1/,
prefixes every operationId with v1., and serves the spec at
/docs/v1/openapi.json.
.deprecated(date) activates two parallel signals:
- a
Sunset: YYYY-MM-DDHTTP header on every response under the version (RFC 8594); deprecated: true+x-sunset: YYYY-MM-DDon every operation in the emitted spec, plus aninfo.x-sunsetmirror at the document root (ADR-0027).
#![allow(unused)]
fn main() {
use ferra::*;
let v1 = Foundry::new(conn)
.api_version("v1")?
.mount::<Film>()
.deprecated(date!(2027-01-05))
.with_docs()
.build();
}
The date! macro validates the calendar date at compile time; an
invalid month or day fails cargo build rather than panicking at
startup. See ferra-core.md §“Time vocabulary”.
Public-default docs and the protected variant
.with_docs() exposes the documentation surface unauthenticated by
default. This is a deliberate, documented departure from Ferra’s
model-route default-deny posture (Q1 in the 0.5.0 specification,
arbitrated in ADR-0024). The reasoning: the docs surface describes
the API but does not serve resource data; gating it would block the
constitutional zero-to-documented-API standard for every new
consumer.
For any publicly-reachable network surface, switch to
.with_docs_protected(verifier). The verifier closure receives the
inbound request and returns bool; a false produces an RFC 7807
401 Unauthorized response with
type: "https://ferra.rs/errors/unauthorized":
#![allow(unused)]
fn main() {
use ferra::*;
use axum::http::header::AUTHORIZATION;
let app = Foundry::new(conn)
.mount::<Film>()
.with_docs_protected(|req: &axum::extract::Request| {
req.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
== Some("Bearer secret")
})
.build();
}
The verifier runs synchronously and MUST NOT perform I/O — the
underlying primitive does not yet integrate with the
ferra-auth provider chain (that landing is scheduled for 0.8.5
beta). The current API accepts any Fn(&Request) -> bool + Send + Sync + 'static; the future Provider arm of DocsAuthVerifier
will be additive.
Layer-ordering invariance
Every resource mounted through Foundry carries the per-resource
Tower stack ferra_router::<M> builds: CORS (restrictive default),
the 413-mapping middleware, the 1 MiB body limit, tracing with body
sampling off, and per-route rate limiting on mutation endpoints.
Foundry does not reorder, replace, or remove any of these layers.
Consumer-added .layer(...) calls on the value Foundry::build()
returns land outside the framework’s stack — the standard Tower
composition rule. If you need a layer to wrap framework layers, add
it on the returned router; if you need it to sit beneath the
framework layers, fork to mount_with::<M>(state) and pre-compose
on the state’s ferra_router::<M> output.
Cross-reference: ferra-http.md §“Tower layer-ordering notation”
documents the exact stack order.
Escape hatches
mount_with::<M>(state)— supply a caller-builtFerraState<M>rather than letting Foundry derive one from the connection. Useful for shared state across builders, instrumentation, or test fixtures where the repository needs to be substituted.with_docs_at(path)/with_docs_protected_at(path, verifier)— override the/docsmount point. Useful when a reverse proxy serves Ferra under a sub-path.- Direct
ferra-openapiuse — for advanced docs UIs (Redoc, RapiDoc, Swagger UI), callferra_openapi::build_openapi_for_models(...)directly and merge the resultingaxum::Routerinto the value Foundry returns. Seeferra-openapi.md§“Alternative UIs”.
When Foundry is the wrong tool
- Apps where every resource needs a hand-tuned middleware stack
(e.g. per-resource auth schemes, per-resource body-limit overrides).
Use
ferra_router::<M>(state)per resource and compose by hand. - Apps that mount the docs surface from a different process than
the API itself. The
ferra-openapifree functions stand alone. - Apps with a non-Sea-ORM persistence layer. Foundry takes a
sea_orm::DatabaseConnection; alternative backends compose throughmount_with::<M>(state)using aFerraState<M>bound to the alternative.
For the bare CRUD case — the framework’s zero-to-documented-API
target — Foundry::new(conn).mount::<M>().with_docs().build() is
the right shape.
Cross-references (for the curious reader)
- ADR-0024 —
ferra-openapicrate scope, public-default docs deviation,Foundry → ferra-openapiedge. - ADR-0025 —
Foundrytypestate strategy (set-membership-via-trait +#[diagnostic::on_unimplemented]). - ADR-0026 — closed
ERROR_TYPESURI namespace; thehttps://ferra.rs/errors/unauthorizedURI used by.with_docs_protected(...). - ADR-0027 —
x-sunsetvendor extension +deprecated: truemirror. - ADR-0002 — Axum 0.8 framework choice and the
Foundry::build() -> axum::Routersingle-leak rule.
These are pointers for readers tracing decisions back to their arbitration; they are not required reading for correct use of the APIs above.
ferra-core
ferra-core is the zero-I/O contract layer that every other Ferra
crate depends on. It exports the following items across five modules:
| Module | Items |
|---|---|
meta | FieldType, FieldMeta, ModelMeta |
model | FerraModel |
id | Id |
error | IdParseError |
time | DateTime, Date, plus the date! macro |
Nothing in this crate allocates, performs I/O, or talks to a
database. Its external runtime dependencies are serde, uuid, and
jiff (activated in 0.5.0 Rolling for the typed time vocabulary).
A fourth dep (utoipa) is feature-gated behind openapi and stays
off in the default build — ferra-openapi flips the feature on
transitively when it depends on this crate.
Model metadata (FieldMeta / FieldType / ModelMeta)
The three types in ferra_core::meta describe a resource precisely
enough for downstream crates to derive HTTP routes, SQL, OpenAPI, and
hypermedia links from a single source of truth.
FieldType
A tag identifying the Rust type of a field.
#![allow(unused)]
fn main() {
pub enum FieldType {
String, I32, I64, F64, Bool, Uuid,
OptionString, OptionI32, OptionI64, OptionF64, OptionBool,
}
}
Option variants are spelled out so every field’s nullability is
visible in the IR without indirection. OptionUuid is deliberately
absent in 0.1.0 — it is deferred to a later phase.
FieldType is #[non_exhaustive]. Pattern matches on it MUST
include a _ => arm to stay forward-compatible:
#![allow(unused)]
fn main() {
use ferra_core::meta::FieldType;
fn is_optional(ty: FieldType) -> bool {
matches!(
ty,
FieldType::OptionString
| FieldType::OptionI32
| FieldType::OptionI64
| FieldType::OptionF64
| FieldType::OptionBool,
)
}
}
FieldMeta
Description of a single field on a model. Seven named pub fields —
no boolean-collapse tuples — so every flag is individually grep-able
and readable in IDE hover output.
#![allow(unused)]
fn main() {
pub struct FieldMeta {
pub name: &'static str,
pub ty: FieldType,
pub required: bool,
pub readable: bool,
pub writable: bool,
pub skip_api: bool,
pub is_id: bool,
}
}
FieldMeta is #[non_exhaustive]. Construction goes through the
const fn new constructor:
#![allow(unused)]
fn main() {
use ferra_core::meta::{FieldMeta, FieldType};
const TITLE_FIELD: FieldMeta = FieldMeta::new(
"title",
FieldType::String,
/*required*/ true,
/*readable*/ true,
/*writable*/ true,
/*skip_api*/ false,
/*is_id*/ false,
);
}
ModelMeta
Description of a single resource:
#![allow(unused)]
fn main() {
pub struct ModelMeta {
pub resource_name: &'static str, // external name, e.g. "films"
pub table_name: &'static str, // SQL table name
pub id_field: &'static str, // must match one field's name
pub fields: &'static [FieldMeta],
}
}
Invariants (hand-authoring discipline in 0.1.0; enforced by
compile_error! from 0.2.0 Smelting onward):
fieldsis non-empty.- Exactly one entry in
fieldshasis_id = trueand itsnamematchesid_field. - All
fields[i].namevalues are distinct.
Hand-authoring in 0.1.0
In 0.1.0 every ModelMeta is hand-written. Starting in 0.2.0, the
#[model] proc-macro (shipping in ferra-forge) emits the same
types automatically.
#![allow(unused)]
fn main() {
use ferra_core::meta::{FieldMeta, FieldType, ModelMeta};
const FILM_FIELDS: &[FieldMeta] = &[
FieldMeta::new("id", FieldType::Uuid, true, true, false, false, true),
FieldMeta::new("title", FieldType::String, true, true, true, false, false),
];
const FILM_META: ModelMeta =
ModelMeta::new("films", "films", "id", FILM_FIELDS);
}
The FerraModel trait
FerraModel binds a Rust struct to its static ModelMeta
description. It is the single piece of user code required to make a
Rust struct a Ferra resource.
#![allow(unused)]
fn main() {
pub trait FerraModel:
serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static
{
fn meta() -> &'static ModelMeta;
}
}
The supertrait set is part of the 0.1.0 contract. Every bound is load-bearing:
| Bound | Why it’s required |
|---|---|
Serialize | Later phases render responses. |
DeserializeOwned | Later phases deserialise request bodies (no borrowed 'de). |
Send + Sync | Models participate in async handlers on a multi-threaded Tokio runtime. |
'static | Ferra models are owned values — never borrowed from request state. |
A hand-written implementation is four lines:
#![allow(unused)]
fn main() {
use ferra_core::{id::Id, meta::{FieldMeta, FieldType, ModelMeta}, model::FerraModel};
use serde::{Deserialize, Serialize};
const FILM_FIELDS: &[FieldMeta] = &[
FieldMeta::new("id", FieldType::Uuid, true, true, false, false, true),
FieldMeta::new("title", FieldType::String, true, true, true, false, false),
];
const FILM_META: ModelMeta = ModelMeta::new("films", "films", "id", FILM_FIELDS);
#[derive(Serialize, Deserialize)]
struct Film { id: Id, title: String }
impl FerraModel for Film {
fn meta() -> &'static ModelMeta { &FILM_META }
}
}
In 0.1.0 this trait has no downstream consumer yet — ferra-http
(0.3.0 Casting) is its first real reader. The trait ships early
because narrowing supertraits later would be a breaking change.
Id — the identifier primitive
Id is the identifier type every Ferra model uses. It is a 16-byte
newtype wrapping uuid::Uuid.
Construction
#![allow(unused)]
fn main() {
use ferra_core::id::Id;
let a = Id::new(); // fresh v4 UUID (collision-resistant)
let b = Id::default(); // same as Id::new()
let n = Id::nil(); // all-zero sentinel — TEST FIXTURE ONLY
}
Id::nil() is explicitly a test fixture: it is distinguishable from
any Id::new() output, which makes it useful as a sentinel in unit
tests. Do not store or return Id::nil() from production code.
Wire format
Display emits the RFC 4122 hyphenated lowercase form (for example,
"67e55044-10b1-426f-9247-bb680e5fe0c8"). FromStr accepts any of
the four canonical UUID forms: hyphenated, simple (no hyphens), URN
(urn:uuid:...), and braced ({...}). The serde impls delegate to
Display / FromStr, so the wire representation is consistent
across JSON, MessagePack, and every serde-backed format.
Full round-trip example
This example mirrors the doctest on Id in
crates/ferra-core/src/id.rs, which is what cargo test actually
runs; the markdown fence below is a readable transcription, not the
compiled source. The doctest is the ground-truth contract for how
Id behaves.
#![allow(unused)]
fn main() {
use std::str::FromStr;
use ferra_core::id::Id;
// Construction.
let id = Id::new();
// Text round-trip.
let text = id.to_string();
let parsed = Id::from_str(&text).expect("fresh ids always parse");
assert_eq!(id, parsed);
// JSON round-trip.
let json = serde_json::to_string(&id).unwrap();
let back: Id = serde_json::from_str(&json).unwrap();
assert_eq!(id, back);
// Equality and the nil sentinel.
let nil = Id::nil();
assert_eq!(nil, Id::nil());
assert_ne!(nil, id);
// Parsing failure is typed, never a panic.
let err = Id::from_str("not a uuid").unwrap_err();
assert_eq!(err.to_string(), "failed to parse Id as a UUID");
// Id is Copy — passing by value is free.
fn takes_id(_x: Id) {}
takes_id(id);
takes_id(id); // still valid, the previous call didn't move it.
}
Why a newtype
Wrapping uuid::Uuid rather than exposing it directly lets Ferra
swap the inner representation later (for example, to UUID v7
time-ordered identifiers) without churning every caller. The 16-byte
payload is Copy; passing Id through the HTTP pipeline costs
nothing at runtime.
Id is not PartialOrd / Ord — v4 UUIDs carry no meaningful
ordering semantics, and adding those traits later is non-breaking.
IdParseError
<Id as FromStr>::Err is IdParseError, a hand-written unit struct
with a Display impl and a std::error::Error impl. Its inner is
pub(crate), so you can only obtain an IdParseError by a parse
failure — never by direct construction.
In later phases, IdParseError may grow variants distinguishing
“wrong length” from “invalid character”. Because external code only
ever matches it as an opaque error, adding variants will be a
non-breaking change.
Time vocabulary (DateTime / Date / date!)
Ferra exposes one typed time vocabulary across the framework. Two
#[repr(transparent)] newtypes — ferra::DateTime (timestamp) and
ferra::Date (calendar date) — wrap the underlying time library so
the framework can swap that library in a future release without a
consumer-facing signature change. Reach the types through
ferra::DateTime / ferra::Date (or via ferra::prelude::*); never
import the underlying time library directly.
DateTime — timestamp
DateTime represents a precise instant anchored to UTC. Wire format:
RFC 3339 timestamp text ("2023-11-14T22:13:20Z").
| Constructor | Returns | Notes |
|---|---|---|
DateTime::from_second(seconds: i64) | Result<DateTime, jiff::Error> | seconds since the Unix epoch |
DateTime::from_millisecond(milliseconds: i64) | Result<DateTime, jiff::Error> | milliseconds since the Unix epoch |
DateTime::from_microsecond(microseconds: i64) | Result<DateTime, jiff::Error> | microseconds since the Unix epoch |
DateTime::from_nanosecond(nanoseconds: i128) | Result<DateTime, jiff::Error> | nanoseconds since the Unix epoch |
DateTime::from_duration(duration: jiff::SignedDuration) | Result<DateTime, jiff::Error> | epoch-relative duration |
DateTime::now() | DateTime | reads the system clock; not const |
The constructor names mirror the underlying time library verbatim;
they are not invented. Names that do not exist on DateTime and
will not be added: from_unix_timestamp, from_unix_nanos.
Each from_* constructor returns a Result and propagates an
out-of-range argument as the underlying time library’s error type.
The methods are not const fn — call sites use the standard ?
operator, not const-context evaluation.
#![allow(unused)]
fn main() {
use ferra::DateTime;
fn epoch_seconds(seconds: i64) -> Result<DateTime, jiff::Error> {
DateTime::from_second(seconds)
}
let now = DateTime::now();
let later = DateTime::from_second(1_700_000_000)?;
}
Date — calendar date
Date represents a time-zone-free calendar date. Wire format: RFC
3339 calendar date text ("YYYY-MM-DD").
| Constructor | Returns | Notes |
|---|---|---|
Date::new(year: i16, month: i8, day: i8) | Date | const fn; out-of-range triple is a const-eval error |
Date::new is const fn — an out-of-range (year, month, day)
triple is rejected at cargo build time, not at runtime:
#![allow(unused)]
fn main() {
use ferra::Date;
const RELEASED: Date = Date::new(2027, 1, 1);
// const INVALID: Date = Date::new(2027, 13, 1); // would not compile
}
date! macro — hyphen-literal calendar dates
ferra::date!(YYYY-MM-DD) re-tokenises a hyphen-literal into
Date::new(year, month, day). Same compile-time validation as
Date::new:
#![allow(unused)]
fn main() {
use ferra::{Date, date};
const SUNSET: Date = date!(2027-01-01);
// const INVALID: Date = date!(2027-13-01); // const-eval error
}
The macro is the recommended call site for fixed dates (release
sunsets, scheduled migrations) because the literal form is most
readable. Use Date::new(...) directly when the inputs are computed
or come from configuration.
Use as model fields
Both newtypes are accepted as field types on #[derive(FerraModel)]
structs and emit the right OpenAPI schema (format: date-time and
format: date):
#![allow(unused)]
fn main() {
use ferra::{Date, DateTime, FerraModel};
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
released: Date,
indexed_at: DateTime,
}
}
Use as Sunset: header values
Foundry::deprecated(sunset) accepts a Date. The framework
serialises the value as the Sunset: HTTP header on every response
under the deprecated version (RFC 8594):
#![allow(unused)]
fn main() {
use ferra::{Foundry, date};
let app = Foundry::new(conn)
.api_version("v1")?
.mount::<Film>()
.deprecated(date!(2027-01-01))
.build();
}
Banned imports
The framework forbids direct use of legacy time libraries. Two gates enforce this:
#[derive(FerraModel)]rejects field declarations whose type path starts withchrono::ortime::. The diagnostic namesferra::DateTime/ferra::Dateas the replacement and is spanned to the offending field.cargo deny checkbans thechronoandtimecrates from the dependency graph except as transitive deps of the Sea-ORM ecosystem (sea-orm,sea-query,sqlx-core,sqlx-postgres). A Ferra crate or downstream consumer that adds either crate directly fails CI.
Sample diagnostic for a chrono-typed field:
error: field type uses the legacy `chrono` time crate; Ferra exposes
typed time values through `ferra::DateTime` for timestamps
and `ferra::Date` for calendar dates
help: replace with `ferra::DateTime` (or `ferra::Date` for
calendar-only values); the wire format is RFC 3339 in
both cases
note: docs/user-guide/ferra-core.md §"Time vocabulary"
Why newtypes
The two newtypes are #[repr(transparent)] over the underlying
library’s value types. The layout is identical to the inner type, so
a future release that replaces the underlying library does so at the
ferra-core::time module boundary alone — consumer-facing signatures
stay stable. The inner field is pub(crate); consumers reach the
value only through ferra::DateTime / ferra::Date.
The OpenAPI schema emission is encapsulated in the same module:
<DateTime as utoipa::ToSchema> and <Date as utoipa::ToSchema>
emit {type: "string", format: "date-time"} and
{type: "string", format: "date"} respectively. Consumers and
downstream crates never write the schema literal — the typed-newtype
contract preserves the framework’s “single source of truth” rule.
ferra-db — the repository layer
The 0.3.0 Casting deliverable. Turns a #[derive(FerraModel, DeriveEntityModel)] entity into a CRUD-capable repository against a live PostgreSQL, with zero hand-written SQL.
This page is the authoritative user-guide for ferra-db. A reader with only this page plus standard Rust knowledge can produce a compiling CRUD round-trip on the first attempt — no ADR, no constitution, and no framework source is required.
At 0.4.0 Refining the
ferrafacade absorbsferra-forge(the#[derive(FerraModel)]macro) andferra-http(the CRUD handler layer). Consumers writeuse ferra::*;and no longer need a second direct dependency onferra-forge— the 003 two-line pattern collapses to one line. Seeferra-http.mdfor the HTTP surface that composes on top of this page’s repository layer.
Zero-to-CRUD in four lines
#![allow(unused)]
fn main() {
use ferra::{DatabaseConnection, FerraRepository, PgRepository};
let conn: DatabaseConnection = sea_orm::Database::connect(&database_url).await?;
let repo = PgRepository::<Film>::new(conn);
let film = repo.find_by_id(42).await?;
}
(Film is the canonical sibling-derive entity shape from ferra-forge — see the worked example below. database_url is read from the DATABASE_URL environment variable in the usual way.)
The FerraRepository<M> surface
FerraRepository<M> is an async trait with ten methods, split into two symmetric groups of five. The ambient-connection quintet runs against the pool supplied at construction time; the _in_tx quintet takes a caller-owned transaction handle.
Ambient-connection methods
| Method | Signature summary | Error outcomes |
|---|---|---|
find_by_id | async fn find_by_id(&self, id: PkValue<M>) -> Result<M, FerraDbError> | NotFound · Db |
find_page | async fn find_page(&self, page: u32, per_page: u32) -> Result<Page<M>, FerraDbError> | Db (never NotFound) |
insert | async fn insert(&self, data: M) -> Result<M, FerraDbError> | Conflict · Db |
update | async fn update(&self, id: PkValue<M>, data: M) -> Result<M, FerraDbError> | NotFound · Conflict · Db |
delete | async fn delete(&self, id: PkValue<M>) -> Result<(), FerraDbError> | NotFound · Db |
PkValue<M>is the Sea-ORM primary-key value type forM— for most entities this is a plaini32/i64/Uuid. The exact type alias ispub type PkValue<M> = <<<M as sea_orm::ModelTrait>::Entity as sea_orm::EntityTrait>::PrimaryKey as sea_orm::PrimaryKeyTrait>::ValueType;— consumers rarely spell it out; it propagates throughimplblocks automatically.find_pageis 1-indexed (page = 1is the first page). A request past the end returnsOk(Page { items: vec![], total, .. })— neverNotFound.updateaccepts(id, data)wheredata: Mcarries the new values. At 0.3.0 the caller is responsible for ensuringdata’s primary key matchesid; the 0.4.0 DTO layer will enforce that invariant upstream.
Transaction-scoped methods
Each ambient method has a paired _in_tx variant with the same name + suffix, taking &DatabaseTransaction as its first argument after &self:
#![allow(unused)]
fn main() {
async fn find_by_id_in_tx(&self, tx: &DatabaseTransaction, id: PkValue<M>) -> Result<M, FerraDbError>;
async fn find_page_in_tx (&self, tx: &DatabaseTransaction, page: u32, per_page: u32) -> Result<Page<M>, FerraDbError>;
async fn insert_in_tx (&self, tx: &DatabaseTransaction, data: M) -> Result<M, FerraDbError>;
async fn update_in_tx (&self, tx: &DatabaseTransaction, id: PkValue<M>, data: M) -> Result<M, FerraDbError>;
async fn delete_in_tx (&self, tx: &DatabaseTransaction, id: PkValue<M>) -> Result<(), FerraDbError>;
}
Semantics are identical to the ambient counterpart modulo the transaction handle. A bug fixed in one path is fixed in both — the two families share their inner implementation via a ConnectionTrait-generic helper.
Object-safety note
FerraRepository<M> is NOT object-safe — edition-2024 native async-in-trait does not support dyn FerraRepository<M>. Compose against it via generics: fn handler<R: FerraRepository<Film>>(repo: R, ...). A runtime-heterogeneous-backend story is deferred to the post-v1 ferra-db-* sibling crates.
The PgRepository<M> constructor
#![allow(unused)]
fn main() {
let repo: PgRepository<Film> = PgRepository::new(conn);
}
connis asea_orm::DatabaseConnection(re-exported fromferra-dbso you do not need to addsea-ormas a direct dependency —ferrare-exports the name underferra::DatabaseConnection).- The struct derives
Cloneunconditionally. Sea-ORM’sDatabaseConnectionwraps its pool in anArc, sorepo.clone()is cheap — passPgRepository<M>by value or by clone rather than wrapping inArc<PgRepository<M>>. - The constructor performs no validation. Callers with a non-PostgreSQL
DatabaseConnectioncompile but lose the SQLSTATE23505→Conflictmapping (those failures fall through to theDb(_)variant).
Bounds on M
PgRepository<M> works for any M that satisfies the 0.2.0 sibling-derive shape — i.e., the derive combination #[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]. Concretely, M must implement FerraModel (from ferra-core) plus the Sea-ORM trait set (ModelTrait, FromQueryResult, IntoActiveModel<ActiveModel>, with Entity: EntityTrait<Model = M> and the ActiveModel: ActiveModelTrait + ActiveModelBehavior + Send). The 0.2.0 canonical shape satisfies every bound automatically.
If you see error[E0277]: the trait bound <M>: sea_orm::ModelTrait is not satisfied at a PgRepository::<M>::new(conn) call site, you forgot the DeriveEntityModel derive on the same struct. Add it per the 0.2.0 canonical shape.
Error taxonomy
FerraDbError has exactly three variants. The set is locked from 0.3.0 through 0.6.0 Welding — adding a variant requires an ADR.
| Variant | When | Display template | 0.4.0 HTTP mapping |
|---|---|---|---|
NotFound { resource: &'static str, id: String } | find_by_id / update / delete observed zero matching rows | "{resource}/{id} not found" | 404 Not Found |
Conflict(String) | insert / update hit a Postgres SQLSTATE 23505 unique-constraint violation | "unique constraint \"{constraint}\" violated on {target}" | 409 Conflict |
Db(sea_orm::DbErr) | Every other sea_orm::DbErr | whatever the wrapped DbErr renders as | 500 Internal Server Error (no internal fragment leaks into the response body) |
Display guarantees
NotFound { resource: "films", id: "42".into() }.to_string()→"films/42 not found".Conflict("unique constraint \"films_title_key\" violated on column \"title\"".into()).to_string()→"unique constraint \"films_title_key\" violated on column \"title\"".Db(db_err).to_string()→ whateverdb_err.to_string()renders (e.g.,"Record not found","Execution Error: ...").
Every variant’s Display is stable through 0.6.0 Welding — the 0.4.0 RFC 7807 layer serializes these templates verbatim into the problem+json response. No Display output contains any internal Rust path fragment (no sea_orm::..., no sqlx::..., no rustc error codes).
source() + downcasting
FerraDbError::Db(_).source() returns Some(&dyn Error) pointing at the wrapped sea_orm::DbErr. You can downcast to inspect specific Sea-ORM failure modes:
#![allow(unused)]
fn main() {
use std::error::Error as _;
if let FerraDbError::Db(_) = &err {
if let Some(db_err) = err.source().and_then(|s| s.downcast_ref::<sea_orm::DbErr>()) {
// inspect db_err
}
}
}
source() on NotFound and Conflict returns None — these variants carry their context in their fields.
Construction rules
External consumers cannot directly construct NotFound or Conflict — those variants are produced exclusively by the repository itself at the single from_sea_orm mapping site. A raw sea_orm::DbErr that propagates through ? becomes FerraDbError::Db(_) via the From<sea_orm::DbErr> derive; that is the only construction path available to user code.
The Page<M> shape
#![allow(unused)]
fn main() {
#[non_exhaustive]
#[derive(Debug)]
pub struct Page<M> {
pub items: Vec<M>,
pub total: u64,
pub page: u32,
pub per_page: u32,
}
}
- 1-indexed
pageby framework convention.page = 0is treated identically topage = 1(the repository appliessaturating_sub(1)internally to hand Sea-ORM its 0-indexed offset). total: u64fits any Postgresbigintin the non-negative half — the full-table row count observed by the count query.items.len() <= per_pagealways. An over-scrolled request isOk(Page { items: vec![], total, .. }), neverNotFound.- Concurrent-insert drift.
items.len()andtotalmay disagree by ±1 under concurrent inserts — both queries run serially on the same connection (FR-015), not in parallel. Callers that need strict consistency usefind_page_in_txinside aSERIALIZABLEtransaction. - Serialize, not Deserialize.
Page<M>: serde::Serialize(inherited fromM: FerraModel: Serialize). NoDeserialize— pages are server-constructed, never parsed from client input. #[non_exhaustive]— future phases may add fields (e.g.,cursor: Option<Cursor>at 0.7.5 Enameling). Construct viaPage::new(items, total, page, per_page), not a struct literal.
Transactions
ferra-db re-exports DatabaseConnection and DatabaseTransaction from Sea-ORM under their original names, so you can type both handles without a direct sea-orm dependency. Obtaining a transaction requires importing Sea-ORM’s TransactionTrait:
#![allow(unused)]
fn main() {
use ferra::{DatabaseConnection, DatabaseTransaction, FerraRepository, PgRepository};
use sea_orm::TransactionTrait; // import needed for .begin()
let tx: DatabaseTransaction = conn.begin().await?;
repo.insert_in_tx(&tx, film_1).await?;
repo.insert_in_tx(&tx, film_2).await?;
tx.commit().await?; // both rows persist atomically
}
Rollback on drop
Dropping a DatabaseTransaction without calling .commit() rolls it back. No cleanup call is required on the failure path:
#![allow(unused)]
fn main() {
let tx = conn.begin().await?;
repo.insert_in_tx(&tx, film_1).await?;
if let Err(e) = repo.insert_in_tx(&tx, film_2).await {
// tx goes out of scope here → Sea-ORM rolls it back. film_1 is NOT persisted.
return Err(e.into());
}
tx.commit().await?;
}
The TransactionTrait import
ferra-db deliberately does not re-export sea_orm::TransactionTrait under a Ferra-prefixed alias. Callers import it explicitly: use sea_orm::TransactionTrait;. This is a one-line acknowledgment that Sea-ORM owns the transaction mechanism; it is not a barrier to use, just intentional transparency.
No raw-SQL entry point
ferra-db publishes NO entry point that accepts a pre-formed SQL string. The following paths do NOT resolve and WILL NOT resolve in 0.3.0 — each is asserted by a trybuild compile-fail fixture (FR-029 is a release blocker):
ferra_db::Statement // not reachable
ferra_db::execute_unprepared // not reachable
ferra_db::raw_sql // not reachable
The same three fragments are also blocked under ferra:: via the companion fixture at crates/ferra/tests/ui/no_raw_sql.rs.
The escape hatch
If you genuinely need raw SQL (a hand-written migration, a complex analytics query that defeats Sea-ORM’s builder), add sea-orm as a direct dependency of your consumer crate:
[dependencies]
ferra = "0.3"
sea-orm = { version = "=2.0.0-rc.38", features = ["runtime-tokio-rustls", "sqlx-postgres", "macros"] }
Then use Sea-ORM’s own raw-SQL entry points (Statement::from_string, execute_unprepared, etc.) directly. This is a deliberate, transparent act — your code reads as “I am opting out of Ferra’s SQL-injection-by-construction contract for this specific query” rather than reaching through a convenience hole in the framework.
The ferra facade
The ferra crate is the single user-facing dependency. At 0.3.0 it re-exports the public surface of ferra-core and ferra-db:
#![allow(unused)]
fn main() {
use ferra::{
FerraDbError, FerraRepository, PgRepository, Page,
DatabaseConnection, DatabaseTransaction,
Id, FerraModel, ModelMeta, FieldMeta, FieldType,
};
}
#[derive(FerraModel)] is NOT re-exported at 0.3.0. Consumers who want the derive at 0.3.0 add ferra-forge as a second direct dependency:
[dependencies]
ferra = "0.3"
ferra-forge = "0.2" # for #[derive(FerraModel)] — re-export arrives at 0.4.0
At 0.4.0 Refining, ferra will gain pub use ferra_forge::FerraModel; and the two-line pattern collapses to one. The 0.3.0 split is deliberate: re-exporting the derive at 0.3.0 would force every ferra-depending consumer to link the proc-macro toolchain even when only the repository layer is needed.
Worked example
A complete compiling example. Drop this into a downstream crate that depends on ferra, ferra-forge, sea-orm, serde, and the Sea-ORM migration crate. The DATABASE_URL environment variable must point at a reachable PostgreSQL 16.
1 — the migration
-- migrations/20260417000000_create_films.sql
CREATE TABLE films (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL UNIQUE,
release_year INTEGER,
archived BOOLEAN NOT NULL DEFAULT FALSE
);
2 — the entity (sibling-derive shape)
#![allow(unused)]
fn main() {
// src/film.rs
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub release_year: Option<i32>,
pub archived: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
pub use Model as Film;
}
3 — the CRUD round-trip
// src/main.rs
use ferra::{FerraDbError, FerraRepository, PgRepository};
use sea_orm::TransactionTrait;
mod film;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db_url = std::env::var("DATABASE_URL")?;
let conn = sea_orm::Database::connect(&db_url).await?;
let repo = PgRepository::<film::Film>::new(conn.clone());
// Insert
let blade_runner = repo.insert(film::Model {
id: 1,
title: "Blade Runner".to_owned(),
release_year: Some(1982),
archived: false,
}).await?;
println!("inserted: {blade_runner:?}");
// Read
let one = repo.find_by_id(1).await?;
assert_eq!(one.title, "Blade Runner");
// Update
let mut tweaked = one.clone();
tweaked.archived = true;
let updated = repo.update(1, tweaked).await?;
assert!(updated.archived);
// Paginate
let page = repo.find_page(1, 10).await?;
println!("page 1/10: {} items, {} total", page.items.len(), page.total);
// Compound write: two inserts in a single transaction
let tx = conn.begin().await?;
repo.insert_in_tx(&tx, film::Model {
id: 2, title: "Alien".into(), release_year: Some(1979), archived: false,
}).await?;
repo.insert_in_tx(&tx, film::Model {
id: 3, title: "2001: A Space Odyssey".into(), release_year: Some(1968), archived: false,
}).await?;
tx.commit().await?;
// Delete
repo.delete(1).await?;
assert!(matches!(repo.find_by_id(1).await, Err(FerraDbError::NotFound { .. })));
Ok(())
}
Troubleshooting
"films/42 not found"
You observed FerraDbError::NotFound { resource: "films", id: "42" } — the find_by_id / update / delete call targeted a primary key that has no row. At the HTTP layer (0.4.0) this maps to 404. Check that the id value you passed corresponds to an extant row.
"unique constraint \"films_title_key\" violated on column \"title\""
You observed FerraDbError::Conflict(_) — Postgres returned SQLSTATE 23505. The inner message names the constraint and, when available, the offending column. At the HTTP layer (0.4.0) this maps to 409. Resolve by either changing the colliding value or deleting the existing row first.
A DbErr::Custom("...") in logs
You observed FerraDbError::Db(_) — any sea_orm::DbErr that is not a recognized NotFound / NotUpdated / unique-violation shape falls through here. The wrapped DbErr is reachable via err.source().and_then(|s| s.downcast_ref::<sea_orm::DbErr>()). At the HTTP layer (0.4.0) this maps to 500 without leaking the internal detail into the response body.
error[E0277]: the trait bound <M>: sea_orm::ModelTrait is not satisfied
You forgot the DeriveEntityModel derive on the struct. The 0.2.0 sibling-derive canonical shape requires BOTH FerraModel AND DeriveEntityModel on the same #[derive(...)] list. Add DeriveEntityModel back and recompile.
error[E0425]: cannot find value Statement in crate ferra_db
Expected — ferra-db publishes no raw-SQL entry point (FR-007). To reach Sea-ORM’s raw-SQL surface, add sea-orm as a direct dependency of your consumer crate and call through it explicitly.
Stability commitment
| Item | 0.3.0 commitment | Change requires |
|---|---|---|
FerraDbError variant list | Three variants — NotFound, Conflict, Db | ADR (locked through 0.6.0 Welding) |
FerraDbError::Display wording | Stable templates per §Error taxonomy | ADR + user-guide update |
FerraRepository<M> method set | Ten methods — five ambient + five _in_tx | ADR to remove / rename; additions are additive |
PgRepository::new(conn) | (conn: DatabaseConnection) -> Self | ADR |
Page<M> field list | Four named fields (items, total, page, per_page) | #[non_exhaustive] allows additions; removals require ADR |
DatabaseConnection / DatabaseTransaction re-exports | Stable names, re-exported from Sea-ORM | ADR |
| Forbidden raw-SQL fragments | Statement / execute_unprepared / raw_sql do not resolve | ADR |
MSRV: rust-version = "1.88" — inherited from the workspace. Edition 2024 native async-in-trait requires it.
ferra-http — the Axum CRUD handler layer
The 0.4.0 Refining deliverable. Turns a #[derive(FerraModel, DeriveEntityModel)] entity into a fully-wired Axum router with RFC 7807 errors, HAL-lite hypermedia, pagination, and constitutional security defaults — zero hand-written routes, zero hand-written error mapping.
This page is the authoritative user-guide for ferra-http. A reader with only this page plus standard Rust knowledge produces a compiling ferra_router::<Film>(state) call site on the first attempt — no ADR, no constitution, and no framework source is required.
Zero-to-API in five lines
#![allow(unused)]
fn main() {
use ferra::*;
let conn = sea_orm::Database::connect(&database_url).await?;
let state = FerraState::<Film>::new(conn);
let app = ferra_router::<Film>(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
}
Drop one #[derive(FerraModel, DeriveEntityModel, Serialize, Deserialize)] on a struct whose PK is i32 (or any Display + DeserializeOwned + Send + Sync + 'static) and you get the five CRUD endpoints (GET /films / GET /films/:id / POST /films / PUT /films/:id / DELETE /films/:id), a Location header on creation, a Warning: 214 header on per_page clamp, and an application/problem+json body on every error — with no further code.
See examples/hello-ferra/ for the full worked example with PostgreSQL container + Rust-coded migrations.
The FerraState<M> constructor
#![allow(unused)]
fn main() {
pub struct FerraState<M> { pub repo: Arc<PgRepository<M>> }
impl<M> FerraState<M> {
pub fn new(pool: DatabaseConnection) -> Self;
}
}
#[non_exhaustive]— construct viaFerraState::new(pool); struct-literal construction is not supported. Future phases add fields (tracing context, auth identity) without breaking this.#[derive(Clone)]— cloning is a reference-count bump onArc<PgRepository<M>>. Every request clones.- Implements
axum::extract::FromRef<FerraState<M>> for Arc<PgRepository<M>>. Custom handlers that only need the repository writeState(repo): State<Arc<PgRepository<M>>>; the Ferra handlers all takeState(state): State<FerraState<M>>.
Instantiate one FerraState<M> per model per database; cheaply share the same DatabaseConnection across models by calling FerraState::new(conn.clone()) multiple times.
The five CRUD handlers
All five handlers are generic over M: FerraModel + <Sea-ORM bounds>. You rarely name them directly — ferra_router<M> mounts them for you. When you do, the HTTP contracts are:
| Handler | Method | Path | Request | Success | Error surfaces |
|---|---|---|---|---|---|
get_item<M> | GET | /{resource}/:id | — | 200 OK + Json<ItemResponse<M>> | 400 on path-extractor rejection · 404 on missing row · 500 on DB error |
get_collection<M> | GET | /{resource} | ?page={u32}&per_page={u32} | 200 OK + optional Warning: 214 … + Json<CollectionResponse<M>> | 500 on DB error |
create_item<M> | POST | /{resource} | Json<M> body | 201 Created + Location: {base}/{resource}/{id} + Json<ItemResponse<M>> | 400 on body parse · 409 on unique violation · 413 on oversized body · 429 on rate limit · 500 |
update_item<M> | PUT | /{resource}/:id | Json<M> body | 200 OK + Json<ItemResponse<M>> | 400 · 404 · 409 · 413 · 429 · 500 |
delete_item<M> | DELETE | /{resource}/:id | — | 204 No Content (no body) | 400 · 404 · 429 · 500 |
Path id is authoritative on update and delete — any id field in the request body is ignored for the WHERE clause. {resource} is <M as FerraModel>::meta().resource_name (e.g. "films").
RFC 7807 error taxonomy
Every error from every handler is rendered as application/problem+json per RFC 7807 §3. Six variants, locked through 0.6.0 Welding:
| Variant | HTTP status | type URI | title |
|---|---|---|---|
FerraError::NotFound { resource, id } | 404 | https://ferra.rs/errors/not_found | Resource Not Found |
FerraError::Validation(ValidationErrors) | 400 | https://ferra.rs/errors/validation | Validation Error |
FerraError::Conflict(String) | 409 | https://ferra.rs/errors/conflict | Conflict |
FerraError::Internal(String) | 500 | https://ferra.rs/errors/internal | Internal Server Error |
| (middleware) | 413 | https://ferra.rs/errors/payload_too_large | Payload Too Large 1 |
| (middleware) | 429 | https://ferra.rs/errors/rate_limited | Too Many Requests 1 |
Worked body for a 404:
{
"type": "https://ferra.rs/errors/not_found",
"title": "Resource Not Found",
"status": 404,
"detail": "films/42 not found"
}
Worked body for a 400 from a malformed JSON request:
{
"type": "https://ferra.rs/errors/validation",
"title": "Validation Error",
"status": 400,
"detail": "validation failed",
"errors": {
"errors": [
{ "field": "body", "code": "invalid_json", "message": "expected `,` or `}` at line 1 column 14" }
]
}
}
Internals never leak. The detail field on a 500 is always the literal string "internal server error"; the inner sea_orm::DbErr is dropped before reaching the HTTP body. The Axum "Failed to deserialize … into the target type: " prefix and any axum::extract::rejection:: substring are stripped at the extractor boundary; only human-readable messages reach errors[*].message.
Worked body for a 429 from the rate-limit mapper:
{
"type": "https://ferra.rs/errors/rate_limited",
"title": "Too Many Requests",
"status": 429,
"detail": "rate limit exceeded; retry after 3 seconds"
}
The response also carries the Retry-After header from the underlying rate-limit component verbatim (integer seconds). When the component does not emit a Retry-After header, detail degrades gracefully to the literal string "rate limit exceeded" and the header is absent from the response. The rate-limit mapper mounts only alongside the framework’s default rate-limit component — consumer-provided rate-limiters produce their own error shape (see ADR-0015 for rationale).
The HAL-lite envelope
Every successful body carries _links so an AI agent can navigate the API without prior URL knowledge.
ItemResponse<M> — single-item envelope
Model fields are flattened at the top level via #[serde(flatten)]. A Film { id: 1, title: "Rust en pratique", director: "Alice", year: Some(2025) } serializes as:
{
"id": 1,
"title": "Rust en pratique",
"director": "Alice",
"year": 2025,
"_links": {
"self": { "href": "https://api.example.com/films/1" },
"collection": { "href": "https://api.example.com/films" }
}
}
CollectionResponse<M> — paginated envelope
{
"items": [ /* array of ItemResponse<M> */ ],
"total": 25,
"page": 2,
"per_page": 10,
"_links": {
"self": { "href": "https://api.example.com/films?page=2&per_page=10" },
"next": { "href": "https://api.example.com/films?page=3&per_page=10" },
"prev": { "href": "https://api.example.com/films?page=1&per_page=10" },
"first": { "href": "https://api.example.com/films?page=1&per_page=10" },
"last": { "href": "https://api.example.com/films?page=3&per_page=10" }
}
}
On boundary pages, next or prev serialize as JSON null (never omitted). On total == 0, last_page == 1 and first == last == self.
Building links manually
#![allow(unused)]
fn main() {
pub fn build_item_links(meta: &ModelMeta, id: &str, base_url: &str) -> ItemLinks;
pub fn build_collection_links(
meta: &ModelMeta,
page: u32,
per_page: u32,
total: u64,
base_url: &str,
) -> CollectionLinks;
}
Use these when writing a custom handler that returns an envelope-shaped body.
Pagination
#![allow(unused)]
fn main() {
pub struct PaginationParams { pub page: Option<u32>, pub per_page: Option<u32> }
impl PaginationParams {
pub const fn with(page: Option<u32>, per_page: Option<u32>) -> Self;
pub fn resolved_page(&self) -> u32; // default 1; 0 → 1
pub fn resolved_per_page(&self) -> u32; // default 20; clamp to [1, 100]
pub fn clamped_per_page(&self) -> Option<u32>;
}
}
PaginationParams is #[non_exhaustive] — external struct-literal construction is rejected. For code that needs to build a PaginationParams outside an Axum Query<_> extractor (integration tests, admin tools, batch jobs), use the stable with(page, per_page) constructor:
#![allow(unused)]
fn main() {
use ferra::PaginationParams;
let p = PaginationParams::with(Some(3), Some(50));
assert_eq!(p.resolved_page(), 3);
assert_eq!(p.resolved_per_page(), 50);
let defaulted = PaginationParams::with(None, None);
assert_eq!(defaulted.resolved_page(), 1);
assert_eq!(defaulted.resolved_per_page(), 20);
let clamped = PaginationParams::with(None, Some(500));
assert_eq!(clamped.resolved_per_page(), 100);
assert_eq!(clamped.clamped_per_page(), Some(100));
}
The constructor stores the raw Option<u32> inputs as-is and applies the clamp / default lazily through the resolved_* / clamped_per_page methods — values produced via with(...) are observationally identical to those that reach a handler through the HTTP extraction path.
- Default:
page=1,per_page=20. - Cap:
per_page ≤ 100(hard limit). Values below 1 or above 100 are silently clamped and the response carriesWarning: 214 - "per_page clamped to {N} (max 100)"per RFC 7234 §5.5.4. - The response body’s
per_pagefield reports the clamped value (so clients can detect the clamp without parsing theWarningheader).
Worked curl:
$ curl -D - 'http://localhost:3000/films?per_page=500'
HTTP/1.1 200 OK
warning: 214 - "per_page clamped to 100 (max 100)"
content-type: application/json
...
{"items":[...],"total":523,"page":1,"per_page":100,"_links":{...}}
Base-URL extractor
#![allow(unused)]
fn main() {
pub fn base_url_from_parts(parts: &http::request::Parts) -> String;
}
Produces the base_url prefix used by build_item_links / build_collection_links. Behavior:
- Scheme: first comma-separated token of
X-Forwarded-Protoif it matches the{http, https}allowlist; otherwise"http". - Host: the
Hostheader if present and non-empty; otherwise"localhost". - Output format:
"{scheme}://{host}". No trailing slash.
Anti-spoofing: X-Forwarded-Proto: javascript (or any non-allowlisted scheme) falls back to http. A spoofed header cannot inject a javascript: URL into _links.*.href by construction.
Deploying behind a TLS-terminating load balancer? Ensure the LB sets X-Forwarded-Proto: https so the generated links match the client-visible scheme.
Custom extractors
See custom-handlers.md — the rationale for FerraJson<T> / FerraPath<T> (RFC 7807 error continuity), the FromRequest vs FromRequestParts ordering rule, the Send + Sync + 'static cheat-sheet, and a worked end-to-end POST /films/{id}/publish action route.
Security defaults
ferra_router::<M>(state) mounts a deny-by-default Tower layer stack — a consumer who writes exactly those five lines is already constitution-§I compliant:
| Layer | Default | Override path |
|---|---|---|
tower_http::cors::CorsLayer::new() | Deny all cross-origin | FerraLayer::cors(...) at 0.7.0 Pre-tempering |
| 413 body-limit mapper | Rewrites stock text/plain → application/problem+json with type: .../payload_too_large | — (permanent) |
tower_http::limit::RequestBodyLimitLayer | 1 MiB | FerraLayer::body_limit(...) at 0.7.0 |
tower_http::trace::TraceLayer | Body sampling OFF (never logs PII) | Per-route opt-in at 0.12.0 Sheen |
tower_governor::GovernorLayer on mutation endpoints | per_second(2) + burst_size(5) + peer-socket-IP key | Full config at 0.9.0 Forging III |
PaginationParams clamp (FR-017) | Loud Warning: 214 header on every clamp | — (constitutional) |
X-Forwarded-Proto allowlist | {http, https} only | — (constitutional) |
429 wire format — the framework’s default rate-limit component’s 429 responses are rewritten to application/problem+json with type: https://ferra.rs/errors/rate_limited, preserving the Retry-After header from tower_governor verbatim. See the worked body in §“RFC 7807 error taxonomy” above. Oversized bodies produce the same application/problem+json shape — a client parsing type never sees a mixed wire format.
Rate-limit observability event. Each time the rate-limit mapper rewrites a 429, it emits a single structured tracing::warn! event so an operator tailing logs can see rate-limit rejections without reading the framework source:
- Target:
ferra_http::rate_limit— namespaced so an operator can isolate it viaEnvFilter(RUST_LOG=ferra_http::rate_limit=warn). - Message:
rate limit exceeded. - Severity:
WARN. - Fields (deliberately minimal):
http.method— the request HTTP method (e.g."POST"). Always present.http.target— the request path-and-query (e.g."/films"or"/films?page=2"). Always present.http.retry_after_seconds— integer seconds parsed from the inboundRetry-Afterheader. Present only when the header parses as a non-negative integer; genuinely absent otherwise (not recorded as0).
Request and response body content never appear in this event — only the three fields listed above. The schema is pinned minimal so the framework’s dedicated observability phase at 0.12.0 Sheen can absorb it into span-based telemetry (adding trace_id, span_id, and peer-IP once the trusted-proxy story lands) without breaking a consumer’s existing EnvFilter or custom tracing::Layer.
Wire up a subscriber in your host binary’s main to surface these events:
#![allow(unused)]
fn main() {
tracing_subscriber::fmt()
.with_env_filter("ferra_http::rate_limit=warn")
.init();
}
Layer placement (FR-006). The rate-limit mapper mounts as the outer layer of the mutation sub-router only, wrapping tower_governor::GovernorLayer from the outside (the last .layer() call on that sub-router). Consumer-added middleware appended to ferra_router::<M>(state) wraps the mapper from further outside — see §“Layer ordering — how .layer() composes” below for the full diagram.
Layer ordering — how .layer() composes
Tower’s .layer() wraps the current router as the inner service, so the last .layer() call becomes the outermost layer. A .layer(MyCustomLayer::new()) appended to ferra_router::<M>(state) therefore wraps every Ferra-provided layer from the outside.
Side-by-side: the conceptual stack (top = outermost, bottom = innermost) and the literal .layer() call sequence in ferra_router::<M>(state) (top = first call, bottom = last call). Arrows map the inversion:
CONCEPTUAL stack (request top→bottom): LITERAL call sequence (first→last in source):
outer → CorsLayer .layer(DefaultBodyLimit::disable()) ← first call (innermost)
body_limit_mapper .layer(TraceLayer::new_for_http())
RequestBodyLimitLayer(1 MiB) .layer(RequestBodyLimitLayer::new(1 MiB))
TraceLayer .layer(middleware::from_fn(body_limit_mapper))
DefaultBodyLimit::disable .layer(CorsLayer::new()) ← last call (outermost)
(on mutation sub-router:)
rate_limit_mapper [on mutation sub-router:]
GovernorLayer .layer(GovernorLayer { ... }) ← first call on mutations
inner → handlers .layer(middleware::from_fn(rate_limit_mapper)) ← last (outer)
Reading it: a request enters at CorsLayer (outermost), traverses down to the handler, and the response retraces the stack back out. In the source, the call order is inverted — the first .layer() call is the innermost layer on the merged router, and the last .layer() call is the outermost.
Mutation sub-router subtlety. GovernorLayer (rate-limit) and rate_limit_mapper (the 429 RFC 7807 wrapper) mount on the mutation sub-router only — never on the merged router and never on read routes. rate_limit_mapper is the last .layer() call on that sub-router, so it wraps GovernorLayer from the outside and sees the 429s that layer produces. Placing a custom observability layer is a decision about which sub-router you wrap — consumers who want to observe only mutations attach to a sub-router; consumers who want to observe everything attach to the merged router returned by ferra_router::<M>(state).
Worked consumer example. A consumer appending two custom layers on top of the framework stack:
#![allow(unused)]
fn main() {
let app = ferra_router::<Film>(state)
.layer(MyObservabilityLayer::new()) // outer: sees requests before the framework
.layer(MyShedLoadLayer::new()); // appended AFTER → now the TRUE outermost
}
On incoming requests: MyShedLoadLayer runs first, then MyObservabilityLayer, then the framework stack (CORS → body_limit_mapper → RequestBodyLimitLayer → TraceLayer → DefaultBodyLimit::disable → handlers; with the mutation-only branch adding rate_limit_mapper → GovernorLayer before mutation handlers). On the way back out, responses traverse the stack in reverse.
See also §“RFC 7807 error taxonomy” for the 429 wire body the rate_limit_mapper produces.
The ferra facade
[dependencies] ferra = { package = "ferra-rs", version = "0.5" } + use ferra::*; is the canonical entry point at 0.4.0. The package = "ferra-rs" alias is required because the ferra name on crates.io is owned by an unrelated project; the framework ships as ferra-rs and Cargo’s package alias keeps ferra::* working in your Rust code (see Getting Started). Every item on this page — FerraState, FerraError, FerraJson, FerraPath, PaginationParams, Link, ItemLinks, CollectionLinks, ItemResponse, CollectionResponse, build_item_links, build_collection_links, base_url_from_parts, the five handlers, and ferra_router — is reached from this single import, alongside the #[derive(FerraModel)] macro and the ferra-core / ferra-db public surface.
Consumers do not depend on ferra-core, ferra-db, ferra-forge, or ferra-http directly. The 0.4.0 compile-fail trybuild fixture also pins ferra::Statement, ferra::execute_unprepared, ferra::raw_sql, and ferra::sea_orm::Statement as unreachable — there is no raw-SQL entry point on the facade.
Worked end-to-end example
use ferra::*;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
mod film {
use super::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub year: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub use film::Model as Film;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let conn = sea_orm::Database::connect(&database_url).await?;
let app = ferra_router::<Film>(FerraState::new(conn));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
See examples/hello-ferra/ for the full three-command boot sequence (docker compose up -d postgres or podman compose up -d postgres → sea-orm-cli migrate up → cargo run -p hello-ferra) and the smoke.sh script that exercises all seven wire-format contracts.
Troubleshooting
413 vs 400 on oversized bodies
A request whose body exceeds the 1 MiB default cap is rejected either with 413 Payload Too Large or with 400 Bad Request, depending on a single precondition: whether the request declares a Content-Length header.
| Request | Response code | type URI | Responsible mapper |
|---|---|---|---|
Body > 1 MiB with Content-Length declared | 413 | https://ferra.rs/errors/payload_too_large | body_limit_mapper (FR-033a) |
Body > 1 MiB without Content-Length (streaming / chunked) | 400 | https://ferra.rs/errors/validation (single errors[0].code = "invalid_json") | FerraJson<T>::from_request surfacing as FerraError::Validation |
When Content-Length is present, RequestBodyLimitLayer short-circuits at the layer level before the stream is consumed — hence the early 413. When the header is absent, the stream is consumed up to the cap, the read fails, and the JSON extractor rejects the request as a parse error (still machine-readable, still RFC 7807, still parseable via the same generic type-dispatch path you use for the other validation failures).
Both outcomes are semantically correct — the oversized input is rejected either way. The divergence is intentional at 0.4.1. A future release may unify the two paths under a single 413 (an anvil-class cleanup candidate); the Story 3 regression test pins the current 400 fallback so any such rework must explicitly update or remove it. If you can set Content-Length on your client, do — your clients then see a single, predictable 413.
Other diagnostics
- 413 with
application/problem+jsonbody — request body exceeds 1 MiB. Split the request or (at 0.7.0 Pre-tempering) raise the limit viaFerraLayer::body_limit(...). - 429 with
Retry-After— peer IP hit the mutation rate limit (per_second(2)/burst_size(5)). Back off for the number of seconds inRetry-After. Warning: 214 - "per_page clamped ..."header — yourper_pagequery parameter was out of[1, 100]. Useper_page ≤ 100to silence it.problem+jsonwithtype: ".../validation"anderrors[0].code = "invalid_path_param"— the path segment after/{resource}/is not a valid primary key forM(e.g.GET /films/abcwhenFilm.id: i32). The built-in path extractor rejects before the handler runs.- 500 with
detail: "internal server error"— the DB surfaced an error the framework does not map. Inspect logs / tracing for theDebugform ofFerraError::Internal; the HTTP body intentionally says nothing more. - CORS preflight fails —
CorsLayer::new()denies all cross-origin by default. At 0.4.0, CORS is not configurable; at 0.7.0 Pre-temperingFerraLayer::cors(...)ships the override surface.
-
emitted by a middleware mapper (the 413 body-limit mapper or the 429 rate-limit mapper), not by
FerraError::IntoResponse. The wire shape is identical to the four handler-produced variants — a client that handles onetypeURI handles them all through the same generic RFC 7807 code path. ↩ ↩2
ferra-openapi
ferra-openapi is the framework’s documentation surface. It builds
an OpenAPI 3.1 specification from a model’s ModelMeta description
and serves the spec at /docs/openapi.json plus an interactive
Scalar UI at /docs. Both surfaces are derived end-to-end from the
same #[model] source of truth that drives the framework’s HTTP
routes, SQL queries, and hypermedia links.
The crate ships in 0.5.0 Rolling. The recommended call site is
Foundry::with_docs() (landing in 0.5.0 Rolling alongside this
crate); the underlying free functions in this crate stand alone for
direct use cases and for the per-resource integration test suite.
What you get for free
A Ferra #[model] mounted through Foundry::with_docs() produces:
GET /docs/openapi.json— fullOpenAPI3.1 document.GET /docs— interactive Scalar UI rendering the spec.
Both routes are publicly reachable by default. The auth-gated
variant (Foundry::with_docs_protected(verifier)) ships in the same
release and is the recommended posture for any publicly-reachable
network surface.
For each mounted model M (PascalCase struct name), the spec
contains:
- Five paths (
GET /{resource},GET /{resource}/{id},POST /{resource},PUT /{resource}/{id},DELETE /{resource}/{id}). - Four projection schemas (
{Model},Create{Model}Input,Update{Model}Input,{Model}Collection). - A shared
ProblemDetailsschema referenced by every error response. - Every operation carries an
operationIdand atagsarray containing the resource name. Zero inline schemas in operation definitions; every body schema is a$refintocomponents/schemas.
Enabling the docs surface
The headline call site is Foundry::with_docs():
#![allow(unused)]
fn main() {
use ferra::Foundry;
let app = Foundry::new(conn)
.mount::<Film>()
.with_docs()
.build();
}
That single chain produces a runnable HTTP service mounting Film’s
five CRUD endpoints plus /docs/openapi.json and /docs with
zero boilerplate.
Variants
| Method | Effect |
|---|---|
.with_docs() | Public docs at /docs and /docs/openapi.json. |
.with_docs_at("/api-docs") | Public docs at the supplied prefix. |
.with_docs_protected(verifier) | Auth-gated docs at /docs; anonymous requests get 401 + RFC 7807 body. |
.with_docs_protected_at("/api-docs", verifier) | Auth-gated at the supplied prefix. |
Public-by-default rationale
The framework’s default-deny posture on model routes does not extend
to the docs surface. The reason: the spec describes the API surface
but does not serve resource data, and gating discovery would block
the framework’s documented zero-to-API-in-5-minutes standard for
every new consumer. The deviation is recorded in ADR-0024; the
secure-default .with_docs_protected(verifier) ships in the same
release as the discoverable opt-in so the consumer’s ability to
choose a secure posture without changing crates is preserved.
For any publicly-reachable network surface, prefer
.with_docs_protected(verifier).
Naming conventions
Schema names
For a model named Film, the spec carries four projections in
components/schemas:
| Projection | OpenAPI schema name | Purpose |
|---|---|---|
| Read | Film | Response shape for GET and the body of POST / PUT responses. |
| Create | CreateFilmInput | Request body of POST /films. |
| Update | UpdateFilmInput | Request body of PUT /films/{id}. |
| Collection | FilmCollection | Response shape for GET /films ({ items: [Film], page, per_page, total }). |
The schema name derives from resource_name (the URL path slug) via PascalCase singularisation.
These names are SDK-friendly: orval, openapi-generator, kiota, and their peers produce idiomatic types in the target language without post-processing.
operationId
Every operation carries a stable operationId:
| Method | Path | operationId |
|---|---|---|
GET | /films | listFilms |
GET | /films/{id} | getFilm |
POST | /films | createFilm |
PUT | /films/{id} | updateFilm |
DELETE | /films/{id} | deleteFilm |
Versioned chains (Foundry::api_version("v1")) prefix the
operationId with the version segment: v1.listFilms,
v1.getFilm, etc.
tags
Every operation carries a single tag — the resource name in
snake_case plural (films, actors). Scalar / Redoc / Swagger UI
group operations by tag for easier navigation.
Field type mapping
Field types map to OpenAPI 3.1 schemas as follows:
| Rust type | OpenAPI |
|---|---|
String | { "type": "string" } |
i32 | { "type": "integer", "format": "int32" } |
i64 | { "type": "integer", "format": "int64" } |
f32 / f64 | { "type": "number" } |
bool | { "type": "boolean" } |
ferra::Id / Uuid | { "type": "string", "format": "uuid" } |
Option<T> | nullable T ({ "type": ["string", "null"] }) and absent from required |
ferra::DateTime | { "type": "string", "format": "date-time" } |
ferra::Date | { "type": "string", "format": "date" } |
Option<T> uses the OpenAPI 3.1 nullable form (a type array
containing "null"), not the deprecated 3.0 nullable: true
keyword.
The two typed time newtypes (ferra::DateTime and ferra::Date)
are recognised by the framework and emit the right format. Direct
use of legacy time crates (chrono::*, time::*) is rejected at
the field-declaration site — see the §“Time vocabulary” section of
ferra-core.md.
Field annotations
Two #[field(...)] attributes shape what appears in each
projection.
#[field(skip)]
Excludes the field from every projection schema and from the wire contract. Useful for server-only state (audit timestamps, internal flags).
#![allow(unused)]
fn main() {
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[field(skip)]
internal_note: String,
}
}
internal_note does not appear in Film, CreateFilmInput, or
UpdateFilmInput, and the wire shape rejects an inbound value for
that field.
#[field(read_only)]
The field appears in the read projection ({Model}) marked
readOnly: true, but is excluded from CreateFilmInput and
UpdateFilmInput. Useful for server-assigned values that the
consumer can read but cannot write.
#![allow(unused)]
fn main() {
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[field(read_only)]
indexed_at: ferra::DateTime,
}
}
indexed_at appears in Film with readOnly: true; the create /
update inputs reject the field.
The shared ProblemDetails schema
Every error response in the spec references the same schema:
{
"$ref": "#/components/schemas/ProblemDetails"
}
The schema’s body:
{
"type": "object",
"required": ["type", "title", "status"],
"properties": {
"type": {
"type": "string",
"format": "uri",
"enum": [
"https://ferra.rs/errors/not_found",
"https://ferra.rs/errors/validation",
"https://ferra.rs/errors/conflict",
"https://ferra.rs/errors/internal",
"https://ferra.rs/errors/payload_too_large",
"https://ferra.rs/errors/rate_limited",
"https://ferra.rs/errors/unauthorized"
]
},
"title": { "type": "string" },
"status": { "type": "integer", "format": "int32" },
"detail": { "type": "string" },
"instance": { "type": "string", "format": "uri-reference" }
}
}
The closed enum on type is the load-bearing constraint: SDK
generators produce a typed union over the URI set in the target
language. An AI agent generating client code is foreclosed from
fabricating new URIs — values outside the enum fail schema
validation.
The full URI table (with HTTP status, title, and example body for every variant) lives in error handling.
Mounting an alternative UI
/docs/openapi.json is the framework’s stable spec endpoint; the
Scalar UI at /docs is the bundled default but not the only
choice. To replace Scalar with Redoc, RapiDoc, or Swagger UI, mount
the alternative on the existing axum router via Router::merge:
#![allow(unused)]
fn main() {
use ferra::Foundry;
use utoipa_redoc::{Redoc, Servable};
let app = Foundry::new(conn)
.mount::<Film>()
.build() // no .with_docs()
.merge(ferra_openapi::docs_routes( // hand-mount the JSON
ferra_openapi::build_openapi(
ferra_openapi::info("Hello", "0.5.0"),
"Film",
Film::meta(),
),
"/docs",
))
.merge(Redoc::with_url("/redoc", openapi)); // and Redoc on top
}
The same pattern works with utoipa-rapidoc and utoipa-swagger-ui.
Where the spec is built
build_openapi(info, model_name, meta) assembles the full document
from a model’s static ModelMeta reference. The function runs once
at process startup; the resulting OpenApi value is cached and
served unchanged on every /docs/openapi.json request — no
per-request rebuild.
For the multi-model assembly, build_openapi_for_models(info, &[ModelEntry, ...]) produces a single document covering every
mounted model. Foundry::build() calls this internally; consumers
who want to assemble a spec by hand can call it directly.
Linting your spec
The framework’s CI runs Spectral
against every reference example’s generated /docs/openapi.json,
extending the default spectral:oas ruleset with four Ferra-specific
rules: every operation declares a tags array, every operation
declares an operationId, every request body schema is a $ref into
components/schemas, and every response schema is a $ref into
components/schemas. New linter findings at the warn severity bar
block the build.
Two takeaways for consumer-side CI:
- Spectral is the recommended linter for a Ferra-served API.
The framework’s
.spectral.yaml(in the workspace root) is a reasonable starting ruleset. Copy it into a downstream repo and add deployment-specific rules (e.g., re-enableoas3-api-serversonce the deployment carries a non-emptyserversarray). - The rule selection is not arbitrary. The full
adopted / deferred / rejected matrix — including why Spectral wins
over Redocly, why Vacuum is rejected, and why
progenitor/openapi-generator/orvalsmoke-tests are deferred rather than adopted — lives in the contributor research memo atdocs/research/dropshot-openapi-conformance.md. Curious readers consult that memo; correct use of the gate does not require it.
See also
Foundry— the assembly facade that mounts the docs surface alongside resource routes (lands alongside this crate in 0.5.0).- Error handling — the closed
ERROR_TYPESURI namespace and theProblemDetailsconsumer- side branching pattern. ferra-core§Time vocabulary — the typedDateTime/Datenewtypes that emitformat: date-timeandformat: datein the spec.
For curious readers, the architectural decisions behind the docs
surface are recorded in ADR-0024 (crate scope + public-default
posture), ADR-0026 (closed ERROR_TYPES URI namespace), and
ADR-0003 v2 (Scalar over Swagger UI as the default UI). These
ADRs are not load-bearing for correct use of the feature; this
guide stands alone.
ferra-forge — the #[derive(FerraModel)] derive
Status: 0.2.0 Smelting.
Reader contract. This page stands on its own. A reader with only this page plus standard Rust and Sea-ORM knowledge can produce a compiling entity on the first attempt. Cross-references to ADRs and the constitution are non-load-bearing — every correctness cue is spelled out below.
ferra-forge is the Ferra proc-macro crate. It exports one
procedural derive, #[derive(FerraModel)], which binds a Rust struct
to its static ferra_core::meta::ModelMeta description. The derive
cohabits with Sea-ORM’s DeriveEntityModel on the same struct —
neither replaces the other; both read the struct.
Canonical entity shape (sibling-derive)
The canonical 0.2.0 entity is a plain Rust struct with six derives and two container attributes:
#![allow(unused)]
fn main() {
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub release_year: Option<i32>,
pub rating: f64,
}
// Sea-ORM boilerplate that `DeriveEntityModel` relies on.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
Key facts:
DeriveEntityModelis the Sea-ORM half. It owns the#[sea_orm(...)]namespace, generates theEntity,Column,PrimaryKey,Relation, andActiveModeltypes, and is required on everyFerraModelstruct.FerraModelis the Ferra half. It reads#[sea_orm(primary_key)]and#[sea_orm(table_name)], plus the#[ferra(...)]namespace, and emits a staticModelMetathat every downstream Ferra crate reads.Serialize+Deserializeare required by theFerraModelsupertrait bound.ferra-forgedoes not auto-inject them — see the next section.- Visibility must be at least
pub(crate). Private structs trip class 8 of the Troubleshooting table.
Field types recognised in 0.2.0: String, i32, i64, f64,
bool, Uuid (or ferra_core::id::Id), and Option<T> for the
first five. Option<Uuid> and Option<Id> are not accepted —
see class 4.
Explicit serde derives are required
ferra-forge does not add #[derive(Serialize, Deserialize)] for
you. If you omit them, rustc fires E0277 with the span pinned to
your struct’s name:
error[E0277]: the trait bound `Model: serde::Serialize` is not satisfied
--> src/my_module.rs:7:12
|
7 | pub struct Model {
| ^^^^^ unsatisfied trait bound
|
= note: for local types consider adding `#[derive(serde::Serialize)]` to your `Model` type
note: required by a bound in `_assert_ferra_model_bounds`
The fix is always the same: add #[derive(Serialize, Deserialize)]
to the derive list.
Why not auto-inject? A user with
use serde::Serialize as S; #[derive(S)]would trip a naive name-based detector into silent double-derive and hit E0119 conflicting-impls. Delegating to the trait-bound solver is correct by construction.
Two attribute namespaces, two policies
ferra-forge reads two attribute namespaces with deliberately
different handling (ADR-0006):
| Namespace | Owner | Unknown-key policy |
|---|---|---|
#[sea_orm(...)] | Sea-ORM | silently ignored by ferra-forge |
#[ferra(...)] | Ferra | hard compile_error! |
The asymmetry is load-bearing. Consider the worked example:
#![allow(unused)]
fn main() {
// Security-critical typo. The user MEANT `write_only`.
#[ferra(writeonly)]
pub password_hash: String,
}
Because #[ferra(writeonly)] is not recognised, the build fails:
error: `#[ferra(writeonly)]` is not a recognized attribute
--> src/models/user.rs:12:13
|
12 | #[ferra(writeonly)]
| ^^^^^^^^^
= help: did you mean `write_only`?
= note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase
If Ferra had accepted the typo, password_hash would quietly serialise
into API responses — a Broken Object Property Level Authorization
(OWASP API Top 10 #3). The hard-fail rule makes this class of bug
unshippable.
Conversely, an unknown-to-Ferra but Sea-ORM-permitted key such as
#[sea_orm(rename_all = "camelCase")] passes through Ferra silently
— it is Sea-ORM’s concern, not Ferra’s, and pre-validating it inside
ferra-forge would saddle Ferra with every Sea-ORM minor-release
vocabulary change.
Recognised #[ferra(...)] keys
Container-level
| Key | Value | Semantics |
|---|---|---|
resource | non-empty string literal | Override the default resource_name in the emitted ModelMeta. |
The default resource_name is the #[sea_orm(table_name)] literal
if present, otherwise snake_case(<StructIdent>) with a simple
English plural suffix (Film → films, UserProfile → user_profiles,
Category → categories, Box → boxes).
Field-level
| Flag | readable | writable | skip_api |
|---|---|---|---|
| (none) | true | true | false |
#[ferra(read_only)] | true | false | false |
#[ferra(write_only)] | false | true | false |
#[ferra(skip)] | false | false | true |
#[ferra(read_only)] and #[ferra(write_only)] on the same field
contradict — see class 10. #[ferra(write_only)] on a primary-key
field contradicts the id’s always-readable invariant — see class 12.
Repeated identical flags are idempotent.
Deferred keys and when they land
The following keys are rejected in 0.2.0 (hard compile_error!)
and will arrive in a later roadmap phase. If you reach for any of
them, you get an unknown-key diagnostic — that is intentional, so you
don’t silently compile code that looks correct but does nothing.
| Key | Planned phase | Intended use |
|---|---|---|
hypermedia | 0.3.0 Casting | Control HAL _links emission per field. |
projection / projections | 0.4.0 Refining | Typed projections (ServerFields<M>). |
exposure | 0.4.0 Refining | Exposure::Strict / Exposure::Loose. |
sortable / filterable / searchable | 0.4.0+ | Query DSL primitives. |
computed | 0.5.0+ | Computed fields with a Default bound. |
cascade | 0.6.0+ | Relation cascade rules. |
auth | 0.8.5 | Per-model auth scopes. |
rate_limit | 0.8.5+ | Tower rate-limit layer binding. |
required | — | Never recognised — nullability is deduced from Option<T>. |
Why DeriveEntityModel is required
Skipping the DeriveEntityModel sibling is a class-16 compile error:
error: FerraModel requires DeriveEntityModel on the same struct
help: add DeriveEntityModel to the `#[derive(...)]` list
note: see docs/user-guide/ferra-forge.md § Canonical entity shape — sibling-derive requirement
--> src/models/film.rs:3:12
|
3 | pub struct Film {
| ^^^^
The requirement is not decorative. Phase 0.3.0 Casting wires the
Entity, Column, PrimaryKey, and ActiveModel types that
DeriveEntityModel generates into the router and SQL query builder.
A FerraModel-only struct would compile locally but collapse the
moment ferra-http or ferra-db touches it. The rule fails loud
now so you cannot paint yourself into that corner.
Troubleshooting
Every error class 0.2.0 emits, with the exact message shape and the
fix. Numbering matches data-model.md § 4 of the Smelting spec.
About the rendered shape. Every Ferra-authored diagnostic in this section carries three logical lines — an
error:line, ahelp:line, and anote:line — becauseferra-forgebuilds them as a singlesyn::Erroron stable Rust. Thehelpandnoteappear as indented continuation lines of the error body rather than as separate= help:/= note:sub-lines (that shape requiresproc_macro::Diagnostic, which is nightly-only as of the 2026-04 toolchain). Text content is identical; grep-based CI checks and visual legibility both work.
Class 1 — no primary key
error: this model has no primary key
help: annotate exactly one field `#[sea_orm(primary_key)]`
note: see docs/user-guide/ferra-forge.md § Troubleshooting — missing primary key
Fix. Add #[sea_orm(primary_key)] to the id field. In 0.2.0
Ferra supports one-field primary keys only.
Class 2 — multiple primary keys
error: composite primary keys are not supported in Ferra 0.2.0
help: keep `#[sea_orm(primary_key)]` on exactly one field
Fix. Consolidate into a single id column. Composite keys are a later-phase feature.
Class 3 — unsupported field type
error: type `<T>` is not supported by Ferra in 0.2.0
help: use one of `String`, `i32`, `i64`, `f64`, `bool`, `Uuid`, or `Option<T>` for the first five
Fix. Model with one of the recognised types, or wait for the
phase that adds your type (Decimal, Timestamp, relations are on
the roadmap).
Class 4 — Option<Uuid> rejection
error: Option<Uuid> is not supported in Ferra 0.2.0
help: remove the `Option<...>` wrapper — UUID identifiers are always required in this phase
note: see crates/ferra-core/src/meta.rs for the FieldType gate
Fix. Use Uuid (non-optional) or model the nullable identifier
at the database layer and project a non-null field into the Ferra
model.
Class 5 — not a struct with named fields
error: FerraModel must be derived on a struct with named fields (not tuple structs, unit structs, enums, or unions)
Fix. Convert to pub struct Name { ... } with named fields.
Class 6 — generic struct
error: FerraModel does not currently support generic models
help: supply a concrete type — remove the generic parameter
Class 7 — lifetime-parameterised struct
error: FerraModel does not currently support lifetime-parameterised models
help: Ferra models are owned values — remove the lifetime parameter
Class 8 — visibility below pub(crate)
error: FerraModel requires at least `pub(crate)` visibility
help: widen the struct's visibility — e.g., `pub struct ...` or `pub(crate) struct ...`
Class 9a — unknown #[ferra(...)] key (no nearest match)
error: `#[ferra(<key>)]` is not a recognized attribute
note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase
Fix. Consult the Recognised keys table above, or the Deferred keys table if you were trying to use a future-phase key.
Class 9b — unknown #[ferra(...)] key with a near-miss hint (release-blocker family)
error: `#[ferra(writeonly)]` is not a recognized attribute
help: did you mean `write_only`?
note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase
Fix. The help line quotes the intended spelling. The canonical
case is #[ferra(writeonly)] on password_hash — a write-only
bypass that would expose sensitive data. The trybuild fixture for
this diagnostic is marked a release blocker per SC-008.
Class 10 — read_only + write_only contradiction
error: `#[ferra(read_only)]` and `#[ferra(write_only)]` contradict
help: keep at most one of the two flags
Class 11 — #[ferra(required)] on Option<T>
error: `#[ferra(required)]` contradicts `Option<T>`
help: remove the attribute or change the field type
Fix. Nullability is deduced from the type, not from an attribute.
Remove #[ferra(required)] or change the field to a non-Option<T>
type.
Class 12 — #[ferra(write_only)] on the primary key
error: primary keys cannot be write-only
help: remove `#[ferra(write_only)]` from the id field
Class 13 — empty #[ferra(resource = "")]
error: `#[ferra(resource)]` must be a non-empty string
help: remove the attribute to fall back to the default
Class 14 — non-string-literal #[ferra(resource = ...)]
error: `#[ferra(resource)]` value must be a string literal
help: e.g., `#[ferra(resource = "films")]`
Class 15 — missing serde derives
Delivered by rustc E0277 on the emitted _assert_ferra_model_bounds
trait-bound assertion (see § Explicit serde derives are required
above). Span is pinned to your struct’s identifier.
Class 16 — missing DeriveEntityModel sibling
error: FerraModel requires DeriveEntityModel on the same struct
help: add DeriveEntityModel to the `#[derive(...)]` list
note: see docs/user-guide/ferra-forge.md § Canonical entity shape — sibling-derive requirement
Fix. Add DeriveEntityModel to the #[derive(...)] list. See the
Canonical entity shape example above.
- :sea_orm::ModelTrait>()
bound assertion; a failure there surfaces aserror[E0277]: the trait bound<YourModel>: sea_orm::ModelTraitis not satisfied. All three paths reduce to the same fix: addDeriveEntityModelto the#[derive(…)]` list.
Class 17 (pass case) — unknown #[sea_orm(...)] key
An unknown-to-Ferra but Sea-ORM-permitted key such as
#[sea_orm(column_name = "...")] compiles without intervention from
Ferra. This is the asymmetry documented in § Two attribute
namespaces, two policies. If Sea-ORM diagnoses the key itself, that
diagnostic surfaces — Ferra does not layer its own.
Custom handlers
Most Ferra apps need at least one route that is not pure CRUD — a publish action, a derived collection, an aggregation, a webhook receiver. This page is the authoritative guide to writing those handlers on top of a Ferra-served API. A reader with this page plus standard Rust knowledge produces a compiling action route on the first attempt — no framework source, no ADR, no constitution required.
The companion CRUD / envelope / Tower-stack / typed-extraction reference is ferra-http.md. The router-assembly facade is foundry.md.
When ferra_router::<M> is not enough
The five built-in handlers cover the resource-shaped endpoints — list, read, create, update, delete. They are all you need for ~80 % of the surface a typical app exposes.
You write a custom handler when the route is not one of those five operations. Common reasons:
- Action routes.
POST /films/{id}/publish,POST /invoices/{id}/cancel,POST /accounts/{id}/transfer— verbs on a resource that aren’t expressible asPUT. - Derived collections.
GET /films/popular,GET /actors/{id}/films— collections whose membership is computed, not justSELECT * WHERE .... - Multi-resource projections.
GET /dashboardreturning a join of three resources at once. - Webhook receivers.
POST /webhooks/stripereading a non-Ferra payload shape. - Cross-cutting endpoints.
GET /healthz,GET /metrics.
Custom handlers are plain Axum handlers. They accept Axum extractors, return Result<_, FerraError> for RFC 7807 error continuity, and compose with the framework router via axum::Router::merge or by appending .route(...) on top of Foundry::build() / ferra_router::<M>(state).
The worked example: POST /films/{id}/publish
The complete shape of an action route — model declaration, handler signature, composition with Foundry — in one file. Drop this into a fresh src/main.rs whose Cargo.toml declares the canonical four-dep set (ferra, tokio, axum, serde) and it compiles.
use ferra::prelude::*;
use axum::{
Json, Router,
http::request::Parts,
routing::post,
};
// --- The model -----------------------------------------------------
mod film {
use super::*;
#[derive(
Clone, Debug, PartialEq, Eq,
DeriveEntityModel, FerraModel,
Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub year: Option<i32>,
pub release_date: Option<Date>,
pub published: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub use film::Model as Film;
// --- The action's request body -------------------------------------
// Plain serde struct — nothing Ferra-specific. The `FerraJson<T>`
// extractor on the handler signature accepts any `T: DeserializeOwned`.
#[derive(Deserialize)]
struct PublishRequest {
release_date: Option<Date>,
}
// --- The handler ---------------------------------------------------
// `#[axum::debug_handler]` is optional but turns the dense
// `Handler` trait-bound error into a single, targeted message
// when one of the rules below is violated. Keep it on while
// iterating; it is a no-op at runtime.
#[axum::debug_handler]
async fn publish_film(
// FromRequestParts — `State` reads only the request parts,
// so it can be in any position.
State(state): State<FerraState<Film>>,
// FromRequestParts — `FerraPath` is the typed-extraction
// wrapper around `axum::extract::Path<T>`.
FerraPath(id): FerraPath<i32>,
// FromRequestParts — `Parts` itself, used below for
// building HAL `_links` from the request URL.
parts: Parts,
// FromRequest — `FerraJson` consumes the request body. Body-
// consuming extractors MUST be the LAST argument.
FerraJson(body): FerraJson<PublishRequest>,
) -> Result<Json<ItemResponse<Film>>, FerraError> {
// 1. Read the existing row.
let mut film = state
.repo
.find_by_id(id)
.await
.map_err(FerraError::from)?;
// 2. Apply the action's effect.
film.published = true;
if let Some(d) = body.release_date {
film.release_date = Some(d);
}
// 3. Persist.
let updated = state
.repo
.update(id, film)
.await
.map_err(FerraError::from)?;
// 4. Build the response envelope with HAL `_links`. The two
// helpers live at the facade root (not the prelude) — reach
// them via the fully-qualified path or add an explicit `use`.
let base_url = ferra::base_url_from_parts(&parts);
let links = ferra::build_item_links(
Film::meta(),
&updated.id.to_string(),
&base_url,
);
Ok(Json(ItemResponse { data: updated, links }))
}
// --- Composition with Foundry --------------------------------------
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let conn = ferra::sea_orm::Database::connect(&database_url).await?;
// Foundry assembles the CRUD + `/docs` surface from `conn`.
let api = Foundry::new(conn.clone())
.mount::<Film>()
.with_docs()
.build();
// The action route lives on a sibling sub-router with its own
// `FerraState<Film>`. `DatabaseConnection: Clone` (Sea-ORM pool
// refcount); `FerraState<M>: Clone` (Arc refcount) — both clones
// are cheap.
let custom = Router::new()
.route("/films/{id}/publish", post(publish_film))
.with_state(FerraState::<Film>::new(conn));
let app = api.merge(custom);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
A successful POST /films/42/publish with body {"release_date": "2026-05-10"} returns 200 OK and the same envelope shape as GET /films/42:
{
"id": 42,
"title": "Rust en pratique",
"director": "Alice",
"year": 2025,
"release_date": "2026-05-10",
"published": true,
"_links": {
"self": { "href": "https://api.example.com/films/42" },
"collection": { "href": "https://api.example.com/films" }
}
}
Errors flow through the same FerraError taxonomy the built-in handlers use — a malformed JSON body returns 400 with type: "https://ferra.rs/errors/validation"; a missing row returns 404 with type: "https://ferra.rs/errors/not_found". Clients that handle one Ferra error already handle this one.
Extractor ordering: FromRequest vs FromRequestParts
Axum has two extractor traits. The distinction looks academic until you write your first multi-extractor handler — then it becomes the load-bearing rule.
| Trait | Reads from | Examples | Position |
|---|---|---|---|
FromRequestParts | request parts only (method, URI, headers, state) | State<T>, Path<T>, Query<T>, Parts, HeaderMap, FerraPath<T> | any position |
FromRequest | the whole request, including the body (consumed) | Json<T>, Bytes, String, Form<T>, FerraJson<T> | last argument only |
The rule: at most one body-consuming extractor (FromRequest) per handler, and it MUST be the last argument. Every other extractor is body-agnostic (FromRequestParts) and goes earlier.
This is a fundamental property of HTTP, not an Axum design quirk: a request body is a one-shot stream. Once you have read it, the bytes are gone.
The handler in the worked example follows the rule:
#![allow(unused)]
fn main() {
async fn publish_film(
State(state): State<FerraState<Film>>, // FromRequestParts
FerraPath(id): FerraPath<i32>, // FromRequestParts
parts: Parts, // FromRequestParts
FerraJson(body): FerraJson<PublishRequest>, // FromRequest — LAST
) -> Result<...> { ... }
}
Reorder FerraJson ahead of FerraPath and the build fails. Without #[axum::debug_handler] the compiler reports the violation through a deep trait-bound chain on Handler. With #[axum::debug_handler] the diagnostic points directly at the offending argument.
Multiple body extractors. Axum allows exactly one body-consuming extractor per handler. If you legitimately need to look at the raw bytes and a typed JSON deserialization, do both inside a single FromRequest-implementing wrapper, or read Bytes and parse it explicitly with serde_json::from_slice. There is no Json<A> + Json<B> shape.
Send + Sync + 'static: the bound-set cheat-sheet
Tokio runs handlers on a multi-threaded executor by default. Every value Axum carries through the handler must be sendable across threads and outlive the request. The compiler enforces this through Send + Sync + 'static-shaped bounds on the Handler trait — failures here look daunting at first read.
The cheat-sheet:
| Symptom | Likely cause | Fix |
|---|---|---|
the trait Send is not implemented for ... inside an async fn | a !Send value is held across an .await (Rc<_>, RefCell<_>, MutexGuard from std::sync::Mutex) | use Arc<_>, tokio::sync::Mutex, or restructure so the offending value is dropped before the .await |
the trait Sync is not implemented for ... on State<T> | the state type is not Sync (e.g., it owns a non-Sync cache) | wrap the inner shared state in Arc<RwLock<...>> or restructure to a channel |
argument requires that ... must outlive 'static | the handler captures a non-'static reference (&str from outside the closure, a borrow from the request) | own the data (String, Bytes), or extract it via an extractor |
Handler<_, _> not satisfied with a long, hard-to-read chain | usually one of the three above, hidden behind Handler’s blanket impl | add #[axum::debug_handler] and read the first targeted error it produces |
Notes on the framework’s own types:
FerraState<M>isClone + Send + Sync + 'staticby construction — it wrapsArc<PgRepository<M>>. Pass it through any number of handlers freely.FerraJson<T>/FerraPath<T>add no bounds beyond the innerT: DeserializeOwned + Send + 'staticthat Axum already requires.FerraErrorisSend + Sync + 'staticand implementsaxum::response::IntoResponse— returnResult<_, FerraError>from any handler signature.
If your handler async fn body holds a !Send value across an .await, the bound failure surfaces in the Handler impl, not in the body. Move the value into a synchronous block or drop it before the await.
Friendlier diagnostics with #[axum::debug_handler]
Axum’s Handler trait has a blanket impl that erases per-argument type information by the time the bound failure is reported. Without help, a single wrong extractor produces a multi-screen not satisfied chain.
#![allow(unused)]
fn main() {
#[axum::debug_handler]
async fn my_handler(/* … */) -> Result<…> { … }
}
The proc-macro re-checks the arguments one by one and emits a single targeted error per offending argument:
- Json must be the last extractor
- the trait FromRequestParts is not implemented for …
- Send is not satisfied because …
It is purely a development aid — it adds no runtime cost and emits no code in release builds. Keep it on every custom handler while iterating; remove it only if a transitive dep needs the un-decorated handler signature for some other proc-macro to see.
It does not (and cannot) flag every misuse — extractor ordering and bound failures are visible to it; subtle data-flow bugs and panic-on-unwrap paths are not.
FerraJson<T> and FerraPath<T> — RFC 7807 error continuity
Axum’s stock Json<T> and Path<T> produce text/plain 400 bodies on rejection. A consumer that handles Ferra’s application/problem+json taxonomy elsewhere on the API gets a mixed wire format on the rejection path — two parsers, two error shapes, two test surfaces.
FerraJson<T> and FerraPath<T> are thin newtypes around the stock extractors that intercept the rejection and remap it to FerraError::Validation:
#![allow(unused)]
fn main() {
pub struct FerraJson<T>(pub T); // wraps axum::Json<T>
pub struct FerraPath<T>(pub T); // wraps axum::extract::Path<T>
}
The output on a malformed body looks like every other Ferra 400:
{
"type": "https://ferra.rs/errors/validation",
"title": "Validation Error",
"status": 400,
"detail": "validation failed",
"errors": {
"errors": [
{ "field": "body", "code": "invalid_json",
"message": "expected `,` or `}` at line 1 column 14" }
]
}
}
Use the wrappers in every custom handler that takes JSON or path parameters. The single-token swap from Json<T> → FerraJson<T> and Path<T> → FerraPath<T> is the only difference.
What if I genuinely want stock Axum extraction? You can — they coexist. A handler that takes axum::Json<T> produces stock 400 text/plain on rejection; one that takes FerraJson<T> produces RFC 7807. Pick per handler.
The errors[*].code vocabulary at this release is {"invalid_json", "invalid_path_param"}. It widens when field-level validation lands, which is additive — existing consumer branches keep matching.
Composing with Foundry, ferra_router::<M>, or both
Three composition patterns, in order of how often you reach for them:
1. Append routes on top of Foundry::build()
#![allow(unused)]
fn main() {
let api = Foundry::new(conn.clone())
.mount::<Film>()
.with_docs()
.build();
let custom = Router::new()
.route("/films/{id}/publish", post(publish_film))
.with_state(FerraState::<Film>::new(conn));
let app: Router = api.merge(custom);
}
The framework’s Tower stack (CORS, body-limit, tracing, rate-limit on mutation routes) wraps every Ferra-mounted resource. Routes you append on the outer Router see the framework’s outer layers but not the per-resource inner layers — your action endpoint does NOT inherit the rate-limit layer that mounts on the CRUD mutation sub-router. Add your own where needed.
2. Append routes on top of a single-resource ferra_router::<M>
#![allow(unused)]
fn main() {
let state = FerraState::<Film>::new(conn);
let app: Router = ferra_router::<Film>(state.clone())
.route("/films/{id}/publish", post(publish_film))
.with_state(state);
}
Use this shape when you have exactly one resource and no Foundry chain — you skip docs but keep the per-resource Tower stack on the CRUD endpoints.
3. Hand-rolled Router with no Ferra CRUD at all
#![allow(unused)]
fn main() {
let app: Router = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/films/{id}/publish", post(publish_film))
.with_state(FerraState::<Film>::new(conn));
}
For apps that are only action routes, with no CRUD surface to derive. You retain FerraJson / FerraPath / FerraError and the response envelopes; you give up the auto-generated CRUD, the HAL _links builder pre-wired into the response, and the docs surface.
In all three patterns, consumer-added .layer(...) calls on the final Router wrap the framework stack from the outside. Place observability and authentication layers there; place per-route policy where the policy belongs (a sub-router scoped to that route).
Reaching the repository directly
FerraState<M> exposes pub repo: Arc<PgRepository<M>>. A custom handler that only needs the database adapter (no other state) can extract just that:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use ferra::PgRepository;
async fn count_films(
State(repo): State<Arc<PgRepository<Film>>>,
) -> Result<Json<u64>, FerraError> {
let page = repo.find_page(1, 1).await.map_err(FerraError::from)?;
Ok(Json(page.total))
}
}
FerraState<M>: FromRef<Arc<PgRepository<M>>> makes this swap zero-effort — Axum derives the inner extractor from the outer state. Use it when the handler genuinely does not need the rest of the state.
Cross-references (for the curious reader)
Pointers below trace decisions back to their arbitration. They are not required reading for correct use of the APIs above — every guarantee stated on this page stands on its own.
ferra-http.md— the typed-extraction reference, the full RFC 7807 taxonomy, and the Tower layer-ordering diagram. Cross-read it when wiring observability or auth around custom handlers.foundry.md— the router-assembly facade. The composition patterns above attach action routes to aFoundry::build()output.ferra-error-handling.md— the closedERROR_TYPESURI namespace and the consumer-side error-branching pattern.ferra-core.md§“Time vocabulary” —DateandDateTime(used in the worked example’srelease_datefield).
Error handling
Every Ferra-served HTTP response that is not a success carries an
RFC 7807 problem-details body (Content-Type: application/problem+json).
Every body’s type field is a URI from a closed set the framework
maintains as a single source of truth.
This page is the reference for that closed set: the URI table, the HTTP status / title / example body for each variant, and the consumer-side branching pattern.
The closed ERROR_TYPES URI set
The framework can emit exactly seven type URIs on the wire. Every
literal in framework source (and in any consumer crate) MUST appear
in this set; a CI gate (error_types_closed.sh) fails the build on
any drift.
| URI | HTTP status | Title | Source |
|---|---|---|---|
https://ferra.rs/errors/not_found | 404 Not Found | Resource Not Found | FerraError::NotFound |
https://ferra.rs/errors/validation | 400 Bad Request | Validation Error | FerraError::Validation |
https://ferra.rs/errors/conflict | 409 Conflict | Conflict | FerraError::Conflict |
https://ferra.rs/errors/internal | 500 Internal Server Error | Internal Server Error | FerraError::Internal |
https://ferra.rs/errors/payload_too_large | 413 Payload Too Large | Payload Too Large | request-body-limit middleware |
https://ferra.rs/errors/rate_limited | 429 Too Many Requests | Too Many Requests | rate-limit middleware |
https://ferra.rs/errors/unauthorized | 401 Unauthorized | Unauthorized | Foundry::with_docs_protected(...) |
Three of the seven URIs (payload_too_large, rate_limited,
unauthorized) are emitted by middleware layers, not through
FerraError. The framework’s full URI surface is the union of the
two paths; both are governed by the same ERROR_TYPES slice.
Worked example bodies
not_found — 404
Returned by every read / update / delete that resolves to zero matching rows.
{
"type": "https://ferra.rs/errors/not_found",
"title": "Resource Not Found",
"status": 404,
"detail": "films/c2bb1f10-72b8-486a-9f2b-c92e4a2cdf41 not found"
}
validation — 400
Returned when an inbound JSON body or a path parameter fails the
framework’s typed deserialisation, when an Id parameter fails to
parse, or when a future validator-derived field check rejects the
input.
{
"type": "https://ferra.rs/errors/validation",
"title": "Validation Error",
"status": 400,
"detail": "validation failed",
"errors": {
"title": ["must not be empty"],
"year": ["must be between 1888 and the current year"]
}
}
The errors object is present only on the validation variant. Its
shape is Map<String, Vec<String>> — a list of error messages per
field name.
conflict — 409
Returned when a write would violate a unique constraint (duplicate slug, primary-key collision, etc.).
{
"type": "https://ferra.rs/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "title 'Casablanca' is already in use"
}
internal — 500
Returned for any database / infrastructure failure not classifiable
as one of the typed variants above. detail is always the constant
literal "internal server error" — no internal path fragment, no
crate name, no underlying-library substring leaks (constitution §I).
{
"type": "https://ferra.rs/errors/internal",
"title": "Internal Server Error",
"status": 500,
"detail": "internal server error"
}
payload_too_large — 413
Emitted when an inbound request body exceeds the framework’s default 1 MiB cap (constitution §I — DoS protection).
{
"type": "https://ferra.rs/errors/payload_too_large",
"title": "Payload Too Large",
"status": 413,
"detail": "request body too large"
}
rate_limited — 429
Emitted by the framework’s default rate-limiter on mutation routes
(POST / PUT / DELETE). The response carries a Retry-After header
when the limiter can compute a sensible delay.
{
"type": "https://ferra.rs/errors/rate_limited",
"title": "Too Many Requests",
"status": 429,
"detail": "rate limit exceeded"
}
unauthorized — 401
Emitted by Foundry::with_docs_protected(verifier) when an
unauthenticated request reaches the docs surface. New in 0.5.0.
{
"type": "https://ferra.rs/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "authentication required"
}
Consumer-side branching
The recommended consumer pattern is to compare the wire type field
against the URI constants — never against the Display form of the
error. The URI is the wire contract; the message text is not.
#![allow(unused)]
fn main() {
match body["type"].as_str() {
Some("https://ferra.rs/errors/not_found") => /* 404 path */,
Some("https://ferra.rs/errors/validation") => /* 400 path */,
Some("https://ferra.rs/errors/conflict") => /* 409 path */,
Some("https://ferra.rs/errors/payload_too_large") => /* 413 path */,
Some("https://ferra.rs/errors/rate_limited") => /* 429 path; check Retry-After */,
Some("https://ferra.rs/errors/unauthorized") => /* 401 path */,
Some("https://ferra.rs/errors/internal") => /* 500 path; retry with backoff */,
_ => /* unreachable on a non-fabricated error */,
}
}
Server-side, when handling a FerraError value (for example, when
mapping to a custom log line or a metrics tag), use the typed
FerraError::error_type method:
#![allow(unused)]
fn main() {
use ferra::FerraError;
fn log_one(err: &FerraError) {
tracing::warn!(error_type = err.error_type(), "request failed");
}
}
FerraError::error_type returns one of the four URIs that map
through the typed enum (not_found, validation, conflict,
internal). The other three URIs reach the wire via middleware
layers and are never observed as FerraError values.
OpenAPI schema constraint
The OpenAPI spec the framework emits at /docs/openapi.json
constrains ProblemDetails.type to the closed ERROR_TYPES set as
an enum. SDK generators (orval, openapi-generator, kiota, …)
produce a typed union over the seven URIs rather than a free-form
string — consumers in TypeScript, Go, Python, etc. branch
exhaustively on the wire type value.
For an AI coding assistant generating client code: the type field
is an enum, not a free-form string. Synthesising a value outside the
seven URIs above fails OpenAPI schema validation against the spec.
Adding a new URI
The framework’s URI namespace is closed by design: a new variant requires a coordinated change across three surfaces.
- Add the URI literal to
ERROR_TYPESincrates/ferra-core/src/error.rs. - Extend the OpenAPI emitter so
ProblemDetails.typecarries the new value in itsenumconstraint (the emitter readsERROR_TYPESdirectly; this step is automatic once step 1 lands). - Publish at least one HTML anchor on
https://ferra.rs/errors/<variant>returning HTTP 200, so that AI agents and human readers reach a stable documentation page for the new variant.
The release-blocking CI gates verify (1) source-side closure
(error_types_closed.sh) and (3) network-side resolution (the URI
probe). New variants without all three gates green fail the build.
The closed-namespace contract is recorded in ADR-0026. The contract is not load-bearing for consumer correctness — the URI table above is self-contained.