Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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) and ferra-rs (the crate) are two different identifiers. The framework’s official domain is ferra.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 is ferra-rs (with a hyphen), because the dotless ferra name 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 no package = "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 Date and DateTime.
  • The typed error type FerraError.
  • The runtime state and router primitives FerraState, ferra_router, and the Foundry builder.
  • The typed-extraction wrappers FerraJson, FerraPath.
  • The response envelopes ItemResponse, CollectionResponse and the PaginationParams extractor.
  • 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:

URLBehaviour
GET /filmsPaginated list of films with HAL _links.
GET /films/{id}Single film + self-link.
POST /filmsCreate — validates, returns 201 Created.
PUT /films/{id}Update.
DELETE /films/{id}Delete.
GET /docs/openapi.jsonGenerated OpenAPI 3.1 spec — schema names Film, CreateFilmInput, UpdateFilmInput, FilmCollection, ProblemDetails.
GET /docsInteractive 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, the ProblemDetails shape, 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:

  1. Direct re-export — when an upstream crate already offers a stable, namespaced surface, Ferra simply re-exports it under ferra::*. You import ferra::sea_orm::DatabaseConnection, ferra::biscuit_quote::check, and you call them with the API the upstream crate documents. There is no Ferra-renamed equivalent.
  2. 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:

NamespaceWhat it is
ferra::FoundryThe router builder — Foundry::new(conn).mount::<M>().build().
ferra::IdThe framework’s identifier type used by #[id] fields.
ferra::RouterThe composed Axum router returned by Foundry::build().
ferra::FerraErrorThe framework-wide error enum surfaced in HTTP responses.
ferra::FerraLayerThe 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:

NamespaceRe-exportsDocumentation
ferra::sea_ormAll of sea_ormsea_orm docs.rs
ferra::biscuit_quoteAll of biscuit-quotebiscuit-quote docs.rs
ferra::tracingAll of tracingtracing 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 flagWhat it enablesWhen you want it
(default)Core framework + tracing + structured stderr JSON loggingAlways.
otelOpenTelemetry SDK + OTLP/HTTP exporter (locked to upstream-default transport)You run an OTel collector and want OTLP exports.
otel-grpcotel + gRPC transport (instead of HTTP/protobuf)Your collector requires gRPC and your ingress preserves HTTP/2.
otel-fipsotel + runtime-installed FIPS-compliant rustls CryptoProviderFIPS-compliant deployments.

Activating a feature

[dependencies]
ferra = { package = "ferra-rs", version = "0.x", features = ["otel"] }

The package = "ferra-rs" alias is required because the ferra name on crates.io is owned by an unrelated project; the framework’s facade is published as ferra-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

QuestionSource
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

MethodDefaultOverride
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-DD HTTP header on every response under the version (RFC 8594);
  • deprecated: true + x-sunset: YYYY-MM-DD on every operation in the emitted spec, plus an info.x-sunset mirror 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-built FerraState<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 /docs mount point. Useful when a reverse proxy serves Ferra under a sub-path.
  • Direct ferra-openapi use — for advanced docs UIs (Redoc, RapiDoc, Swagger UI), call ferra_openapi::build_openapi_for_models(...) directly and merge the resulting axum::Router into the value Foundry returns. See ferra-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-openapi free functions stand alone.
  • Apps with a non-Sea-ORM persistence layer. Foundry takes a sea_orm::DatabaseConnection; alternative backends compose through mount_with::<M>(state) using a FerraState<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-openapi crate scope, public-default docs deviation, Foundry → ferra-openapi edge.
  • ADR-0025 — Foundry typestate strategy (set-membership-via-trait + #[diagnostic::on_unimplemented]).
  • ADR-0026 — closed ERROR_TYPES URI namespace; the https://ferra.rs/errors/unauthorized URI used by .with_docs_protected(...).
  • ADR-0027 — x-sunset vendor extension + deprecated: true mirror.
  • ADR-0002 — Axum 0.8 framework choice and the Foundry::build() -> axum::Router single-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:

ModuleItems
metaFieldType, FieldMeta, ModelMeta
modelFerraModel
idId
errorIdParseError
timeDateTime, 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):

  • fields is non-empty.
  • Exactly one entry in fields has is_id = true and its name matches id_field.
  • All fields[i].name values 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:

BoundWhy it’s required
SerializeLater phases render responses.
DeserializeOwnedLater phases deserialise request bodies (no borrowed 'de).
Send + SyncModels participate in async handlers on a multi-threaded Tokio runtime.
'staticFerra 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").

ConstructorReturnsNotes
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()DateTimereads 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").

ConstructorReturnsNotes
Date::new(year: i16, month: i8, day: i8)Dateconst 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:

  1. #[derive(FerraModel)] rejects field declarations whose type path starts with chrono:: or time::. The diagnostic names ferra::DateTime / ferra::Date as the replacement and is spanned to the offending field.
  2. cargo deny check bans the chrono and time crates 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 ferra facade absorbs ferra-forge (the #[derive(FerraModel)] macro) and ferra-http (the CRUD handler layer). Consumers write use ferra::*; and no longer need a second direct dependency on ferra-forge — the 003 two-line pattern collapses to one line. See ferra-http.md for 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

MethodSignature summaryError outcomes
find_by_idasync fn find_by_id(&self, id: PkValue<M>) -> Result<M, FerraDbError>NotFound · Db
find_pageasync fn find_page(&self, page: u32, per_page: u32) -> Result<Page<M>, FerraDbError>Db (never NotFound)
insertasync fn insert(&self, data: M) -> Result<M, FerraDbError>Conflict · Db
updateasync fn update(&self, id: PkValue<M>, data: M) -> Result<M, FerraDbError>NotFound · Conflict · Db
deleteasync fn delete(&self, id: PkValue<M>) -> Result<(), FerraDbError>NotFound · Db
  • PkValue<M> is the Sea-ORM primary-key value type for M — for most entities this is a plain i32 / i64 / Uuid. The exact type alias is pub 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 through impl blocks automatically.
  • find_page is 1-indexed (page = 1 is the first page). A request past the end returns Ok(Page { items: vec![], total, .. }) — never NotFound.
  • update accepts (id, data) where data: M carries the new values. At 0.3.0 the caller is responsible for ensuring data’s primary key matches id; 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);
}
  • conn is a sea_orm::DatabaseConnection (re-exported from ferra-db so you do not need to add sea-orm as a direct dependency — ferra re-exports the name under ferra::DatabaseConnection).
  • The struct derives Clone unconditionally. Sea-ORM’s DatabaseConnection wraps its pool in an Arc, so repo.clone() is cheap — pass PgRepository<M> by value or by clone rather than wrapping in Arc<PgRepository<M>>.
  • The constructor performs no validation. Callers with a non-PostgreSQL DatabaseConnection compile but lose the SQLSTATE 23505Conflict mapping (those failures fall through to the Db(_) 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.

VariantWhenDisplay template0.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::DbErrwhatever the wrapped DbErr renders as500 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() → whatever db_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 page by framework convention. page = 0 is treated identically to page = 1 (the repository applies saturating_sub(1) internally to hand Sea-ORM its 0-indexed offset).
  • total: u64 fits any Postgres bigint in the non-negative half — the full-table row count observed by the count query.
  • items.len() <= per_page always. An over-scrolled request is Ok(Page { items: vec![], total, .. }), never NotFound.
  • Concurrent-insert drift. items.len() and total may disagree by ±1 under concurrent inserts — both queries run serially on the same connection (FR-015), not in parallel. Callers that need strict consistency use find_page_in_tx inside a SERIALIZABLE transaction.
  • Serialize, not Deserialize. Page<M>: serde::Serialize (inherited from M: FerraModel: Serialize). No Deserialize — 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 via Page::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

Item0.3.0 commitmentChange requires
FerraDbError variant listThree variants — NotFound, Conflict, DbADR (locked through 0.6.0 Welding)
FerraDbError::Display wordingStable templates per §Error taxonomyADR + user-guide update
FerraRepository<M> method setTen methods — five ambient + five _in_txADR to remove / rename; additions are additive
PgRepository::new(conn)(conn: DatabaseConnection) -> SelfADR
Page<M> field listFour named fields (items, total, page, per_page)#[non_exhaustive] allows additions; removals require ADR
DatabaseConnection / DatabaseTransaction re-exportsStable names, re-exported from Sea-ORMADR
Forbidden raw-SQL fragmentsStatement / execute_unprepared / raw_sql do not resolveADR

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 via FerraState::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 on Arc<PgRepository<M>>. Every request clones.
  • Implements axum::extract::FromRef<FerraState<M>> for Arc<PgRepository<M>>. Custom handlers that only need the repository write State(repo): State<Arc<PgRepository<M>>>; the Ferra handlers all take State(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:

HandlerMethodPathRequestSuccessError surfaces
get_item<M>GET/{resource}/:id200 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> body201 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}/:idJson<M> body200 OK + Json<ItemResponse<M>>400 · 404 · 409 · 413 · 429 · 500
delete_item<M>DELETE/{resource}/:id204 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:

VariantHTTP statustype URItitle
FerraError::NotFound { resource, id }404https://ferra.rs/errors/not_foundResource Not Found
FerraError::Validation(ValidationErrors)400https://ferra.rs/errors/validationValidation Error
FerraError::Conflict(String)409https://ferra.rs/errors/conflictConflict
FerraError::Internal(String)500https://ferra.rs/errors/internalInternal Server Error
(middleware)413https://ferra.rs/errors/payload_too_largePayload Too Large 1
(middleware)429https://ferra.rs/errors/rate_limitedToo 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.

#![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 carries Warning: 214 - "per_page clamped to {N} (max 100)" per RFC 7234 §5.5.4.
  • The response body’s per_page field reports the clamped value (so clients can detect the clamp without parsing the Warning header).

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-Proto if it matches the {http, https} allowlist; otherwise "http".
  • Host: the Host header 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:

LayerDefaultOverride path
tower_http::cors::CorsLayer::new()Deny all cross-originFerraLayer::cors(...) at 0.7.0 Pre-tempering
413 body-limit mapperRewrites stock text/plainapplication/problem+json with type: .../payload_too_large— (permanent)
tower_http::limit::RequestBodyLimitLayer1 MiBFerraLayer::body_limit(...) at 0.7.0
tower_http::trace::TraceLayerBody sampling OFF (never logs PII)Per-route opt-in at 0.12.0 Sheen
tower_governor::GovernorLayer on mutation endpointsper_second(2) + burst_size(5) + peer-socket-IP keyFull 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 via EnvFilter (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 inbound Retry-After header. Present only when the header parses as a non-negative integer; genuinely absent otherwise (not recorded as 0).

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_mapperRequestBodyLimitLayerTraceLayerDefaultBodyLimit::disable → handlers; with the mutation-only branch adding rate_limit_mapperGovernorLayer 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 postgressea-orm-cli migrate upcargo 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.

RequestResponse codetype URIResponsible mapper
Body > 1 MiB with Content-Length declared413https://ferra.rs/errors/payload_too_largebody_limit_mapper (FR-033a)
Body > 1 MiB without Content-Length (streaming / chunked)400https://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+json body — request body exceeds 1 MiB. Split the request or (at 0.7.0 Pre-tempering) raise the limit via FerraLayer::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 in Retry-After.
  • Warning: 214 - "per_page clamped ..." header — your per_page query parameter was out of [1, 100]. Use per_page ≤ 100 to silence it.
  • problem+json with type: ".../validation" and errors[0].code = "invalid_path_param" — the path segment after /{resource}/ is not a valid primary key for M (e.g. GET /films/abc when Film.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 the Debug form of FerraError::Internal; the HTTP body intentionally says nothing more.
  • CORS preflight failsCorsLayer::new() denies all cross-origin by default. At 0.4.0, CORS is not configurable; at 0.7.0 Pre-tempering FerraLayer::cors(...) ships the override surface.

  1. 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 one type URI 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 — full OpenAPI 3.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 ProblemDetails schema referenced by every error response.
  • Every operation carries an operationId and a tags array containing the resource name. Zero inline schemas in operation definitions; every body schema is a $ref into components/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

MethodEffect
.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:

ProjectionOpenAPI schema namePurpose
ReadFilmResponse shape for GET and the body of POST / PUT responses.
CreateCreateFilmInputRequest body of POST /films.
UpdateUpdateFilmInputRequest body of PUT /films/{id}.
CollectionFilmCollectionResponse 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:

MethodPathoperationId
GET/filmslistFilms
GET/films/{id}getFilm
POST/filmscreateFilm
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 typeOpenAPI
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-enable oas3-api-servers once the deployment carries a non-empty servers array).
  • 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 / orval smoke-tests are deferred rather than adopted — lives in the contributor research memo at docs/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_TYPES URI namespace and the ProblemDetails consumer- side branching pattern.
  • ferra-core §Time vocabulary — the typed DateTime / Date newtypes that emit format: date-time and format: date in 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:

  • DeriveEntityModel is the Sea-ORM half. It owns the #[sea_orm(...)] namespace, generates the Entity, Column, PrimaryKey, Relation, and ActiveModel types, and is required on every FerraModel struct.
  • FerraModel is the Ferra half. It reads #[sea_orm(primary_key)] and #[sea_orm(table_name)], plus the #[ferra(...)] namespace, and emits a static ModelMeta that every downstream Ferra crate reads.
  • Serialize + Deserialize are required by the FerraModel supertrait bound. ferra-forge does 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):

NamespaceOwnerUnknown-key policy
#[sea_orm(...)]Sea-ORMsilently ignored by ferra-forge
#[ferra(...)]Ferrahard 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

KeyValueSemantics
resourcenon-empty string literalOverride 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

Flagreadablewritableskip_api
(none)truetruefalse
#[ferra(read_only)]truefalsefalse
#[ferra(write_only)]falsetruefalse
#[ferra(skip)]falsefalsetrue

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

KeyPlanned phaseIntended use
hypermedia0.3.0 CastingControl HAL _links emission per field.
projection / projections0.4.0 RefiningTyped projections (ServerFields<M>).
exposure0.4.0 RefiningExposure::Strict / Exposure::Loose.
sortable / filterable / searchable0.4.0+Query DSL primitives.
computed0.5.0+Computed fields with a Default bound.
cascade0.6.0+Relation cascade rules.
auth0.8.5Per-model auth scopes.
rate_limit0.8.5+Tower rate-limit layer binding.
requiredNever 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, a help: line, and a note: line — because ferra-forge builds them as a single syn::Error on stable Rust. The help and note appear as indented continuation lines of the error body rather than as separate = help: / = note: sub-lines (that shape requires proc_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.

Degraded diagnostic under one specific shape. The Ferra-authored class-16 message above fires when the struct has zero #[sea_orm(...)] attributes (neither at container level nor on fields). If you forget DeriveEntityModel but do keep a #[sea_orm(primary_key)] on a field, rustc errors earlier than ferra-forge runs with cannot find attribute sea_orm in this scope — the sea_orm helper is unregistered without its owning derive. As a final safety net for any remaining edge case, the emitted code contains a `_assert_derives_entity_model<T:
:sea_orm::ModelTrait>()bound assertion; a failure there surfaces aserror[E0277]: the trait bound <YourModel>: sea_orm::ModelTrait is not satisfied. All three paths reduce to the same fix: add DeriveEntityModelto 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 as PUT.
  • Derived collections. GET /films/popular, GET /actors/{id}/films — collections whose membership is computed, not just SELECT * WHERE ....
  • Multi-resource projections. GET /dashboard returning a join of three resources at once.
  • Webhook receivers. POST /webhooks/stripe reading 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.

TraitReads fromExamplesPosition
FromRequestPartsrequest parts only (method, URI, headers, state)State<T>, Path<T>, Query<T>, Parts, HeaderMap, FerraPath<T>any position
FromRequestthe 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:

SymptomLikely causeFix
the trait Send is not implemented for ... inside an async fna !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 'staticthe 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 chainusually one of the three above, hidden behind Handler’s blanket impladd #[axum::debug_handler] and read the first targeted error it produces

Notes on the framework’s own types:

  • FerraState<M> is Clone + Send + Sync + 'static by construction — it wraps Arc<PgRepository<M>>. Pass it through any number of handlers freely.
  • FerraJson<T> / FerraPath<T> add no bounds beyond the inner T: DeserializeOwned + Send + 'static that Axum already requires.
  • FerraError is Send + Sync + 'static and implements axum::response::IntoResponse — return Result<_, 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 a Foundry::build() output.
  • ferra-error-handling.md — the closed ERROR_TYPES URI namespace and the consumer-side error-branching pattern.
  • ferra-core.md §“Time vocabulary” — Date and DateTime (used in the worked example’s release_date field).

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.

URIHTTP statusTitleSource
https://ferra.rs/errors/not_found404 Not FoundResource Not FoundFerraError::NotFound
https://ferra.rs/errors/validation400 Bad RequestValidation ErrorFerraError::Validation
https://ferra.rs/errors/conflict409 ConflictConflictFerraError::Conflict
https://ferra.rs/errors/internal500 Internal Server ErrorInternal Server ErrorFerraError::Internal
https://ferra.rs/errors/payload_too_large413 Payload Too LargePayload Too Largerequest-body-limit middleware
https://ferra.rs/errors/rate_limited429 Too Many RequestsToo Many Requestsrate-limit middleware
https://ferra.rs/errors/unauthorized401 UnauthorizedUnauthorizedFoundry::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_found404

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"
}

validation400

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.

conflict409

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"
}

internal500

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_large413

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_limited429

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"
}

unauthorized401

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.

  1. Add the URI literal to ERROR_TYPES in crates/ferra-core/src/error.rs.
  2. Extend the OpenAPI emitter so ProblemDetails.type carries the new value in its enum constraint (the emitter reads ERROR_TYPES directly; this step is automatic once step 1 lands).
  3. 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.