Introduction
Ferra is a Rust framework for building HTTP APIs from a single source of truth. You annotate a struct once — Ferra derives the routes, the SQL queries, the OpenAPI spec, the documentation UI, and the hypermedia automatically, at compile time. If the struct changes, everything downstream changes with it. If the generated code is inconsistent, the build fails.
The problem Ferra solves
Building a conventional REST API means describing the same data structure multiple times:
- once in a migration file (the database schema),
- once as a Rust struct (the model),
- once in each handler (request body, response type, SQL query),
- once in OpenAPI annotations (
#[utoipa::path(...)], response schemas), - once in validation rules.
Every copy can drift from the others — and none of these mismatches are caught at compile time. A field renamed in the struct but not in the OpenAPI annotation is a silent documentation lie. A validation rule missing from one handler is a silent security gap.
Ferra collapses all five copies into one struct:
use ferra::*;
#[derive(FerraModel)]
pub struct Film {
#[id]
pub id: Id,
pub title: String,
pub year: i32,
}
What you get from twelve lines
#[tokio::main]
async fn main() {
let conn = sea_orm::Database::connect(std::env::var("DATABASE_URL").unwrap()).await.unwrap();
let app = Foundry::new(conn).mount::<Film>().build();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Running cargo run gives you all of the following — no additional code required.
Five CRUD routes
| Method | Path | Description |
|---|---|---|
GET | /films | Paginated collection — ?page=1&per_page=20 |
GET | /films/:id | Single resource by ID |
POST | /films | Create — returns 201 + Location header |
PUT | /films/:id | Full update — returns 200 or 404 |
DELETE | /films/:id | Delete — returns 204 or 404 |
Pagination is first-class, not a bolt-on. The collection endpoint always returns
a page envelope with total, page, per_page, and the HAL _links for
next and prev.
Hypermedia _links in every response
Every item response includes a _links block following the HAL standard. Clients
and AI agents can navigate the API without prior knowledge of its URL structure:
{
"id": "01JVZ2RFJK3N7XQKE4M8P6SWTD",
"title": "Amélie",
"year": 2001,
"_links": {
"self": { "href": "https://api.example.com/films/01JVZ2RFJK3N7XQKE4M8P6SWTD" },
"collection": { "href": "https://api.example.com/films" }
}
}
The _links.self href is always absolute and always correct — built from the
Host header at request time, not hardcoded at build time.
OpenAPI 3.1 spec that cannot drift
The spec is served at /docs/openapi.json and the interactive Scalar UI at /docs.
Both are derived from the same struct the routes are derived from — they cannot
disagree with the running code. There are no annotations to forget, no
#[utoipa::path] attributes to keep in sync.
Add a field to Film and it appears in the spec, the Scalar UI, the request
validation, and the SQL insert — all at the next cargo build.
Typed SQL — injection-proof by construction
Every generated query is parameterized. There is no mechanism in the framework
to concatenate user input into a SQL string. SQL injection is structurally
excluded, not just discouraged — the framework has #![forbid(unsafe_code)] on
every crate.
RFC 7807 errors — typed and machine-readable
Every error response follows RFC 7807 problem details. The type field is a URI
from a closed set the framework maintains as a constant — not a free-form
string. SDK generators and AI agents can produce a typed branch for every
possible error because the set is expressed as a closed enum constraint in the
OpenAPI spec.
{
"type": "https://ferra.rs/errors/not_found",
"title": "Resource Not Found",
"status": 404,
"detail": "films/01JVZ2RFJK3N7XQKE4M8P6SWTD not found"
}
The complete error URI table is documented in Error Handling.
Security defaults, not security options
- Rate limiting on mutation routes (
POST,PUT,DELETE) — out of the box, no middleware to remember to add. - Body size cap — requests over 1 MiB are rejected before the body is read.
- CORS — restrictive by default. You opt in to origins, not out of open access.
#![forbid(unsafe_code)]— on every crate in the workspace, no exceptions.
Extending beyond the generated surface
The generated CRUD surface is a floor, not a ceiling. Ferra routes are standard Axum handlers — hand-written routes compose freely alongside them:
async fn top_rated(State(conn): State<DatabaseConnection>) -> Result<Json<Vec<Film>>, FerraError> {
let films = Film::find()
.filter(film::Column::Year.gt(2000))
.order_by_desc(film::Column::Rating)
.limit(10)
.all(&conn)
.await
.map_err(|e| FerraError::Internal(e.to_string()))?;
Ok(Json(films))
}
let app = Foundry::new(conn)
.mount::<Film>()
.route("/films/top-rated", get(top_rated))
.build();
Custom handlers reuse the same FerraError enum and emit the same RFC 7807
bodies — clients handle one error shape across the entire API surface.
What’s coming — Cast Iron to Hardened Steel
Ferra is in its Cast Iron phase (v0.5–v0.14): each version adds one major capability on the way to a stable 1.0.
v0.5 — Rolling · OpenAPI 3.1 + Scalar UI
The OpenAPI 3.1 spec and the Scalar interactive UI land here. The spec is a deterministic compiler output — if a field type changes, the spec changes. If the spec would be wrong, the code does not compile.
Also ships: ferra::DateTime and ferra::Date newtypes over jiff for
timestamp fields — serialized as RFC 3339, correctly handled in OpenAPI schemas.
v0.6 — Welding · Field validation
Declarative validation rules directly on struct fields, generated by the
proc-macro into garde validators. Rules are applied automatically on POST
and PUT before the query runs:
#[derive(FerraModel)]
pub struct Film {
#[id]
pub id: Id,
#[field(min_length = 1, max_length = 255)]
pub title: String,
#[field(min = 1888, max = 2100)]
pub year: i32,
#[field(email)]
pub contact: String,
#[field(url)]
pub poster: Option<String>,
}
Violations return 422 Unprocessable Content with a per-field errors map.
Every failing field is reported in a single response — clients never retry
to discover further errors.
{
"type": "https://ferra.rs/errors/validation_failed",
"title": "Validation Failed",
"status": 422,
"detail": "validation failed",
"errors": {
"title": ["must be at least 1 character"],
"year": ["must be at most 2100"],
"contact": ["must be a valid email address"]
}
}
v0.7 — Pre-tempering · Model behaviors
Common database patterns available as struct-level annotations:
#[derive(FerraModel)]
#[behavior(timestampable, soft_delete)]
pub struct Film {
#[id]
pub id: Id,
pub title: String,
}
timestampable—created_atandupdated_atare injected automatically.INSERTsets both;UPDATErefreshesupdated_at. No field declarations needed in the struct.soft_delete—DELETE /films/:idsetsdeleted_atinstead of removing the row. AllSELECTqueries automatically filterWHERE deleted_at IS NULL. A soft-deleted item is invisible to the API — it returns404, not a deleted record.
v0.8 — Forging · Authentication + authorization
Auth is deny-by-default. Forgetting to configure it is a startup panic, not a silent open endpoint.
You implement one trait to bridge your JWT claims to Ferra’s permission system:
impl FerraIdentity for MyClaims {
fn id(&self) -> &str { &self.sub }
fn has_role(&self, role: &str) -> bool { self.roles.contains(role) }
}
Then declare access rules on the model:
#[derive(FerraModel)]
#[authorize(
read = "IsAuthenticated",
write = "IsOwner | IsAdmin",
delete = "IsAdmin"
)]
pub struct Film {
#[id] pub id: Id,
pub title: String,
#[field(owner)]
pub owner_id: Id,
}
IsOwner is resolved automatically by comparing identity.id() to the
#[field(owner)] field — no custom handler code needed.
v0.8.5 extends this with external JWKS providers (Auth0, Keycloak, Cognito,
Google), API key authentication, and a FerraAuthProvider trait for bringing
any custom auth mechanism.
v0.9 — Forging III · Role-based field projections
Different callers see different fields of the same model, declared statically:
#[derive(FerraModel)]
#[project(read = ["id", "title", "year"], admin_read = ["id", "title", "year", "internal_notes"])]
#[authorize(read_as = { IsAdmin => "admin_read", _ => "read" })]
pub struct Film { /* ... */ }
The OpenAPI spec emits a oneOf for the two response shapes — SDK generators
produce typed variants, not a union of optional fields. The projection mismatch
is caught at compile time.
v0.10 — Assembly · Relations
IRI-based relations between models, declared in the struct:
#[derive(FerraModel)]
pub struct Film {
#[id] pub id: Id,
pub title: String,
#[field(belongs_to = Director)]
pub director_id: Id,
#[field(has_many = Review)]
pub reviews: Vec<Id>,
}
Foreign keys are stored as IDs. In API responses, they are rendered as IRI
references ("/directors/01HXYZ"). Write endpoints accept the same IRI format.
The OpenAPI spec documents the relation type correctly.
v0.12 — Sheen · Test client + observability
FerraTestClient for integration testing without a running server:
let client = FerraTestClient::new(state);
let res = client.create(Film { title: "Amélie".into(), year: 2001 }).await;
assert_eq!(res.status(), 201);
let res = client.with_auth(admin_claims).delete(id).await;
assert_eq!(res.status(), 204);
Also ships: built-in tracing spans per request and per DB query, and an
optional OpenTelemetry export via --features otel.
v0.14 — Deployment · Serverless
ferra = { package = "ferra-rs", version = "0.14", features = ["lambda"] }
// The same app code runs on Axum or Lambda — the entry point is the same.
ferra::run(app).await.unwrap();
FerraState::lazy() defers the DB connection to the first request for Lambda
cold-start optimization.
v1.0 — Hardened Steel · Stable API
Security audit, benchmarks, crates.io publication. The public API is
Semver-stable from this point. The invariant holds from the first beta: a
#[derive(FerraModel)] written at v1.0 compiles unchanged in v2, v3, v4, and
v5.
What Ferra is not
Not an ORM. Database schema management (migrations, table creation) is handled by Sea-ORM CLI and lives outside the framework. Ferra generates the queries; you manage the schema.
Not a general-purpose web framework. No template engines, no session handling, no WebSockets yet. Ferra is narrowly focused on REST + hypermedia + typed errors. When you need to go further, Axum handlers compose directly alongside Ferra routes.
Not stable yet. The public API follows Semver once 1.0 ships. Until then, minor versions may introduce breaking changes — each release documents them.
How to read this guide
- Getting Started — zero to running API in five minutes. Start here.
- Architecture — Ferra’s cohabitation principle: what the framework wraps, what it re-exports verbatim, and where the upstream crate docs are the canonical reference.
- Foundry — the router builder: configuration, middleware stack, mounting multiple models.
- Ferra DB —
PgRepository<M>, query methods, and how Sea-ORM sits underneath. - Ferra HTTP — extractors, the
FerraErrorenum, the built-in middleware stack. - Ferra OpenAPI — the generated spec, the Scalar UI, schema customization.
- Ferra Forge — the
#[derive(FerraModel)]proc-macro: field attributes, deferred keys, compile-time diagnostics. - Custom Handlers — Axum handlers alongside generated routes, sharing the same error taxonomy.
- Error Handling — the closed RFC 7807 error URI set, per-variant wire shape, client branching patterns.
Getting started with Ferra
Ferra is a Rust framework for building HTTP APIs from a single
#[model] source of truth. One model annotation gives you a CRUD
router, an OpenAPI 3.1 spec, a Swagger / Scalar UI, hypermedia
responses, and typed RFC 7807 errors — all derived at compile time.
This page walks you from cargo new to a running, documented API. If
you only have five minutes, read Add Ferra to your project, The
five-line CRUD, and Run it.
Important: the crate is published as ferra-rs
The ferra name on crates.io belongs to an unrelated project, so the
framework’s facade crate is published as ferra-rs. To keep your
Rust code idiomatic, use Cargo’s package alias — declare the
dependency under the local name ferra while pulling ferra-rs from
crates.io:
[dependencies]
ferra = { package = "ferra-rs", version = "0.5" }
The alias is invisible from this point forward. Every use ferra::...
import, every example in this guide, every doc snippet on docs.rs uses
the ferra name — Cargo handles the indirection.
ferra.rs(the domain) andferra-rs(the crate) are two different identifiers. The framework’s official domain isferra.rs— that’s where the documentation site lives and where the closed error-type namespace (https://ferra.rs/errors/<variant>) resolves. The crate name on crates.io isferra-rs(with a hyphen), because the dotlessferraname was already taken. Both are correct and authoritative; do not substitute one for the other.
If you see a plain
ferra = "..."line in older snippets (with nopackage = "ferra-rs"key), that’s pre-rename documentation. Replace it with the form above — the plain line resolves to a different, unrelated crate on crates.io.
Add Ferra to your project
A consumer crate built on Ferra needs one direct framework dependency plus the surrounding async / HTTP / serde stack:
[package]
name = "my-api"
version = "0.1.0"
edition = "2024"
[dependencies]
ferra = { package = "ferra-rs", version = "0.5" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
That is the entire framework dep surface. ferra-core, ferra-db,
ferra-forge, ferra-http, ferra-openapi, and the surrounding
sea-orm ORM are all reached through the ferra facade — they do
not appear as direct dependencies. Adding them directly fights the
facade’s cohabitation guarantees and is flagged by the framework’s
example dep-shape gate.
The one-line opener
Open every Ferra source file with a single glob over the prelude:
use ferra::prelude::*;
That brings into scope, in one line:
- The
#[derive(FerraModel)]derive — the model annotation. - The typed time newtypes
DateandDateTime. - The typed error type
FerraError. - The runtime state and router primitives
FerraState,ferra_router, and theFoundrybuilder. - The typed-extraction wrappers
FerraJson,FerraPath. - The response envelopes
ItemResponse,CollectionResponseand thePaginationParamsextractor. - Sea-ORM’s entity-derive prelude (
DeriveEntityModel,EntityTrait,ColumnTrait,Uuid, …) reached via the cohabitation re-export. serde::{Deserialize, Serialize}so model structs declare their derive set with no extra imports.
Reaching Sea-ORM through the facade
When you need Sea-ORM types directly — typically the
Database::connect(...) constructor, or the #[sea_orm(...)]
attribute namespace on a model struct — write ferra::sea_orm::...:
let conn = ferra::sea_orm::Database::connect(&database_url).await?;
The prelude glob already brings the sea_orm module name into scope,
so the standard sibling-derive pair #[derive(DeriveEntityModel, FerraModel)] works with no extra use sea_orm::entity::prelude::*;
line.
The five-line CRUD
The smallest meaningful Ferra application — one resource, full CRUD,
auto-generated OpenAPI spec, Scalar docs UI, and HAL hypermedia
responses — fits in this src/main.rs:
use ferra::prelude::*;
mod film {
use ferra::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq,
DeriveEntityModel, FerraModel,
Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub year: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub use film::Model as Film;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let conn = ferra::sea_orm::Database::connect(&database_url).await?;
let app = Foundry::new(conn).mount::<Film>().with_docs().build();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
What you get on cargo run:
| URL | Behaviour |
|---|---|
GET /films | Paginated list of films with HAL _links. |
GET /films/{id} | Single film + self-link. |
POST /films | Create — validates, returns 201 Created. |
PUT /films/{id} | Update. |
DELETE /films/{id} | Delete. |
GET /docs/openapi.json | Generated OpenAPI 3.1 spec — schema names Film, CreateFilmInput, UpdateFilmInput, FilmCollection, ProblemDetails. |
GET /docs | Interactive Scalar UI rendered from the spec. |
Errors come back as RFC 7807 application/problem+json with typed
type URIs from a closed namespace (https://ferra.rs/errors/<variant>).
The example in
examples/hello-ferra/in the framework repository matches this layout one-to-one and is the canonical reference. If you’re reading this through docs.rs, the same example is published under that name on the Ferra repository.
Run it
You need PostgreSQL reachable at DATABASE_URL. The fastest local
setup uses Podman or Docker:
podman run --rm -d --name pg \
-e POSTGRES_USER=ferra -e POSTGRES_PASSWORD=ferra \
-e POSTGRES_DB=my_api \
-p 5432:5432 \
postgres:16
export DATABASE_URL=postgres://ferra:ferra@localhost:5432/my_api
Apply your schema migrations (Sea-ORM CLI) and start the server:
cargo run
You should see listening on 0.0.0.0:3000 — open http://localhost:3000/docs
in a browser.
MSRV and toolchain
Ferra’s MSRV is Rust 1.88 (edition 2024, resolver v3). let
chains, native async fn in traits, and the rest of the modern Rust
feature surface this guide assumes are stable on 1.88+.
You don’t need a pinned development toolchain in your own project — any stable Rust at or above 1.88 works. The framework itself pins a specific toolchain in its repository for reproducible CI; that pin does not propagate to consumers.
Next steps
- Foundry — the router-assembly facade. Multi-resource
chains, API versioning, the
with_docs_protected(...)auth-gated docs variant. - Ferra Forge — the
#[derive(FerraModel)]derive grammar; the full attribute set on model structs and fields. - Ferra OpenAPI — what the generated spec contains, schema-name derivation, and how SDK generators consume it.
- Custom Handlers — drop down to your own Axum handler when Ferra’s defaults don’t fit a specific endpoint.
- Error Handling — the closed
https://ferra.rs/errors/<variant>namespace, theProblemDetailsshape, and consumer-side branching.
Ferra and the Rust ecosystem
Ferra is a thin layer over a small set of upstream Rust crates that already solve specific concerns well. This page explains what is Ferra-defined and what is re-exported, so that you know where to look for documentation and which surface to import when you build an application.
If you are looking for “five-minute zero-to-API,” see
Getting started. This page is about the long-term
shape of the dependency graph you inherit when you depend on ferra.
The principle: cohabitation, not encapsulation
Ferra does not wrap the upstream crates it integrates. Instead, it does one of two things:
- Direct re-export — when an upstream crate already offers a stable,
namespaced surface, Ferra simply re-exports it under
ferra::*. You importferra::sea_orm::DatabaseConnection,ferra::biscuit_quote::check, and you call them with the API the upstream crate documents. There is no Ferra-renamed equivalent. - Default-locking + opt-in feature — when an upstream crate is correct for some applications but not all, Ferra locks the upstream-default configuration explicitly behind a Cargo feature, so the dependency weight only lands when you opt in.
Ferra introduces a wrapper or a Ferra-defined trait only when one of three things is true:
- There is concrete behaviour to inject at the call site (logging, validation, span propagation).
- A typed Ferra newtype prevents misuse that the upstream type does not
catch (
Email,UserId). - A Ferra trait abstracts over multiple backends with build-time or
runtime swap-out (
FerraAuthProvider,RateLimitStore).
If none of those apply, the upstream surface is exposed verbatim and the upstream documentation is the canonical reference.
What lives under ferra::*
Ferra-defined surface
The ferra:: namespace defines a small, stable user-facing API:
| Namespace | What it is |
|---|---|
ferra::Foundry | The router builder — Foundry::new(conn).mount::<M>().build(). |
ferra::Id | The framework’s identifier type used by #[id] fields. |
ferra::Router | The composed Axum router returned by Foundry::build(). |
ferra::FerraError | The framework-wide error enum surfaced in HTTP responses. |
ferra::FerraLayer | The Tower layer composition entry point. |
#[derive(FerraModel)] | The proc-macro that turns a struct into a Ferra resource. |
#[ferra(...)] | The attribute namespace for Ferra-specific concerns. |
#[id], #[field(...)] | Field-level attributes for primary keys and projection rules. |
#[authorize(...)] | Operation-level authorization rules. |
The Ingot, ferra-forge, ferra-anvil thematic names belong to internal
documentation. You will not see them in your application code.
Re-exported upstream surface
The rest of ferra::* is re-exports. The biggest namespaces:
| Namespace | Re-exports | Documentation |
|---|---|---|
ferra::sea_orm | All of sea_orm | sea_orm docs.rs |
ferra::biscuit_quote | All of biscuit-quote | biscuit-quote docs.rs |
ferra::tracing | All of tracing | tracing docs.rs |
When you write:
use ferra::sea_orm::Database;
let conn = Database::connect("postgres://...").await?;
…you are calling sea_orm::Database::connect. The upstream documentation
applies verbatim. There is no Ferra-side wrapping.
Crate graph: modules pub(crate), items glob-exported at root
Every framework implementation crate (ferra-core, ferra-db,
ferra-forge, ferra-http, ferra-openapi) keeps its modules
pub(crate) and re-exports its public items at its crate root. The
facade then writes pub use ferra_<crate>::*; once per implementation
crate. A consequence is that two implementation crates can share a
module name (emit, routes, schema, …) without collisions: only
the items at each crate’s root reach the facade, and the items
themselves are uniquely named across the workspace.
This rule is enforced mechanically. The cargo clippy --workspace -- -D ambiguous_glob_reexports lint fires the moment two glob
re-exports in the facade name the same item from different sources;
CI gates the lint as a release-blocker (FR-024). A future regression
that exposes a sub-module from any implementation crate as pub
would be caught the next time the facade picks up a sibling-crate
item with the same name.
If you are reading the source code: prefer the absence of pub mod
on every implementation crate’s lib.rs. The single exception is
ferra::prelude on the facade itself — the prelude is intended to be
opened directly by consumers, so its module visibility is pub (FR-025).
Cargo feature surface
Ferra’s default dependency graph is intentionally lean. Heavy or audience-partial features are opt-in.
| Feature flag | What it enables | When you want it |
|---|---|---|
| (default) | Core framework + tracing + structured stderr JSON logging | Always. |
otel | OpenTelemetry SDK + OTLP/HTTP exporter (locked to upstream-default transport) | You run an OTel collector and want OTLP exports. |
otel-grpc | otel + gRPC transport (instead of HTTP/protobuf) | Your collector requires gRPC and your ingress preserves HTTP/2. |
otel-fips | otel + runtime-installed FIPS-compliant rustls CryptoProvider | FIPS-compliant deployments. |
Activating a feature
[dependencies]
ferra = { package = "ferra-rs", version = "0.x", features = ["otel"] }
The
package = "ferra-rs"alias is required because theferraname on crates.io is owned by an unrelated project; the framework’s facade is published asferra-rs. See Getting Started for full details.
Adding the feature pulls the locked OTel SDK pin into your build; without the feature, none of those crates compile. There is no runtime check — the choice is at compile time.
Where to look for documentation
| Question | Source |
|---|---|
| How do I declare a model? | This user guide → ferra-forge.md |
What does Foundry::new(...) do? | This user guide → ferra-http.md |
| How do I run a database query? | Upstream — sea_orm docs.rs (Ferra re-exports verbatim) |
What are valid Datalog rules in #[authorize]? | Upstream — biscuit documentation + the rule grammar reference |
| How do I configure tracing subscribers? | Upstream — tracing-subscriber docs.rs (Ferra re-exports verbatim) |
When you ask an AI coding assistant about a Ferra application, the assistant should reach for upstream documentation for re-exported surfaces and for this user guide for Ferra-defined surface. The distinction matters because upstream training corpora are large and Ferra-specific corpora are small — the cohabitation pattern is what makes a Ferra application legible to AI agents trained on the broader Rust ecosystem.
A worked example
A complete Ferra application demonstrating both halves of the principle:
// Cargo.toml:
// ferra = { package = "ferra-rs", version = "0.x", features = ["otel"] }
// Direct re-export (Half A): upstream `sea_orm` reached without renaming.
use ferra::sea_orm::Database;
// Direct re-export (Half A): upstream `biscuit-quote::check` for compile-time
// Datalog. A typo here is a `cargo build` error, not a runtime panic.
use ferra::biscuit_quote::check;
// Ferra-defined surface: the model derive, the router builder.
use ferra::{Foundry, FerraModel, Id};
#[derive(FerraModel)]
struct Film {
#[id]
id: Id,
title: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Upstream API (sea_orm): no Ferra-side wrapper.
let conn = Database::connect("postgres://localhost/films").await?;
// Ferra-defined: the router composition surface.
let app = Foundry::new(conn).mount::<Film>().build();
// Upstream API (axum / hyper): Ferra returns a standard Axum Router.
axum::serve(tokio::net::TcpListener::bind("0.0.0.0:3000").await?, app).await?;
Ok(())
}
The same application without the otel feature compiles to a smaller
binary; the OTel SDK is not in the dependency graph at all.
When you need a Ferra wrapper that does not yet exist
If you find yourself wanting a Ferra-side wrapper around an upstream type — a Ferra newtype, a Ferra trait abstracting multiple backends, or a Ferra macro that does something the upstream macro does not — the wrapper is a feature request worth filing. The maintainers will ask which of the three justifications applies (concrete behaviour to inject, type-narrowing, swap-out insurance) and ship the wrapper at that point. Until then, the upstream surface is the documented path.
Foundry
Foundry is Ferra’s router-assembly facade. One chain — beginning
with a Sea-ORM DatabaseConnection and ending with .build() —
declares every resource on your API, mounts the OpenAPI 3.1 docs
surface, applies version prefixes, and produces the final
axum::Router.
use ferra::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let conn = sea_orm::Database::connect(&std::env::var("DATABASE_URL")?).await?;
let app = Foundry::new(conn)
.mount::<Film>()
.mount::<Actor>()
.with_docs()
.build();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
The chain replaces the per-resource
conn.clone() + FerraState::new + ferra_router::<M> + Router::merge
boilerplate. Two compile-time invariants are enforced by the type
system: no duplicate .mount::<M>() calls, and no mixing of
un-versioned with versioned mounts on a single chain.
Foundry::build() -> axum::Router is the sole method whose return
type names an axum::* type. Every intermediate value is a
Foundry-defined builder. Consumer-added .layer() calls on the
returned router land outside the framework’s Tower stack.
The chain at a glance
| Method | Default | Override |
|---|---|---|
Foundry::new(conn) | begins the chain | — |
.mount::<M>() | mounts the model’s CRUD on /{resource} | .mount_with::<M>(state) (custom FerraState) |
.with_docs() | publishes /docs/openapi.json + /docs (Scalar UI) | .with_docs_at(path) |
.with_docs_protected(verifier) | gates docs behind the verifier (RFC 7807 401 on rejection) | .with_docs_protected_at(path, verifier) |
.api_version("v1")? | transitions to a versioned chain (paths under /v1/) | — |
.deprecated(date) | adds Sunset: header + deprecated: true + x-sunset to the spec | — |
.build() | finalizes the chain into axum::Router | — |
api_version returns Result<VersionedFoundryBuilder, ApiVersionError>
— prefixes that are empty, contain /, contain whitespace, or
contain characters unsafe in a URL path segment are rejected at
construction time.
The no-duplicate-mount rule
Mounting the same model twice is a compile error. The
typestate-tracked Set: NotMounted<M> bound forecloses route
shadowing at the type level (FR-012a, ADR-0025). The
#[diagnostic::on_unimplemented] message names the duplicated type:
error[E0277]: the model `Film` is already mounted on this Foundry chain
--> src/main.rs:5:33
|
5 | .mount::<Film>().mount::<Film>().build();
| ^^^^^^^^^^^^^ duplicate `.mount::<Film>()` —
| remove this call or rename the model
|
= note: see https://ferra.rs/guide/foundry#duplicate-mount for the supported pattern
If you genuinely need two different shapes for the same underlying type, define two distinct Rust types — Ferra’s metadata travels with the type, not with the resource name.
The no-mixed-mode rule
.api_version(...) is callable only on an empty chain. Mounting an
un-versioned model first and then calling .api_version("v1") is a
compile error (FR-014a, ADR-0025):
error[E0277]: `.api_version(...)` cannot be called after un-versioned `.mount(...)` calls
--> src/main.rs:6:14
|
6 | .api_version("v1")?
| ^^^^^^^^^^^^^^^^ this builder already has un-versioned mounts
|
= note: to ship mixed-mode (legacy + versioned) routes, build two
assemblies and merge them via `axum::Router::merge`. See
https://ferra.rs/guide/foundry#mixed-mode for a worked example.
Mixed-mode pattern: two assemblies merged
If you want a top-level /films and a /v1/films, build two
Foundry chains and merge them with axum::Router::merge:
use ferra::*;
let conn = /* … */;
let legacy = Foundry::new(conn.clone())
.mount::<Film>()
.with_docs() // serves /docs/openapi.json
.build();
let v1 = Foundry::new(conn)
.api_version("v1")?
.mount::<Film>()
.deprecated(date!(2027-01-05))
.with_docs() // serves /docs/v1/openapi.json
.build();
let app: axum::Router = legacy.merge(v1);
Each Foundry::build() produces its own axum::Router; merging
them is the supported way to expose multiple-version surfaces from
a single binary. The OpenAPI documents stay separate — one per
build.
Versioning + deprecation
.api_version("v1")? nests every mounted resource under /v1/,
prefixes every operationId with v1., and serves the spec at
/docs/v1/openapi.json.
.deprecated(date) activates two parallel signals:
- a
Sunset: YYYY-MM-DDHTTP header on every response under the version (RFC 8594); deprecated: true+x-sunset: YYYY-MM-DDon every operation in the emitted spec, plus aninfo.x-sunsetmirror at the document root (ADR-0027).
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”.
Post-sunset warn-event
When today_utc > sunset (strict greater-than — equality does not
trigger; the sunset day is the last day of the grace window), every
request under the deprecated version causes the framework to emit a
structured warning at tracing::Level::WARN. The event is layered
into the same middleware that emits the Sunset: response header, so
the two signals always travel together.
Verbatim target string (filterable via tracing-subscriber’s
EnvFilter):
ferra_http::sunset
Field set on the event:
| Field name | Type | Source |
|---|---|---|
version | string | the version prefix ("v1") |
sunset | string | the declared sunset date in YYYY-MM-DD form |
days_overdue | i64 | whole days from sunset to today_utc |
http.method | string | the inbound request method, upper-cased |
http.target | string | the inbound path?query (includes the version prefix) |
Message body (verbatim):
deprecated API version reached after declared sunset date
Once-per-process de-duplication: the framework fires the warning
exactly once per (version, sunset) pair within a single running
process. Two sequential requests on the same pair produce one
warning; two distinct pairs (e.g., v1/2026-01-01 and
v2/2026-06-01) each fire once independently. The dedup state is
process-local — a process restart re-creates the registry and the
first post-restart hit fires the warning again. This is the right
default for operational paging: a single human-readable signal per
incident, repeated only when a release rolls the world.
Operator filtering. An operator who wants only the post-sunset warnings (and nothing else from the framework) sets:
RUST_LOG=ferra_http::sunset=warn
An operator who wants every framework-emitted event at warn level:
RUST_LOG=ferra_http=warn
The convention follows the <snake-cased crate name>::<event segment>
pattern; the rate-limit event lives at ferra_http::rate_limit and
is configured the same way.
Metrics counter (gated by the observability Cargo feature).
When the observability feature is enabled:
[dependencies]
ferra = { package = "ferra-rs", version = "0.7", features = ["observability"] }
the framework also advances a metrics::counter! named
ferra.sunset.post_sunset_hits on every post-sunset request,
labelled with version and sunset. Unlike the warn-event, this
counter advances on every hit — its purpose is rate-of-arrival
visibility, not signal-per-incident paging. With the feature
off (default), the framework takes zero dependency on the
metrics crate; the constitutional 0.4.0 secure defaults are
byte-identical for non-opting-in consumers.
No-deny on post-sunset: the comparator is observability-only.
The framework continues to serve every request past sunset; if you
want denial after a date, add your own middleware. A first-class
deprecated_with_enforcement(...) opt-in is on the roadmap but not
shipped at 0.7.0.
Per-request budget: the comparator’s common path runs in well
under 200 ns on the CI reference runner and is informational-tier
under 100 ns. The framework’s first build-gating performance
contract enforces this: a regression past 200 ns fails the build
(scripts/bench/check_sunset_ceiling.sh against
cargo bench --bench sunset_comparator). The two-tier discipline is
documented in ADR-0039 as the pattern for future hot-path additions.
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":
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.
Transport-layer overrides
Reader contract. This section stands on its own. A reader with only this page plus standard Rust knowledge produces a correct per-app or per-model override on the first attempt. Every knob, default, fallback, and validation rule is spelled out below — no ADR and no framework source is required.
The framework’s secure Tower stack (CORS deny-by-default, 1 MiB body
cap, { per_second: 2, burst_size: 5 } mutation rate-limit, tracing
with body sampling off) is the right default for the vast majority of
APIs. Some shapes — file uploads, high-volume mutation endpoints,
sub-path deployments behind a reverse proxy — need narrow overrides.
Use the FerraLayer builder for those.
FerraLayer is a single value carrying overrides for five knobs:
| Knob | Default | Override surface |
|---|---|---|
| CORS | CorsLayer::new() (deny by default) | .cors(cors_layer) |
| Body cap | 1 MiB | .body_limit(bytes) |
| Rate-limit (mutations only) | RateLimitRule::new(2, 5) (2/sec, burst 5) | .rate_limit(RateLimitRule::new(per_second, burst_size)) |
| Tracing | TraceLayer::new_for_http() with body sampling off | .trace_layer(layer) |
Base path for _links | none | .base_path("/api") |
A FerraLayer whose every knob is left at its None default
(FerraLayer::new().build()) reproduces the framework stack
byte-for-byte — the override surface is silent when unused.
RateLimitRuleconstruction. Always use theRateLimitRule::new(per_second, burst_size)constructor. The struct carries#[non_exhaustive]to accommodate future additive knobs (e.g., per-key extractor selection, per-method overrides) without breaking downstream construction sites; direct struct-literal construction (RateLimitRule { per_second: 2, burst_size: 5 }) is rejected by the compiler from outside the crate.
Per-app vs per-model — REPLACE not merge
FerraLayer applies at two scopes:
- Per-application default —
Foundry::new(conn).layer(global)— every model mounted afterwards inheritsglobal’s settings. - Per-model override —
Foundry::new(conn).mount_with_layer::<M>(M_layer)— onlyM’s routes useM_layer.
The per-model layer REPLACES (does NOT merge with) the per-app
layer for the affected model. Critically, a knob left at None on
the per-model layer falls back to the framework default — NOT to
the per-app setting. The intent is per-model auditability in
isolation: every mount_with_layer::<M>(...) site is fully
inspectable on its own, without chasing a per-app default the reader
may not have on screen, and without the security surprise of inheriting
a permissive per-app override the per-model author did not intend.
Security posture. The REPLACE-not-merge rule means that an unset knob on a per-model override cannot silently inherit a permissive per-app value. If the per-app layer raises the body cap to 5 MiB and the per-model layer for
Documentdoes NOT explicitly call.body_limit(...),Documentroutes use the 1 MiB framework default, not the 5 MiB per-app value. The rule is auditable bygrep: every override is explicit at its declaration site.
let global = FerraLayer::new()
.body_limit(5 * 1024 * 1024) // 5 MiB
.build();
let document = FerraLayer::new()
.cors(permissive_cors)
.build();
let app = Foundry::new(conn)
.layer(global) // 5 MiB on every model …
.mount_with_layer::<Document>(document) // … EXCEPT Document
.mount::<Film>()
.build();
Observable behaviour:
Filmroutes: body limit 5 MiB (fromglobal), CORS deny-by-default (globaldid not set.cors(...)).Documentroutes: body limit 1 MiB (the framework default —documentdid not set.body_limit(...), so the per-model layer does NOT inheritglobal’s 5 MiB), permissive CORS (the per-model setting).
Body-limit override + verbatim 413 detail
When the configured cap is exceeded, the framework’s standard 413
application/problem+json response carries the verbatim configured
limit in the detail field:
{
"type": "https://ferra.rs/errors/payload_too_large",
"title": "Payload Too Large",
"status": 413,
"detail": "request body exceeds 5242880 bytes"
}
Operators tracing 413s in logs can map the byte cap in the response
back to the originating FerraLayer::body_limit(...) call site
without consulting source code.
Sub-path mount — base_path
A common deployment shape: serve Ferra at /api/ behind a reverse
proxy that strips the prefix before forwarding. Configure the
framework’s hypermedia links to include the publicly visible prefix
via .base_path("/api"):
Static prefix only.
base_pathis a static&'static strdeclared at framework-assembly time. Ferra deliberately does NOT consume theX-Forwarded-Prefixrequest header (the dynamic-prefix convention used by FastAPI’sroot_path_in_servers+ Traefik). Reasons: (a) request-time prefix discovery breaks_linksURL stability — two requests through different proxies could yield different_links.self.hreffor the same row; (b) the closed,const-evaluable contract enables the validation grammar below to fire at compile time, not at proc-macro time. If your deployment needs multiple prefixes simultaneously, declare a separateFerraLayerper-deployment-target.
let app = Foundry::new(conn)
.layer(FerraLayer::new().base_path("/api").build())
.mount::<Film>()
.build();
Every _links.*.href (self, collection, restore, named-operation
links) now carries the /api prefix:
_links.self.href→https://api.example.com/api/films/42_links.collection.href→https://api.example.com/api/films
.base_path is validated at proc-macro-equivalent time: the prefix
MUST start with / and MUST NOT end with /. Malformed
literals produce a compile error at the call site when the value
is reachable in const context:
.base_path("api") // compile error — no leading `/`
.base_path("/api/") // compile error — trailing `/`
.base_path("") // compile error — empty
The base_path declaration applies only to hypermedia link
generation. The framework does not assume the prefix is present in
the inbound request URI — if the reverse proxy strips /api before
forwarding, no further configuration is needed. To mount under the
prefix internally as well, compose with axum::Router::nest("/api", foundry.build()).
Rate-limit override
use ferra::{FerraLayer, RateLimitRule};
let layer = FerraLayer::new()
.rate_limit(RateLimitRule::new(10, 20)) // 10/sec, burst 20
.build();
The constructor signature is RateLimitRule::new(per_second: u32, burst_size: u32) -> Self. burst_size is the bucket depth (peak
concurrent mutations); per_second is the steady-state refill rate.
At RateLimitRule::new(10, 20) a client may issue 20 mutations in
burst, then 10/second steady-state. The key extractor at 0.7.0 is
fixed to peer socket IP; richer extractor selection (proxy-aware
keying, custom keying) composes on top in a later release. The
#[non_exhaustive] attribute on RateLimitRule ensures additive
knobs ship without breaking downstream construction sites — always
use the ::new(...) constructor rather than struct-literal
construction.
Custom CORS + tracing layers
.cors(cors_layer) and .trace_layer(trace_layer) accept the
underlying [tower_http] types verbatim — the framework does not
wrap them. To configure CORS for a specific allowed origin:
use tower_http::cors::{AllowOrigin, CorsLayer};
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(["https://app.example.com".parse().unwrap()]))
.allow_methods(tower_http::cors::AllowMethods::list([
axum::http::Method::GET,
axum::http::Method::POST,
]));
let layer = FerraLayer::new().cors(cors).build();
The accepted TraceLayer type at 0.7.0 is the default
TraceLayer<HttpMakeClassifier> shape returned by
TraceLayer::new_for_http(). Consumers who need a custom classifier
or per-handler customisation that changes the layer’s generic
parameters compose on top of Foundry::build() with a regular
.layer(...) call.
Composition order — preserved
The constitutional Tower stack order is preserved regardless of which knobs are overridden:
CorsLayer (outermost)
body_limit_mapper (413 → problem+json with verbatim detail)
RequestBodyLimitLayer({bytes})
TraceLayer
DefaultBodyLimit::disable
[on mutation sub-router only:]
rate_limit_mapper (429 → problem+json)
GovernorLayer(per_second, burst_size)
<handlers>
[on read sub-router:]
<handlers>
FerraLayer does not reorder, add, or remove layers — only swaps
the parameters / values that flow into the existing slots.
Typestate guard — .layer(...) before .mount(...)
.layer(...) carries the same Set: IsEmpty typestate bound as
.api_version(...). Calling it after a model has been mounted
produces a compile error pointing at the merge-two-assemblies
pattern (the same diagnostic shape documented in §“The
no-mixed-mode rule” above).
.mount_with_layer::<M>(layer) follows the same no-duplicate-mount
rule as .mount::<M>().
Escape hatches
mount_with::<M>(state)— supply a caller-builtFerraState<M>rather than letting Foundry derive one from the connection. Useful for shared state across builders, instrumentation, or test fixtures where the repository needs to be substituted.with_docs_at(path)/with_docs_protected_at(path, verifier)— override the/docsmount point. Useful when a reverse proxy serves Ferra under a sub-path.- Direct
ferra-openapiuse — for advanced docs UIs (Redoc, RapiDoc, Swagger UI), callferra_openapi::build_openapi_for_models(...)directly and merge the resultingaxum::Routerinto the value Foundry returns. Seeferra-openapi.md§“Alternative UIs”.
When Foundry is the wrong tool
- Apps where every resource needs a hand-tuned middleware stack
(e.g. per-resource auth schemes, per-resource body-limit overrides).
Use
ferra_router::<M>(state)per resource and compose by hand. - Apps that mount the docs surface from a different process than
the API itself. The
ferra-openapifree functions stand alone. - Apps with a non-Sea-ORM persistence layer. Foundry takes a
sea_orm::DatabaseConnection; alternative backends compose throughmount_with::<M>(state)using aFerraState<M>bound to the alternative.
For the bare CRUD case — the framework’s zero-to-documented-API
target — Foundry::new(conn).mount::<M>().with_docs().build() is
the right shape.
Cross-references (for the curious reader)
- ADR-0024 —
ferra-openapicrate scope, public-default docs deviation,Foundry → ferra-openapiedge. - ADR-0025 —
Foundrytypestate strategy (set-membership-via-trait +#[diagnostic::on_unimplemented]). - ADR-0026 — closed
ERROR_TYPESURI namespace; thehttps://ferra.rs/errors/unauthorizedURI used by.with_docs_protected(...). - ADR-0027 —
x-sunsetvendor extension +deprecated: truemirror. - ADR-0002 — Axum 0.8 framework choice and the
Foundry::build() -> axum::Routersingle-leak rule.
These are pointers for readers tracing decisions back to their arbitration; they are not required reading for correct use of the APIs above.
ferra-core
ferra-core is the zero-I/O contract layer that every other Ferra
crate depends on. It exports the following items across five modules:
| Module | Items |
|---|---|
meta | FieldType, FieldMeta, ModelMeta |
model | FerraModel |
id | Id |
error | IdParseError |
time | DateTime, Date, plus the date! macro |
Nothing in this crate allocates, performs I/O, or talks to a
database. Its external runtime dependencies are serde, uuid, and
jiff (activated in 0.5.0 Rolling for the typed time vocabulary).
A fourth dep (utoipa) is feature-gated behind openapi and stays
off in the default build — ferra-openapi flips the feature on
transitively when it depends on this crate.
Model metadata (FieldMeta / FieldType / ModelMeta)
The three types in ferra_core::meta describe a resource precisely
enough for downstream crates to derive HTTP routes, SQL, OpenAPI, and
hypermedia links from a single source of truth.
FieldType
A tag identifying the Rust type of a field.
pub enum FieldType {
String, I32, I64, F64, Bool, Uuid,
OptionString, OptionI32, OptionI64, OptionF64, OptionBool,
OptionUuid,
}
Option variants are spelled out so every field’s nullability is
visible in the IR without indirection. OptionUuid joins the table
in 0.6.0 Welding (User Story 4 — FR-014/FR-024); see the recognised
field types table in ferra-forge.md for the
end-user surface.
FieldType is #[non_exhaustive]. Pattern matches on it MUST
include a _ => arm to stay forward-compatible:
use ferra_core::meta::FieldType;
fn is_optional(ty: FieldType) -> bool {
matches!(
ty,
FieldType::OptionString
| FieldType::OptionI32
| FieldType::OptionI64
| FieldType::OptionF64
| FieldType::OptionBool
| FieldType::OptionUuid,
)
}
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.
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:
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:
pub struct ModelMeta {
pub resource_name: &'static str, // external name, e.g. "films"
pub table_name: &'static str, // SQL table name
pub primary_key_fields: &'static [&'static str], // PK column names, declaration order
pub fields: &'static [FieldMeta],
}
primary_key_fields carries the primary-key column names in
declaration order. Single-PK models hold a one-element slice
(e.g. &["id"]) — the single-PK fast path; downstream
consumers read primary_key_fields[0] and the route shape stays
/{resource}/{<pk>}. Composite-PK models hold a multi-element
slice (e.g. &["tenant_id", "document_id"]) and the route shape
becomes /{resource}/{<pk1>}/{<pk2>}/…. The composite-PK lift
landed in 0.6.0 Welding (User Story 3).
Invariants (hand-authoring discipline in 0.1.0; enforced by
compile_error! from 0.2.0 Smelting onward):
fieldsis non-empty.primary_key_fieldshas length ≥ 1 (theN = 0case is rejected byferra-forgeat proc-macro time).- Each entry of
primary_key_fieldsmatches somefields[i].namewhoseis_id = true. - All
fields[i].namevalues are distinct.
Hand-authoring in 0.1.0
In 0.1.0 every ModelMeta is hand-written. Starting in 0.2.0, the
#[model] proc-macro (shipping in ferra-forge) emits the same
types automatically.
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),
];
// Single-PK fast path: a one-element slice of PK field names in
// declaration order. Composite-PK models pass a multi-element slice
// (e.g. `&["tenant_id", "document_id"]`).
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.
pub trait FerraModel:
serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static
{
fn meta() -> &'static ModelMeta;
}
The supertrait set is part of the 0.1.0 contract. Every bound is load-bearing:
| Bound | Why it’s required |
|---|---|
Serialize | Later phases render responses. |
DeserializeOwned | Later phases deserialise request bodies (no borrowed 'de). |
Send + Sync | Models participate in async handlers on a multi-threaded Tokio runtime. |
'static | Ferra models are owned values — never borrowed from request state. |
A hand-written implementation is four lines:
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
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.
use std::str::FromStr;
use ferra_core::id::Id;
// Construction.
let id = Id::new();
// Text round-trip.
let text = id.to_string();
let parsed = Id::from_str(&text).expect("fresh ids always parse");
assert_eq!(id, parsed);
// JSON round-trip.
let json = serde_json::to_string(&id).unwrap();
let back: Id = serde_json::from_str(&json).unwrap();
assert_eq!(id, back);
// Equality and the nil sentinel.
let nil = Id::nil();
assert_eq!(nil, Id::nil());
assert_ne!(nil, id);
// Parsing failure is typed, never a panic.
let err = Id::from_str("not a uuid").unwrap_err();
assert_eq!(err.to_string(), "failed to parse Id as a UUID");
// Id is Copy — passing by value is free.
fn takes_id(_x: Id) {}
takes_id(id);
takes_id(id); // still valid, the previous call didn't move it.
Why a newtype
Wrapping uuid::Uuid rather than exposing it directly lets Ferra
swap the inner representation later (for example, to UUID v7
time-ordered identifiers) without churning every caller. The 16-byte
payload is Copy; passing Id through the HTTP pipeline costs
nothing at runtime.
Id is not PartialOrd / Ord — v4 UUIDs carry no meaningful
ordering semantics, and adding those traits later is non-breaking.
IdParseError
<Id as FromStr>::Err is IdParseError, a hand-written unit struct
with a Display impl and a std::error::Error impl. Its inner is
pub(crate), so you can only obtain an IdParseError by a parse
failure — never by direct construction.
In later phases, IdParseError may grow variants distinguishing
“wrong length” from “invalid character”. Because external code only
ever matches it as an opaque error, adding variants will be a
non-breaking change.
Time vocabulary (DateTime / Date / date!)
Ferra exposes one typed time vocabulary across the framework. Two
#[repr(transparent)] newtypes — ferra::DateTime (timestamp) and
ferra::Date (calendar date) — wrap the underlying time library so
the framework can swap that library in a future release without a
consumer-facing signature change. Reach the types through
ferra::DateTime / ferra::Date (or via ferra::prelude::*); never
import the underlying time library directly.
DateTime — timestamp
DateTime represents a precise instant anchored to UTC. Wire format:
RFC 3339 timestamp text ("2023-11-14T22:13:20Z").
| Constructor | Returns | Notes |
|---|---|---|
DateTime::from_second(seconds: i64) | Result<DateTime, jiff::Error> | seconds since the Unix epoch |
DateTime::from_millisecond(milliseconds: i64) | Result<DateTime, jiff::Error> | milliseconds since the Unix epoch |
DateTime::from_microsecond(microseconds: i64) | Result<DateTime, jiff::Error> | microseconds since the Unix epoch |
DateTime::from_nanosecond(nanoseconds: i128) | Result<DateTime, jiff::Error> | nanoseconds since the Unix epoch |
DateTime::from_duration(duration: jiff::SignedDuration) | Result<DateTime, jiff::Error> | epoch-relative duration |
DateTime::now() | DateTime | reads the system clock; not const |
The constructor names mirror the underlying time library verbatim;
they are not invented. Names that do not exist on DateTime and
will not be added: from_unix_timestamp, from_unix_nanos.
Each from_* constructor returns a Result and propagates an
out-of-range argument as the underlying time library’s error type.
The methods are not const fn — call sites use the standard ?
operator, not const-context evaluation.
use ferra::DateTime;
fn epoch_seconds(seconds: i64) -> Result<DateTime, jiff::Error> {
DateTime::from_second(seconds)
}
let now = DateTime::now();
let later = DateTime::from_second(1_700_000_000)?;
Date — calendar date
Date represents a time-zone-free calendar date. Wire format: RFC
3339 calendar date text ("YYYY-MM-DD").
| Constructor | Returns | Notes |
|---|---|---|
Date::new(year: i16, month: i8, day: i8) | Date | const fn; out-of-range triple is a const-eval error |
Date::new is const fn — an out-of-range (year, month, day)
triple is rejected at cargo build time, not at runtime:
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:
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):
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):
use ferra::{Foundry, date};
let app = Foundry::new(conn)
.api_version("v1")?
.mount::<Film>()
.deprecated(date!(2027-01-01))
.build();
Banned imports
The framework forbids direct use of legacy time libraries. Two gates enforce this:
#[derive(FerraModel)]rejects field declarations whose type path starts withchrono::ortime::. The diagnostic namesferra::DateTime/ferra::Dateas the replacement and is spanned to the offending field.cargo deny checkbans thechronoandtimecrates from the dependency graph except as transitive deps of the Sea-ORM ecosystem (sea-orm,sea-query,sqlx-core,sqlx-postgres). A Ferra crate or downstream consumer that adds either crate directly fails CI.
Sample diagnostic for a chrono-typed field:
error: field type uses the legacy `chrono` time crate; Ferra exposes
typed time values through `ferra::DateTime` for timestamps
and `ferra::Date` for calendar dates
help: replace with `ferra::DateTime` (or `ferra::Date` for
calendar-only values); the wire format is RFC 3339 in
both cases
note: docs/user-guide/ferra-core.md §"Time vocabulary"
Why newtypes
The two newtypes are #[repr(transparent)] over the underlying
library’s value types. The layout is identical to the inner type, so
a future release that replaces the underlying library does so at the
ferra-core::time module boundary alone — consumer-facing signatures
stay stable. The inner field is pub(crate); consumers reach the
value only through ferra::DateTime / ferra::Date.
The OpenAPI schema emission is encapsulated in the same module:
<DateTime as utoipa::ToSchema> and <Date as utoipa::ToSchema>
emit {type: "string", format: "date-time"} and
{type: "string", format: "date"} respectively. Consumers and
downstream crates never write the schema literal — the typed-newtype
contract preserves the framework’s “single source of truth” rule.
Public surface
RequestContext
RequestContext (canonical path: ferra_core::request_context::RequestContext)
is the request-scoped handle the framework threads through the
computed-field async pipeline. At 0.6.5 Chasing the type is an empty
marker: the framework constructs it via RequestContext::empty() and
passes it as the ctx parameter to FerraComputed::compute_async and
FerraComputed::compute_async_batch. The shape is deliberate — the
parameter pins the trait-method signatures so that 0.7.x can widen
RequestContext with the authentication-principal handle, the
database-pool handle, and the HTTP-request metadata without touching
any existing impl FerraComputed block. The #[non_exhaustive]
posture on the struct makes that widening non-breaking by construction.
For the consumer-facing usage of the surrounding pipeline, see
ferra-forge.md § Computed fields.
ferra-db — the repository layer
The 0.3.0 Casting deliverable. Turns a #[derive(FerraModel, DeriveEntityModel)] entity into a CRUD-capable repository against a live PostgreSQL, with zero hand-written SQL.
This page is the authoritative user-guide for ferra-db. A reader with only this page plus standard Rust knowledge can produce a compiling CRUD round-trip on the first attempt — no ADR, no constitution, and no framework source is required.
At 0.4.0 Refining the
ferrafacade absorbsferra-forge(the#[derive(FerraModel)]macro) andferra-http(the CRUD handler layer). Consumers writeuse ferra::*;and no longer need a second direct dependency onferra-forge— the 003 two-line pattern collapses to one line. Seeferra-http.mdfor the HTTP surface that composes on top of this page’s repository layer.
Zero-to-CRUD in four lines
use ferra::{DatabaseConnection, FerraRepository, PgRepository};
let conn: DatabaseConnection = sea_orm::Database::connect(&database_url).await?;
let repo = PgRepository::<Film>::new(conn);
let film = repo.find_by_id(42).await?;
(Film is the canonical sibling-derive entity shape from ferra-forge — see the worked example below. database_url is read from the DATABASE_URL environment variable in the usual way.)
The FerraRepository<M> surface
FerraRepository<M> is an async trait with ten methods, split into two symmetric groups of five. The ambient-connection quintet runs against the pool supplied at construction time; the _in_tx quintet takes a caller-owned transaction handle.
Ambient-connection methods
| Method | Signature summary | Error outcomes |
|---|---|---|
find_by_id | async fn find_by_id(&self, id: PkValue<M>) -> Result<M, FerraDbError> | NotFound · Db |
find_page | async fn find_page(&self, page: u32, per_page: u32) -> Result<Page<M>, FerraDbError> | Db (never NotFound) |
insert | async fn insert(&self, data: M) -> Result<M, FerraDbError> | Conflict · Db |
update | async fn update(&self, id: PkValue<M>, data: M) -> Result<M, FerraDbError> | NotFound · Conflict · Db |
delete | async fn delete(&self, id: PkValue<M>) -> Result<(), FerraDbError> | NotFound · Db |
PkValue<M>is the Sea-ORM primary-key value type forM— for most entities this is a plaini32/i64/Uuid. The exact type alias ispub type PkValue<M> = <<<M as sea_orm::ModelTrait>::Entity as sea_orm::EntityTrait>::PrimaryKey as sea_orm::PrimaryKeyTrait>::ValueType;— consumers rarely spell it out; it propagates throughimplblocks automatically.- For composite primary keys (two or more
#[sea_orm(primary_key)]markers — seeferra-forge.md§ Composite primary keys), Sea-ORM resolvesPkValue<M>to a tuple matching the declaration order:(K1, K2)for two-key composites,(K1, K2, K3)for three-key, etc. The repository call site is the typed tuple lookuprepo.find_by_id((tenant_id, document_id)). Single-PK consumers see no change. find_pageis 1-indexed (page = 1is the first page). A request past the end returnsOk(Page { items: vec![], total, .. })— neverNotFound.updateaccepts(id, data)wheredata: Mcarries the new values. At 0.3.0 the caller is responsible for ensuringdata’s primary key matchesid; the 0.4.0 DTO layer will enforce that invariant upstream.
Transaction-scoped methods
Each ambient method has a paired _in_tx variant with the same name + suffix, taking &DatabaseTransaction as its first argument after &self:
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
let repo: PgRepository<Film> = PgRepository::new(conn);
connis asea_orm::DatabaseConnection(re-exported fromferra-dbso you do not need to addsea-ormas a direct dependency —ferrare-exports the name underferra::DatabaseConnection).- The struct derives
Cloneunconditionally. Sea-ORM’sDatabaseConnectionwraps its pool in anArc, sorepo.clone()is cheap — passPgRepository<M>by value or by clone rather than wrapping inArc<PgRepository<M>>. - The constructor performs no validation. Callers with a non-PostgreSQL
DatabaseConnectioncompile but lose the SQLSTATE23505→Conflictmapping (those failures fall through to theDb(_)variant).
Bounds on M
PgRepository<M> works for any M that satisfies the 0.2.0 sibling-derive shape — i.e., the derive combination #[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]. Concretely, M must implement FerraModel (from ferra-core) plus the Sea-ORM trait set (ModelTrait, FromQueryResult, IntoActiveModel<ActiveModel>, with Entity: EntityTrait<Model = M> and the ActiveModel: ActiveModelTrait + ActiveModelBehavior + Send). The 0.2.0 canonical shape satisfies every bound automatically.
If you see error[E0277]: the trait bound <M>: sea_orm::ModelTrait is not satisfied at a PgRepository::<M>::new(conn) call site, you forgot the DeriveEntityModel derive on the same struct. Add it per the 0.2.0 canonical shape.
Error taxonomy
FerraDbError has exactly three variants. The set is locked from 0.3.0 through 0.6.0 Welding — adding a variant requires an ADR.
| Variant | When | Display template | 0.4.0 HTTP mapping |
|---|---|---|---|
NotFound { resource: &'static str, id: String } | find_by_id / update / delete observed zero matching rows | "{resource}/{id} not found" | 404 Not Found |
Conflict(String) | insert / update hit a Postgres SQLSTATE 23505 unique-constraint violation | "unique constraint \"{constraint}\" violated on {target}" | 409 Conflict |
Db(sea_orm::DbErr) | Every other sea_orm::DbErr | whatever the wrapped DbErr renders as | 500 Internal Server Error (no internal fragment leaks into the response body) |
Display guarantees
NotFound { resource: "films", id: "42".into() }.to_string()→"films/42 not found".Conflict("unique constraint \"films_title_key\" violated on column \"title\"".into()).to_string()→"unique constraint \"films_title_key\" violated on column \"title\"".Db(db_err).to_string()→ whateverdb_err.to_string()renders (e.g.,"Record not found","Execution Error: ...").
Every variant’s Display is stable through 0.6.0 Welding — the 0.4.0 RFC 7807 layer serializes these templates verbatim into the problem+json response. No Display output contains any internal Rust path fragment (no sea_orm::..., no sqlx::..., no rustc error codes).
source() + downcasting
FerraDbError::Db(_).source() returns Some(&dyn Error) pointing at the wrapped sea_orm::DbErr. You can downcast to inspect specific Sea-ORM failure modes:
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
#[non_exhaustive]
#[derive(Debug)]
pub struct Page<M> {
pub items: Vec<M>,
pub total: u64,
pub page: u32,
pub per_page: u32,
}
- 1-indexed
pageby framework convention.page = 0is treated identically topage = 1(the repository appliessaturating_sub(1)internally to hand Sea-ORM its 0-indexed offset). total: u64fits any Postgresbigintin the non-negative half — the full-table row count observed by the count query.items.len() <= per_pagealways. An over-scrolled request isOk(Page { items: vec![], total, .. }), neverNotFound.- Concurrent-insert drift.
items.len()andtotalmay disagree by ±1 under concurrent inserts — both queries run serially on the same connection (FR-015), not in parallel. Callers that need strict consistency usefind_page_in_txinside aSERIALIZABLEtransaction. - Serialize, not Deserialize.
Page<M>: serde::Serialize(inherited fromM: FerraModel: Serialize). NoDeserialize— pages are server-constructed, never parsed from client input. #[non_exhaustive]— future phases may add fields (e.g.,cursor: Option<Cursor>at 0.7.5 Enameling). Construct viaPage::new(items, total, page, per_page), not a struct literal.
Transactions
ferra-db re-exports DatabaseConnection and DatabaseTransaction from Sea-ORM under their original names, so you can type both handles without a direct sea-orm dependency. Obtaining a transaction requires importing Sea-ORM’s TransactionTrait:
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:
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:
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)
// src/film.rs
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub release_year: Option<i32>,
pub archived: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
pub use Model as Film;
3 — the CRUD round-trip
// src/main.rs
use ferra::{FerraDbError, FerraRepository, PgRepository};
use sea_orm::TransactionTrait;
mod film;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db_url = std::env::var("DATABASE_URL")?;
let conn = sea_orm::Database::connect(&db_url).await?;
let repo = PgRepository::<film::Film>::new(conn.clone());
// Insert
let blade_runner = repo.insert(film::Model {
id: 1,
title: "Blade Runner".to_owned(),
release_year: Some(1982),
archived: false,
}).await?;
println!("inserted: {blade_runner:?}");
// Read
let one = repo.find_by_id(1).await?;
assert_eq!(one.title, "Blade Runner");
// Update
let mut tweaked = one.clone();
tweaked.archived = true;
let updated = repo.update(1, tweaked).await?;
assert!(updated.archived);
// Paginate
let page = repo.find_page(1, 10).await?;
println!("page 1/10: {} items, {} total", page.items.len(), page.total);
// Compound write: two inserts in a single transaction
let tx = conn.begin().await?;
repo.insert_in_tx(&tx, film::Model {
id: 2, title: "Alien".into(), release_year: Some(1979), archived: false,
}).await?;
repo.insert_in_tx(&tx, film::Model {
id: 3, title: "2001: A Space Odyssey".into(), release_year: Some(1968), archived: false,
}).await?;
tx.commit().await?;
// Delete
repo.delete(1).await?;
assert!(matches!(repo.find_by_id(1).await, Err(FerraDbError::NotFound { .. })));
Ok(())
}
Troubleshooting
"films/42 not found"
You observed FerraDbError::NotFound { resource: "films", id: "42" } — the find_by_id / update / delete call targeted a primary key that has no row. At the HTTP layer (0.4.0) this maps to 404. Check that the id value you passed corresponds to an extant row.
"unique constraint \"films_title_key\" violated on column \"title\""
You observed FerraDbError::Conflict(_) — Postgres returned SQLSTATE 23505. The inner message names the constraint and, when available, the offending column. At the HTTP layer (0.4.0) this maps to 409. Resolve by either changing the colliding value or deleting the existing row first.
A DbErr::Custom("...") in logs
You observed FerraDbError::Db(_) — any sea_orm::DbErr that is not a recognized NotFound / NotUpdated / unique-violation shape falls through here. The wrapped DbErr is reachable via err.source().and_then(|s| s.downcast_ref::<sea_orm::DbErr>()). At the HTTP layer (0.4.0) this maps to 500 without leaking the internal detail into the response body.
error[E0277]: the trait bound <M>: sea_orm::ModelTrait is not satisfied
You forgot the DeriveEntityModel derive on the struct. The 0.2.0 sibling-derive canonical shape requires BOTH FerraModel AND DeriveEntityModel on the same #[derive(...)] list. Add DeriveEntityModel back and recompile.
error[E0425]: cannot find value Statement in crate ferra_db
Expected — ferra-db publishes no raw-SQL entry point (FR-007). To reach Sea-ORM’s raw-SQL surface, add sea-orm as a direct dependency of your consumer crate and call through it explicitly.
Stability commitment
| Item | 0.3.0 commitment | Change requires |
|---|---|---|
FerraDbError variant list | Three variants — NotFound, Conflict, Db | ADR (locked through 0.6.0 Welding) |
FerraDbError::Display wording | Stable templates per §Error taxonomy | ADR + user-guide update |
FerraRepository<M> method set | Ten methods — five ambient + five _in_tx | ADR to remove / rename; additions are additive |
PgRepository::new(conn) | (conn: DatabaseConnection) -> Self | ADR |
Page<M> field list | Four named fields (items, total, page, per_page) | #[non_exhaustive] allows additions; removals require ADR |
DatabaseConnection / DatabaseTransaction re-exports | Stable names, re-exported from Sea-ORM | ADR |
| Forbidden raw-SQL fragments | Statement / execute_unprepared / raw_sql do not resolve | ADR |
MSRV: rust-version = "1.88" — inherited from the workspace. Edition 2024 native async-in-trait requires it.
ferra-http — the Axum CRUD handler layer
The 0.4.0 Refining deliverable. Turns a #[derive(FerraModel, DeriveEntityModel)] entity into a fully-wired Axum router with RFC 7807 errors, HAL-lite hypermedia, pagination, and constitutional security defaults — zero hand-written routes, zero hand-written error mapping.
This page is the authoritative user-guide for ferra-http. A reader with only this page plus standard Rust knowledge produces a compiling ferra_router::<Film>(state) call site on the first attempt — no ADR, no constitution, and no framework source is required.
Zero-to-API in five lines
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
pub struct FerraState<M> { pub repo: Arc<PgRepository<M>> }
impl<M> FerraState<M> {
pub fn new(pool: DatabaseConnection) -> Self;
}
#[non_exhaustive]— construct viaFerraState::new(pool); struct-literal construction is not supported. Future phases add fields (tracing context, auth identity) without breaking this.#[derive(Clone)]— cloning is a reference-count bump onArc<PgRepository<M>>. Every request clones.- Implements
axum::extract::FromRef<FerraState<M>> for Arc<PgRepository<M>>. Custom handlers that only need the repository writeState(repo): State<Arc<PgRepository<M>>>; the Ferra handlers all takeState(state): State<FerraState<M>>.
Instantiate one FerraState<M> per model per database; cheaply share the same DatabaseConnection across models by calling FerraState::new(conn.clone()) multiple times.
The five CRUD handlers
All five handlers are generic over M: FerraModel + <Sea-ORM bounds>. You rarely name them directly — ferra_router<M> mounts them for you. When you do, the HTTP contracts are:
| Handler | Method | Path | Request | Success | Error surfaces |
|---|---|---|---|---|---|
get_item<M> | GET | /{resource}/:id | — | 200 OK + Json<ItemResponse<M>> | 400 on path-extractor rejection · 404 on missing row · 500 on DB error |
get_collection<M> | GET | /{resource} | ?page={u32}&per_page={u32} | 200 OK + optional Warning: 214 … + Json<CollectionResponse<M>> | 500 on DB error |
create_item<M> | POST | /{resource} | Json<M> body | 201 Created + Location: {base}/{resource}/{id} + Json<ItemResponse<M>> | 400 on body parse · 409 on unique violation · 413 on oversized body · 429 on rate limit · 500 |
update_item<M> | PUT | /{resource}/:id | Json<M> body | 200 OK + Json<ItemResponse<M>> | 400 · 404 · 409 · 413 · 429 · 500 |
delete_item<M> | DELETE | /{resource}/:id | — | 204 No Content (no body) | 400 · 404 · 429 · 500 |
Path id is authoritative on update and delete — any id field in the request body is ignored for the WHERE clause. {resource} is <M as FerraModel>::meta().resource_name (e.g. "films").
A model that carries one or more #[ferra(projection(...))] declarations also mounts a parallel set of routes under each named projection’s URL prefix (e.g. /detail/films/{id}). The wire shape on those routes is filtered to the projection’s read_fields / write_fields allowlist. See Projections and routing for the full URL contract, naming convention, and the path_prefix override.
RFC 7807 error taxonomy
Every error from every handler is rendered as application/problem+json per RFC 7807 §3. Six variants, locked through 0.6.0 Welding:
| Variant | HTTP status | type URI | title |
|---|---|---|---|
FerraError::NotFound { resource, id } | 404 | https://ferra.rs/errors/not_found | Resource Not Found |
FerraError::Validation(ValidationErrors) | 400 | https://ferra.rs/errors/validation | Validation Error |
FerraError::Conflict(String) | 409 | https://ferra.rs/errors/conflict | Conflict |
FerraError::Internal(String) | 500 | https://ferra.rs/errors/internal | Internal Server Error |
| (middleware) | 413 | https://ferra.rs/errors/payload_too_large | Payload Too Large 1 |
| (middleware) | 429 | https://ferra.rs/errors/rate_limited | Too Many Requests 1 |
Worked body for a 404:
{
"type": "https://ferra.rs/errors/not_found",
"title": "Resource Not Found",
"status": 404,
"detail": "films/42 not found"
}
Worked body for a 400 from a malformed JSON request:
{
"type": "https://ferra.rs/errors/validation",
"title": "Validation Error",
"status": 400,
"detail": "validation failed",
"errors": {
"errors": [
{ "field": "body", "code": "invalid_json", "message": "expected `,` or `}` at line 1 column 14" }
]
}
}
Internals never leak. The detail field on a 500 is always the literal string "internal server error"; the inner sea_orm::DbErr is dropped before reaching the HTTP body. The Axum "Failed to deserialize … into the target type: " prefix and any axum::extract::rejection:: substring are stripped at the extractor boundary; only human-readable messages reach errors[*].message.
Worked body for a 429 from the rate-limit mapper:
{
"type": "https://ferra.rs/errors/rate_limited",
"title": "Too Many Requests",
"status": 429,
"detail": "rate limit exceeded; retry after 3 seconds"
}
The response also carries the Retry-After header from the underlying rate-limit component verbatim (integer seconds). When the component does not emit a Retry-After header, detail degrades gracefully to the literal string "rate limit exceeded" and the header is absent from the response. The rate-limit mapper mounts only alongside the framework’s default rate-limit component — consumer-provided rate-limiters produce their own error shape (see ADR-0015 for rationale).
The HAL-lite envelope
Every successful body carries _links so an AI agent can navigate the API without prior URL knowledge.
ItemResponse<M> — single-item envelope
Model fields are flattened at the top level via #[serde(flatten)]. A Film { id: 1, title: "Rust en pratique", director: "Alice", year: Some(2025) } serializes as:
{
"id": 1,
"title": "Rust en pratique",
"director": "Alice",
"year": 2025,
"_links": {
"self": { "href": "https://api.example.com/films/1" },
"collection": { "href": "https://api.example.com/films" }
}
}
CollectionResponse<M> — paginated envelope
{
"items": [ /* array of ItemResponse<M> */ ],
"total": 25,
"page": 2,
"per_page": 10,
"_links": {
"self": { "href": "https://api.example.com/films?page=2&per_page=10" },
"next": { "href": "https://api.example.com/films?page=3&per_page=10" },
"prev": { "href": "https://api.example.com/films?page=1&per_page=10" },
"first": { "href": "https://api.example.com/films?page=1&per_page=10" },
"last": { "href": "https://api.example.com/films?page=3&per_page=10" }
}
}
On boundary pages, next or prev serialize as JSON null (never omitted). On total == 0, last_page == 1 and first == last == self.
Building links manually
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
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:
use ferra::PaginationParams;
let p = PaginationParams::with(Some(3), Some(50));
assert_eq!(p.resolved_page(), 3);
assert_eq!(p.resolved_per_page(), 50);
let defaulted = PaginationParams::with(None, None);
assert_eq!(defaulted.resolved_page(), 1);
assert_eq!(defaulted.resolved_per_page(), 20);
let clamped = PaginationParams::with(None, Some(500));
assert_eq!(clamped.resolved_per_page(), 100);
assert_eq!(clamped.clamped_per_page(), Some(100));
The constructor stores the raw Option<u32> inputs as-is and applies the clamp / default lazily through the resolved_* / clamped_per_page methods — values produced via with(...) are observationally identical to those that reach a handler through the HTTP extraction path.
- Default:
page=1,per_page=20. - Cap:
per_page ≤ 100(hard limit). Values below 1 or above 100 are silently clamped and the response carriesWarning: 214 - "per_page clamped to {N} (max 100)"per RFC 7234 §5.5.4. - The response body’s
per_pagefield reports the clamped value (so clients can detect the clamp without parsing theWarningheader).
Worked curl:
$ curl -D - 'http://localhost:3000/films?per_page=500'
HTTP/1.1 200 OK
warning: 214 - "per_page clamped to 100 (max 100)"
content-type: application/json
...
{"items":[...],"total":523,"page":1,"per_page":100,"_links":{...}}
Base-URL extractor
pub fn base_url_from_parts(parts: &http::request::Parts) -> String;
Produces the base_url prefix used by build_item_links / build_collection_links. Behavior:
- Scheme: first comma-separated token of
X-Forwarded-Protoif it matches the{http, https}allowlist; otherwise"http". - Host: the
Hostheader if present and non-empty; otherwise"localhost". - Output format:
"{scheme}://{host}". No trailing slash.
Anti-spoofing: X-Forwarded-Proto: javascript (or any non-allowlisted scheme) falls back to http. A spoofed header cannot inject a javascript: URL into _links.*.href by construction.
Deploying behind a TLS-terminating load balancer? Ensure the LB sets X-Forwarded-Proto: https so the generated links match the client-visible scheme.
Custom extractors
See custom-handlers.md — the rationale for FerraJson<T> / FerraPath<T> (RFC 7807 error continuity), the FromRequest vs FromRequestParts ordering rule, the Send + Sync + 'static cheat-sheet, and a worked end-to-end POST /films/{id}/publish action route.
Security defaults
ferra_router::<M>(state) mounts a deny-by-default Tower layer stack — a consumer who writes exactly those five lines is already constitution-§I compliant:
| Layer | Default | Override path |
|---|---|---|
tower_http::cors::CorsLayer::new() | Deny all cross-origin | FerraLayer::cors(...) at 0.7.0 Pre-tempering |
| 413 body-limit mapper | Rewrites stock text/plain → application/problem+json with type: .../payload_too_large | — (permanent) |
tower_http::limit::RequestBodyLimitLayer | 1 MiB | FerraLayer::body_limit(...) at 0.7.0 |
tower_http::trace::TraceLayer | Body sampling OFF (never logs PII) | Per-route opt-in at 0.12.0 Sheen |
tower_governor::GovernorLayer on mutation endpoints | per_second(2) + burst_size(5) + peer-socket-IP key | Full config at 0.9.0 Forging III |
PaginationParams clamp (FR-017) | Loud Warning: 214 header on every clamp | — (constitutional) |
X-Forwarded-Proto allowlist | {http, https} only | — (constitutional) |
429 wire format — the framework’s default rate-limit component’s 429 responses are rewritten to application/problem+json with type: https://ferra.rs/errors/rate_limited, preserving the Retry-After header from tower_governor verbatim. See the worked body in §“RFC 7807 error taxonomy” above. Oversized bodies produce the same application/problem+json shape — a client parsing type never sees a mixed wire format.
Rate-limit observability event. Each time the rate-limit mapper rewrites a 429, it emits a single structured tracing::warn! event so an operator tailing logs can see rate-limit rejections without reading the framework source:
- Target:
ferra_http::rate_limit— namespaced so an operator can isolate it viaEnvFilter(RUST_LOG=ferra_http::rate_limit=warn). - Message:
rate limit exceeded. - Severity:
WARN. - Fields (deliberately minimal):
http.method— the request HTTP method (e.g."POST"). Always present.http.target— the request path-and-query (e.g."/films"or"/films?page=2"). Always present.http.retry_after_seconds— integer seconds parsed from the inboundRetry-Afterheader. Present only when the header parses as a non-negative integer; genuinely absent otherwise (not recorded as0).
Request and response body content never appear in this event — only the three fields listed above. The schema is pinned minimal so the framework’s dedicated observability phase at 0.12.0 Sheen can absorb it into span-based telemetry (adding trace_id, span_id, and peer-IP once the trusted-proxy story lands) without breaking a consumer’s existing EnvFilter or custom tracing::Layer.
Wire up a subscriber in your host binary’s main to surface these events:
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.
Overriding the defaults. Every default in the table above is overridable via the FerraLayer builder applied to a Foundry chain — body cap, CORS, mutation rate-limit rule, tracing layer, and a base_path prefix on hypermedia links. The override surface is documented in foundry.md § “Transport-layer overrides” with the full worked example and the REPLACE-not-merge rule for per-model overrides.
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:
let app = ferra_router::<Film>(state)
.layer(MyObservabilityLayer::new()) // outer: sees requests before the framework
.layer(MyShedLoadLayer::new()); // appended AFTER → now the TRUE outermost
On incoming requests: MyShedLoadLayer runs first, then MyObservabilityLayer, then the framework stack (CORS → body_limit_mapper → RequestBodyLimitLayer → TraceLayer → DefaultBodyLimit::disable → handlers; with the mutation-only branch adding rate_limit_mapper → GovernorLayer before mutation handlers). On the way back out, responses traverse the stack in reverse.
See also §“RFC 7807 error taxonomy” for the 429 wire body the rate_limit_mapper produces.
Content negotiation — RFC 7240 Prefer: header
Every mutation endpoint (POST, PUT, DELETE) recognises the RFC 7240 Prefer: request header and branches the response shape accordingly. Consumers that do not send the header observe no change — the wire shape stays byte-identical to the framework’s pre-0.6.5 emission.
Recognised tokens
Prefer: value | Effect | Resulting status | Response body |
|---|---|---|---|
| (header absent) | default mutation shape | POST → 201 Created, PUT → 200 OK, DELETE → 204 | full resource body on POST/PUT; empty on DELETE |
return=minimal | omit the response body | POST → 204 No Content (Location preserved), PUT → 204, DELETE → 204 unchanged | empty |
return=representation | explicit form of the default | POST → 201, PUT → 200, DELETE → 204 unchanged | full resource body |
respond-async | parse-only at 0.6.5 — recognised so clients can probe the header without being broken, but has no effect on the response shape. Reserved for a future async-jobs cohort. | unchanged | unchanged |
The parser is case-insensitive, whitespace-tolerant, and silently drops unrecognised tokens and ;-delimited parameters per RFC 7240 §2 (“recipients SHOULD ignore preferences they do not understand”):
Prefer: Return=Minimal, respond-async; wait=10
…parses as return=minimal + respond-async (the ; wait=10 parameter is dropped).
Preference-Applied: response header
Per RFC 7240 §3, the framework echoes honoured preferences via the Preference-Applied: response header. The header is absent when no preference was honoured — including the no-header case (table row 1) and the unrecognised-token case.
Request Prefer: | Response Preference-Applied: |
|---|---|
| (absent) | absent |
return=minimal | return=minimal |
return=representation | return=representation (echoed even though the body is unchanged — the standard binds the echo to “preferences that were honoured”, not to “preferences that changed the response”) |
respond-async | absent (parse-only — no observable change) |
return=minimal, respond-async | return=minimal (only the honoured preference is echoed) |
handling=lenient (unrecognised) | absent |
Interaction with the 422 validation path
When a mutation request fails declarative validation (the 0.6.0 Welding application/problem+json 422 path), the Prefer: header is recognised but does not change the response. The 422 body is mandated by the framework’s closed-namespace contract; Preference-Applied: is not emitted on the 422 path (no preference was honoured that changed observable behaviour).
Worked example
# Default — full body returned.
curl -i -X POST https://api.example.com/films \
-H 'Content-Type: application/json' \
-d '{"title": "Inception", "director": "Christopher Nolan", "year": 2010}'
# HTTP/1.1 201 Created
# Content-Type: application/json
# Location: /films/42
# { "id": 42, "title": "Inception", ... }
# `return=minimal` — body omitted, Location preserved.
curl -i -X POST https://api.example.com/films \
-H 'Content-Type: application/json' \
-H 'Prefer: return=minimal' \
-d '{"title": "Inception", "director": "Christopher Nolan", "year": 2010}'
# HTTP/1.1 204 No Content
# Location: /films/43
# Preference-Applied: return=minimal
Batch-processing consumers gain bandwidth and JSON-parse savings on every mutation; consumers that do not opt in observe the same response shape they did before 0.6.5.
The ferra facade
[dependencies] ferra = { package = "ferra-rs", version = "0.5" } + use ferra::*; is the canonical entry point at 0.4.0. The package = "ferra-rs" alias is required because the ferra name on crates.io is owned by an unrelated project; the framework ships as ferra-rs and Cargo’s package alias keeps ferra::* working in your Rust code (see Getting Started). Every item on this page — FerraState, FerraError, FerraJson, FerraPath, PaginationParams, Link, ItemLinks, CollectionLinks, ItemResponse, CollectionResponse, build_item_links, build_collection_links, base_url_from_parts, the five handlers, and ferra_router — is reached from this single import, alongside the #[derive(FerraModel)] macro and the ferra-core / ferra-db public surface.
Consumers do not depend on ferra-core, ferra-db, ferra-forge, or ferra-http directly. The 0.4.0 compile-fail trybuild fixture also pins ferra::Statement, ferra::execute_unprepared, ferra::raw_sql, and ferra::sea_orm::Statement as unreachable — there is no raw-SQL entry point on the facade.
Worked end-to-end example
use ferra::*;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
mod film {
use super::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub year: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub use film::Model as Film;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let conn = sea_orm::Database::connect(&database_url).await?;
let app = ferra_router::<Film>(FerraState::new(conn));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
See examples/hello-ferra/ for the full three-command boot sequence (docker compose up -d postgres or podman compose up -d postgres → sea-orm-cli migrate up → cargo run -p hello-ferra) and the smoke.sh script that exercises all seven wire-format contracts.
Troubleshooting
413 vs 400 on oversized bodies
A request whose body exceeds the 1 MiB default cap is rejected either with 413 Payload Too Large or with 400 Bad Request, depending on a single precondition: whether the request declares a Content-Length header.
| Request | Response code | type URI | Responsible mapper |
|---|---|---|---|
Body > 1 MiB with Content-Length declared | 413 | https://ferra.rs/errors/payload_too_large | body_limit_mapper (FR-033a) |
Body > 1 MiB without Content-Length (streaming / chunked) | 400 | https://ferra.rs/errors/validation (single errors[0].code = "invalid_json") | FerraJson<T>::from_request surfacing as FerraError::Validation |
When Content-Length is present, RequestBodyLimitLayer short-circuits at the layer level before the stream is consumed — hence the early 413. When the header is absent, the stream is consumed up to the cap, the read fails, and the JSON extractor rejects the request as a parse error (still machine-readable, still RFC 7807, still parseable via the same generic type-dispatch path you use for the other validation failures).
Both outcomes are semantically correct — the oversized input is rejected either way. The divergence is intentional at 0.4.1. A future release may unify the two paths under a single 413 (an anvil-class cleanup candidate); the Story 3 regression test pins the current 400 fallback so any such rework must explicitly update or remove it. If you can set Content-Length on your client, do — your clients then see a single, predictable 413.
Other diagnostics
- 413 with
application/problem+jsonbody — request body exceeds 1 MiB. Split the request or (at 0.7.0 Pre-tempering) raise the limit viaFerraLayer::body_limit(...). - 429 with
Retry-After— peer IP hit the mutation rate limit (per_second(2)/burst_size(5)). Back off for the number of seconds inRetry-After. Warning: 214 - "per_page clamped ..."header — yourper_pagequery parameter was out of[1, 100]. Useper_page ≤ 100to silence it.problem+jsonwithtype: ".../validation"anderrors[0].code = "invalid_path_param"— the path segment after/{resource}/is not a valid primary key forM(e.g.GET /films/abcwhenFilm.id: i32). The built-in path extractor rejects before the handler runs.- 500 with
detail: "internal server error"— the DB surfaced an error the framework does not map. Inspect logs / tracing for theDebugform ofFerraError::Internal; the HTTP body intentionally says nothing more. - CORS preflight fails —
CorsLayer::new()denies all cross-origin by default. At 0.4.0, CORS is not configurable; at 0.7.0 Pre-temperingFerraLayer::cors(...)ships the override surface.
-
emitted by a middleware mapper (the 413 body-limit mapper or the 429 rate-limit mapper), not by
FerraError::IntoResponse. The wire shape is identical to the four handler-produced variants — a client that handles onetypeURI handles them all through the same generic RFC 7807 code path. ↩ ↩2
Projections and routing
The 0.6.5 Chasing deliverable. A #[derive(FerraModel)] struct can expose multiple distinct read/write shapes — projections — by adding a single attribute. Each named projection auto-mounts under its own URL prefix and surfaces as a distinct path in the OpenAPI document, a distinct call in the generated SDK, and a distinct HAL _links.self.href target.
This page is the authoritative user-guide for projection-driven routing. A reader with only this page plus standard Rust knowledge produces a working projection declaration on the first attempt — no ADR and no framework source is required.
Mental model
A projection is a content variant of a resource — not an access-control mechanism. The same underlying row can be served through several projections, each one a deliberate slice of the row’s fields plus a deliberate stance on which fields a client may submit.
The defining property is that every projection is a distinct callable entity at every layer of the stack: a distinct URL path, a distinct OpenAPI operationId, a distinct generated-SDK method, a distinct HAL self link. A consumer that has the URL has, by construction, also fixed the wire shape — there is no header, no query parameter, and no out-of-band negotiation required.
Authorisation is enforced separately — by middleware on the route or by per-operation guards. A detail projection is not an admin-only projection; it is a more detailed projection that a developer may choose to gate behind an authorisation layer if they wish.
The routing contract
| Surface | URL | Wire shape |
|---|---|---|
| Default read (always emitted) | GET /{resource}/{id} and GET /{resource} | Every readable field of M |
| Default write (always emitted) | POST /{resource}, PUT /{resource}/{id} | Every writable field of M |
| Named projection — auto-derived prefix | GET /{name}/{resource}/{id} (etc.) | Exactly the fields the projection’s read/write lists declare |
Named projection — explicit path_prefix | GET /{path_prefix}/{resource}/{id} (etc.) | As above; the auto-derived /{name}/ is suspended |
Default replacement (default = true) | GET /{resource}/{id} (the bare path now serves the named projection’s shape) | The named projection’s read/write lists |
A model that declares zero named projections observes byte-identical 0.5.x / 0.6.0 routing — the bare resource path is unchanged. This is the SC-007 baseline: introducing a named projection on a model is strictly additive to its URL surface, never a silent breaking change.
A worked example
use ferra::*;
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")]
#[ferra(projection(
name = "detail",
read = [id, title, director, cost_price],
write = [title, director, cost_price],
))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub cost_price: Option<f64>,
}
This declaration auto-mounts two parallel route groups:
| Verb | Bare path | Detail path |
|---|---|---|
| Read item | GET /films/{id} | GET /detail/films/{id} |
| Read collection | GET /films | GET /detail/films |
| Create | POST /films | POST /detail/films |
| Update | PUT /films/{id} | PUT /detail/films/{id} |
| Delete | DELETE /films/{id} | DELETE /detail/films/{id} |
The bare-path responses carry every readable field of Model (the default-read set). The /detail/... responses are filtered to the projection’s read list — fields not in that list never appear in the response body, ever.
For the OpenAPI document, the paths map gains entries for /films/{id} and /detail/films/{id}, each operation referencing a distinct schema (Film vs FilmDetail). SDK generators driving off the document produce distinct method names per surface (client.films.get(id) vs client.films.getDetail(id)).
Overriding the URL prefix
By default the projection’s name is lower-cased and used as the URL prefix segment. To pin the URL surface to something different — for example, a versioned segment or a developer-friendly synonym — add a path_prefix:
#[ferra(projection(
name = "detail",
path_prefix = "/v2/detail",
read = [id, title, director, cost_price],
write = [title, director, cost_price],
))]
This mounts /v2/detail/films/{id} (and parallel verbs). The auto-derived /detail/... URL is NOT also registered — exactly one URL surface exists per projection.
The leading / is part of the literal; the framework appends the trailing / and the resource segment.
Naming convention — shape, not audience
Projection names MUST describe the shape of the variant — what fields it exposes — not the audience it serves.
| Good (shape) | Bad (audience) |
|---|---|
"detail" | "admin" |
"summary" | "staff" |
"compact" | "backoffice" |
"full" | "internal" |
Naming a projection after a role visually couples the projection to access-control in the developer’s mental model and in URLs that appear in access logs — even though projections enforce no authorisation. A "detail" projection that exposes cost prices is still subject to whatever auth middleware sits on its route; calling it "admin" invites the next reader to assume the projection itself enforces a role check.
Use the role-neutral shape name and gate the route at the auth layer.
Default replacement and the silent-break problem
Setting default = true on a named projection makes that projection’s shape the one served at the bare resource path. The auto-default read/write surface is suppressed; consumers requesting /films/{id} get the named projection’s shape without any URL change to signal the difference. This is a silent breaking change to the public API contract.
To force the change to be visible, the framework requires two attestation attributes alongside default = true:
#[ferra(projection(
name = "detail",
default = true,
promotes_from = "auto-derived", // attestation of the previous default
breaking_change_version = "2.0.0", // app version in which the promotion lands
read = [id, title, director, cost_price],
write = [title, director, cost_price],
))]
Missing either attestation produces compile-time FRG-316. promotes_from is purely declarative — the macro does not validate it against repository history. Its purpose is to force the developer to name what is being replaced, which is the part the silent-flip case skips entirely. breaking_change_version surfaces in the OpenAPI document as the x-ferra-promoted-in vendor extension on every operation under the bare resource path, so SDK consumers and downstream tooling can detect the silent break.
A second defence (the .ferra/projection-defaults.lock file with schema-hash drift detection) is queued for the next release; at 0.6.5 the Layer-1 attestation is the sole defence.
Worked example — promotion replaces the bare path
The default-promoting projection serves at the bare resource path and no prefixed mirror is published. A model whose v2 projection is the default has exactly one URL surface for the projection’s shape: /films / /films/{id}. The would-be auto-derived /v2/... prefix is not registered, so GET /v2/films/{id} returns 404. This is what makes the promotion a REPLACE, not an ADD.
#[derive(FerraModel, /* ... */)]
#[sea_orm(table_name = "films")]
#[ferra(projection(
name = "v2",
default = true,
promotes_from = "auto-derived",
breaking_change_version = "2.0.0",
read = [id, title],
write = [title],
))]
pub struct Film {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Wire-shape contract:
| Request | Response |
|---|---|
POST /films body {"title": "X"} | 201 Created — accepted, named projection’s write_fields |
POST /films body {"title": "X", "unknown": "y"} | 422 Unprocessable Entity — unknown field rejected |
GET /films/{id} | 200 OK body {"id": ..., "title": "X", "_links": {...}} |
GET /v2/films/{id} | 404 Not Found — the prefixed mirror is NOT published |
Worked example — x-ferra-promoted-in in the OpenAPI document
The breaking_change_version attestation surfaces as the x-ferra-promoted-in vendor extension on every operation under the bare resource path. SDK generators and tools like ferra anvil read this extension to detect the silent breaking change.
For the Film model above, fetching GET /docs/openapi.json returns a document containing:
paths:
/films:
get:
x-ferra-promoted-in: "2.0.0"
# ... rest of operation ...
post:
x-ferra-promoted-in: "2.0.0"
# ...
/films/{id}:
get:
x-ferra-promoted-in: "2.0.0"
# ...
put:
x-ferra-promoted-in: "2.0.0"
# ...
delete:
x-ferra-promoted-in: "2.0.0"
# ...
The extension is per-operation under the bare path. A non-default named projection (one with an auto-derived /admin/ prefix or an explicit path_prefix = "/...") does not emit the extension on its prefixed operations — the extension is the signal that the model’s bare-path wire shape has shifted, not a per-model badge.
Prefix collisions
Two collision classes are rejected by the framework — one at compile time, one at Foundry::build().
FRG-310 — same prefix on two projections
Two projections (on the same model or across two mounted models) that resolve to the same effective URL prefix produce ambiguous routing. The compile-time channel catches the intra-model case; the Foundry::build() channel catches the cross-model case.
// Both projections declare path_prefix = "/staff" → FRG-310 at compile time.
#[ferra(projection(name = "admin", path_prefix = "/staff", read = [id, title]))]
#[ferra(projection(name = "manager", path_prefix = "/staff", read = [id, title]))]
Resolution: rename one of the projections, or set a distinct path_prefix on one of them.
FRG-312 — projection prefix collides with another resource
A named projection’s auto-derived prefix that matches another mounted model’s resource_name is rejected at Foundry::build(). For example, model A with projection name = "admin" (auto-deriving /admin/) cannot be mounted on the same Foundry as model B with resource_name = "admin" (mounting at /admin).
Resolution: rename the projection to a shape descriptor that does not match any mounted resource name, or set an explicit path_prefix to disambiguate.
Nested resources (root-only URL surface)
When 0.6.5 ships, a single Ferra application mounts every model on a flat URL surface — there is no nested-resource mounting API. ADR-0030 §Nested resources reserves the routing semantics for a future release:
- The URL prefix derives exclusively from the root resource’s active projection and propagates uniformly to every nested segment.
- A nested model MAY declare a projection with the same name as the root’s; its read/write shapes are served for that segment. If it does not declare a matching projection, the default read/write shape of the nested model is served (additive fallback).
- A nested (non-root) model MUST NOT declare its own
path_prefix. Doing so produces compile-time FRG-315 when the nested-mounting API ships.
No URL of the form /detail/films/{id}/summary/reviews/{rid} is supported — a request reaches with one variant choice that applies coherently to the entire response graph. A developer who needs different per-segment shapes declares distinct root-level projections.
Versioning composition
Ferra takes no position on API versioning — that is an application-level concern. The framework provides two complementary mechanisms that compose with the projection surface:
-
Application-level prefix (canonical). Mount the entire API under a version segment:
let app = Foundry::new(conn).api_version("v1")?.mount::<Film>().build();When a breaking change requires bumping
/v1→/v2, the developer bumps the prefix in the application code. The framework provides the signal (the FRG-316 attestation requirement ondefault = true); the developer decides when to bump and how to communicate the bump (Deprecation:header, sunset date, parallel-run period). -
Per-projection versioning (escape hatch via
path_prefix). A single projection can be versioned independently of the application by embedding the version in itspath_prefix:#[ferra(projection(name = "detail", path_prefix = "/v2/detail", read = [...]))]This composes naturally with
.api_version("v1")— the final URL becomes/v1/v2/detail/films/{id}.
The framework deliberately does NOT introduce a separate version = N attribute; path_prefix is sufficient for the escape-hatch case and avoids growing the projection surface for what is fundamentally an application concern.
Why path-prefix routing — and what it costs
Three alternative designs were considered and rejected. The honest trade-offs:
| Alternative | Pros | Why rejected |
|---|---|---|
Accept: profile=... (RFC 6906) | Standards-track REST answer; preserves single-URL semantics | No mainstream SDK generator branches on Accept parameters; CDN caching requires Vary: Accept (fragments hit rates); HAL _links cannot encode the profile in the href; AI clients must reason about header serialisation at the call site |
Prefer: profile=... (RFC 7240 extension) | Reuses an existing header surface | RFC 7240 §2 mandates that recipients MUST silently ignore unrecognised preferences — a misconfigured intermediary could drop the variant signal without error; conflates Prefer:’s response-shape semantic with variant selection |
Query parameter (?projection=... / JSON:API sparse fieldsets / Google FieldMask / Stripe expand) | Strongest industry precedent; single canonical URL per row; arbitrary runtime field selection composes naturally | Collapses the OpenAPI paths map into a single entry per resource; SDK generator produces one method per resource with a runtime opts.projection parameter; HAL _links.self.href either encodes the query string (less clean) or drops the variant signal; diagnostics for invalid projection names move from compile-time to runtime |
The load-bearing axis is the typed-callable-per-projection invariant: every projection should be a distinct callable entity at every layer of the stack — handler, OpenAPI path, SDK method, HAL self URL, operationId. Query parameter routing collapses three of those four layers (OpenAPI path, SDK method, operationId) back into a single entity with a runtime discriminator, which weakens the typed-projection contract this feature exists to support.
Path-prefix routing is the more aggressive choice and the less-precedented one, but it is the one that propagates the type-level distinction through to the wire format. The trade-off accepted: REST-purist critique on URL-per-row, in exchange for typed-callable-per-projection through OpenAPI and SDKs.
When NOT to use projections
- The variant exists for authorisation reasons (e.g., “only authenticated admins see cost prices”). Use auth middleware on a route, not a projection.
- The variant changes the underlying row identity (e.g., joined data from another table). That is a separate resource, not a projection — declare a second
#[derive(FerraModel)]. - The variant is one-of-many runtime field selections (
?fields=id,title,cost_pricestyle). Projections are named-and-typed at compile time; arbitrary runtime field selection is a future feature on top of (or instead of) projections. - The wire shape varies by row content, not by URL. Projections are URL-keyed by design.
Reference
- ADR-0030 — Typed projection codegen / URL-prefix routing — the design rationale, the four-options trade-off matrix, and the explicit nested-resource semantics.
- ADR-0009 — Typed projections codegen — the IR-level shape that drives schema and route emission.
- Compile-time diagnostics — FRG-310 / FRG-311 / FRG-312 / FRG-315 / FRG-316 — every rejection’s worked-good and worked-bad sample.
- Ferra HTTP — Routing — the lower-level Tower stack the projection routes inherit.
- Ferra OpenAPI — Schema names — how projections map to
components/schemasentries and thex-ferra-promoted-invendor extension.
Behaviors
The 0.7.0 Pre-tempering deliverable. A #[derive(FerraModel)] struct can enrol cross-cutting behaviors that the framework injects in lockstep across the entire stack — fields, SQL, default projections, hypermedia links, and OpenAPI emission — from a single container-level attribute.
This page is the authoritative user-guide for the behaviors surface. A reader with only this page plus standard Rust knowledge produces a working #[behavior(...)] declaration on the first attempt — no ADR and no framework source is required.
Mental model
A behavior is a cohesive cohort of cross-cutting modifications the framework applies to a model: the fields it carries, the SQL the framework emits, the projections the framework derives, the hypermedia links the framework attaches, and the OpenAPI operations the framework documents — all at once, from one attribute line.
The two behaviors that ship at this release:
| Name | Effect |
|---|---|
soft_delete | Adds a deleted_at column. Every framework-generated SELECT filters rows whose stamp is non-null. The standard DELETE rewrites to an UPDATE deleted_at = NOW(). A new POST /{resource}/{id}/restore action sets the stamp back to NULL. |
timestampable | Adds created_at and updated_at columns. Every framework-generated INSERT stamps both. Every framework-generated UPDATE advances updated_at. Submitted values for either field in a create/update body are silently ignored. |
A model that declares zero #[behavior(...)] attributes observes byte-identical 0.6.5 behaviour. Adding a behavior is strictly additive to the model’s surface — the existing routes, the existing projections, and the existing OpenAPI emission all continue to exist; the behavior layers new structure on top.
The attribute
#[behavior(name1, name2, ...)]
- Container-level — applied to the
structdeclaration, not to individual fields. - Closed vocabulary at this release:
soft_delete,timestampable. An unknown name is a compile-time error. - Stackable — the attribute can appear multiple times on the same struct; the framework dedupes by set-union.
- Order-irrelevant —
#[behavior(soft_delete, timestampable)]and#[behavior(timestampable, soft_delete)]produce byte-identicalModelMeta. - No arguments at this release — each name is a bare identifier.
#[behavior(soft_delete)]
Worked example
use ferra::*;
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")]
#[behavior(soft_delete)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub title: String,
}
The framework’s ModelMeta for Film carries:
- A
behaviorsslice containingBehaviorKind::SoftDelete. - An automatically injected
deleted_at: Option<jiff::Timestamp>field at the end of the user’s field list, flaggedread_only: trueso the default-read projection includes it and the default-write projection excludes it. - An auto-emitted
OperationMeta::new("restore", "POST", "restore")onoperations.
The migration
The column shape is hand-written into your sea-orm-migration migration file at 0.7.0 (the ferra-anvil migration create scaffolder lands at 0.13.0 and emits the same shape automatically based on M::meta().behaviors):
ALTER TABLE films ADD COLUMN deleted_at TIMESTAMPTZ NULL;
That is the entire migration surface — the framework’s runtime SQL composes the predicate (WHERE deleted_at IS NULL) and the rewrites (DELETE → UPDATE deleted_at = NOW()) on top of this single column.
What changes on the wire
DELETE /films/{id}on a live row → 204 No Content, no physical delete; the row’sdeleted_atis set toNOW().DELETE /films/{id}on an already-soft-deleted row → 404 Not Found (the framework’sSELECTpredicate filters the row out before the handler reaches it).GET /films/{id}on a soft-deleted row → 404 Not Found.GET /films→ soft-deleted rows are absent from the collection.- The 1-to-1 read response shape is unchanged — your existing JSON consumers see
deleted_at: nullon every live row (the field is in the default-read projection by construction).
Restoring a row
POST /films/{id}/restore
- On a soft-deleted row → 200 OK with the row’s HAL
ItemResponse. Thedeleted_atis set back toNULL. - On a row that never existed → 404 Not Found.
- On a row that is already live (
deleted_at IS NULL) → 404 Not Found (idempotent — re-restoring a live row is a no-op as far as the client is concerned). - The route honours
Prefer: return=minimalper the framework’s standard preference header rules: with the header → 204 No Content,Preference-Applied: return=minimal, empty body.
The restore action mounts under the standard Tower stack — CORS deny / body cap / mutation rate-limit / tracing — exactly like the standard CRUD mutation routes. It composes with per-projection URL prefixes by simple concatenation:
POST /admin/films/{id}/restore ← when the model has an `admin` projection
_links.restore.href
Every live row’s HAL response carries:
{
"data": { "id": "550e8400-...", "title": "Casablanca", "deleted_at": null },
"_links": {
"self": { "href": "https://api.example/films/550e8400-..." },
"collection": { "href": "https://api.example/films" },
"restore": { "href": "https://api.example/films/550e8400-.../restore" }
}
}
The _links.restore.href is present only on live rows — soft-deleted rows are filtered before they ever reach the link-emission stage, so a client cannot observe a _links.restore.href on a row whose stamp is non-null. The URL composes through any configured base_path automatically (see Foundry → Transport-layer overrides → base_path).
The AllowsHardDelete opt-in marker
The framework’s standard DELETE /{r}/{id} handler rewrites to UPDATE deleted_at = NOW() on every soft-delete model. A developer who registers a custom processor that bypasses this and issues DELETE FROM films WHERE ... directly is bypassing the soft-delete safety contract.
The framework requires the bypass to be explicit:
use ferra::AllowsHardDelete;
pub struct PurgeProcessor;
impl FerraProcessor<Film> for PurgeProcessor {
// ... developer's processor logic, including the physical DELETE ...
}
// The opt-in:
impl AllowsHardDelete for PurgeProcessor {}
Without the impl AllowsHardDelete line, Foundry::build() panics at boot with a verbatim message naming the model, the processor type, and the exact impl line the developer must add. The framework refuses to wire the application together until the bypass is acknowledged.
A custom processor that does NOT issue a physical DELETE — for example, one that mutates deleted_at directly via UPDATE films SET deleted_at = NOW() WHERE id = $1 — does NOT require the marker. The marker gates only the physical-delete path.
Tip: directly mutating deleted_at from a custom processor is permitted without the AllowsHardDelete marker, but it bypasses the framework’s restore action and the HAL link emission. The framework provides the restore operation as the supported way to flip the stamp back; direct mutation is your responsibility.
Edge case — identifier collision with a soft-deleted row
A POST /films that supplies an id matching an existing soft-deleted row returns the framework’s standard 409 conflict response:
{
"type": "https://ferra.rs/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "..."
}
This is the standard duplicate-key shape unchanged — the soft-deleted row continues to occupy the UNIQUE (id) index, and an INSERT colliding on it is a primary-key collision regardless of stamp state. The framework’s behaviour matches the database’s natural semantics.
If your application needs identifier reuse across hard-deleted-and-restored cycles, apply a partial unique index in the same migration:
-- Allow re-use of soft-deleted identifiers:
CREATE UNIQUE INDEX films_id_live_only ON films (id) WHERE deleted_at IS NULL;
ALTER TABLE films DROP CONSTRAINT films_pkey;
ALTER TABLE films ADD CONSTRAINT films_pkey PRIMARY KEY (id) DEFERRABLE INITIALLY IMMEDIATE;
The partial-unique-index pattern is an opt-in schema customisation; the framework does not emit it automatically.
#[behavior(timestampable)]
Worked example
use ferra::*;
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")]
#[behavior(timestampable)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub title: String,
}
The framework injects created_at: jiff::Timestamp and updated_at: jiff::Timestamp at the end of the field list, both flagged read_only: true (present in the default-read, absent from the default-write).
The migration
ALTER TABLE films ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE films ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
NOT NULL DEFAULT NOW() ensures that pre-existing rows in the table receive a value at migration time. The framework’s runtime SQL stamps both columns on every INSERT and advances updated_at on every UPDATE, so the DEFAULT NOW() only matters for the migration path.
What changes on the wire
POST /films→ the response carriescreated_atandupdated_atpopulated to the server’s current time. The request body cannot set them; values submitted in either field are silently ignored.PATCH /films/{id}→ the response carries the advancedupdated_at;created_atis unchanged.GET /films/{id}andGET /films→ both fields appear on every row.
The “silently ignored on submit” behaviour follows from the read_only: true flag: the default-write projection excludes both fields, and the deserialisation rejects unknown fields when strict-projection-write is on (the framework’s default). Submitting a body with an explicit created_at value either returns a 422 (strict mode) or silently drops it (lenient mode) — the persisted value is always the framework’s NOW() stamp.
Combined behaviors
#[behavior(soft_delete, timestampable)] enrols both. The framework injects all three fields and the restore action. The composition behaves as the union of the two individual behaviors:
#[derive(
Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
#[behavior(soft_delete, timestampable)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub title: String,
}
Composition rules:
DELETE /films/{id}rewrites toUPDATE films SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL. Soft-delete refreshesupdated_atbecause the soft-delete is logically a write.POST /films/{id}/restoreemitsUPDATE films SET deleted_at = NULL, updated_at = NOW() WHERE id = $1 AND deleted_at IS NOT NULL. Restore refreshesupdated_at;created_atstays at the original creation time.- The order of names is irrelevant —
#[behavior(timestampable, soft_delete)]produces the sameModelMetaas#[behavior(soft_delete, timestampable)].
The combined migration:
ALTER TABLE films ADD COLUMN deleted_at TIMESTAMPTZ NULL;
ALTER TABLE films ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE films ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
Common pitfalls
Manually declaring an injected column on the struct
#[behavior(soft_delete)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub title: String,
pub deleted_at: Option<jiff::Timestamp>, // ← compile error
}
The framework emits FRG-323 at proc-macro time:
error: field `deleted_at` conflicts with the soft_delete behavior's auto-injected field
help: remove the field declaration; the behavior injects it automatically
The same diagnostic applies to created_at and updated_at under #[behavior(timestampable)]. Behaviors own their injected column shape; if you need a column whose name happens to match one of the framework’s reserved names but holds different data, choose a different column name.
Forgetting the AllowsHardDelete marker on a custom processor
A FerraProcessor registered against a soft-delete model that issues a physical DELETE triggers a boot-time panic:
the model `Film` is enrolled in the soft-delete behavior AND a custom processor
`PurgeProcessor` is registered against it. The processor MUST opt in via
`impl AllowsHardDelete for PurgeProcessor {}` to acknowledge that a physical
delete bypasses the soft-delete safety contract. See
https://ferra.rs/guide/behaviors#the-allowshard-delete-opt-in-marker.
Add the marker (impl AllowsHardDelete for PurgeProcessor {}) or rewrite the processor to soft-delete instead. The check runs at Foundry::build() — it does not require a request to surface.
Submitting a timestamp value in a request body
A POST /films request whose body carries created_at: "2020-01-01T00:00:00Z" does NOT plant the past date — the framework silently ignores submitted values for behavior-injected timestamps. The persisted value is the server’s NOW() at the moment of the INSERT. This is by construction (the field is excluded from the default-write projection).
If your application needs to record a different “as-of” date (e.g., for retroactive imports), declare a separate column with a non-reserved name (recorded_at, effective_at, …) and write it normally — the framework’s behaviors own only the names listed in the closed vocabulary above.
Unknown behavior name
#[behavior(softdelete)] // typo — missing underscore
The framework emits FRG-320 at proc-macro time:
error: unrecognised behavior name `softdelete`
help: did you mean `soft_delete`?
note: recognised behaviors: soft_delete, timestampable.
See docs/user-guide/behaviors.md.
The recognised vocabulary is closed; future releases add new names additively (the #[non_exhaustive] BehaviorKind enum makes the additions non-breaking by construction).
Empty #[behavior()]
#[behavior()] // empty
The framework emits FRG-321:
error: behavior attribute requires at least one name
help: remove the attribute or supply at least one of: soft_delete, timestampable
An empty attribute is rejected because it has no effect and most likely indicates an editor template that was not filled in.
Non-identifier syntax inside the attribute
#[behavior(soft_delete = true)] // key/value — not the grammar
#[behavior("soft_delete")] // string literal — not the grammar
Both fire FRG-322 at proc-macro time. The grammar is bare comma-separated identifiers; key/value pairs and string literals are reserved for future widening.
Forward compatibility
The behaviors namespace is #[non_exhaustive] at the IR level — future releases add behaviors additively without breaking existing call sites. Future releases will likely add behaviors such as audit_log, tenanted, versioned, and other model-level concerns — the namespace is designed for additive growth and no specific shape is contracted at this release. Argument-bearing behaviors (e.g., #[behavior(versioned(strategy = "snapshot"))]) are explicitly not part of the 0.7.0 surface; the grammar widens additively when the first argument-bearing behavior lands.
ferra-openapi
ferra-openapi is the framework’s documentation surface. It builds
an OpenAPI 3.1 specification from a model’s ModelMeta description
and serves the spec at /docs/openapi.json plus an interactive
Scalar UI at /docs. Both surfaces are derived end-to-end from the
same #[model] source of truth that drives the framework’s HTTP
routes, SQL queries, and hypermedia links.
The crate ships in 0.5.0 Rolling. The recommended call site is
Foundry::with_docs() (landing in 0.5.0 Rolling alongside this
crate); the underlying free functions in this crate stand alone for
direct use cases and for the per-resource integration test suite.
What you get for free
A Ferra #[model] mounted through Foundry::with_docs() produces:
GET /docs/openapi.json— fullOpenAPI3.1 document.GET /docs— interactive Scalar UI rendering the spec.
Both routes are publicly reachable by default — the inventory is the
feature. For deployments where the endpoint structure is itself
sensitive, the auth-gated variant
(Foundry::with_docs_protected(verifier)) ships in the same release
and gates the surface behind authentication. See
§“Public-by-default rationale” below for when to choose which.
For each mounted model M (PascalCase struct name), the spec
contains:
- Five paths (
GET /{resource},GET /{resource}/{id},POST /{resource},PUT /{resource}/{id},DELETE /{resource}/{id}). - Four projection schemas (
{Model},Create{Model}Input,Update{Model}Input,{Model}Collection). - A shared
ProblemDetailsschema referenced by every error response. - Every operation carries an
operationIdand atagsarray containing the resource name. Zero inline schemas in operation definitions; every body schema is a$refintocomponents/schemas.
Enabling the docs surface
The headline call site is Foundry::with_docs():
use ferra::Foundry;
let app = Foundry::new(conn)
.mount::<Film>()
.with_docs()
.build();
That single chain produces a runnable HTTP service mounting Film’s
five CRUD endpoints plus /docs/openapi.json and /docs with
zero boilerplate.
Variants
| Method | Effect |
|---|---|
.with_docs() | Public docs at /docs and /docs/openapi.json. |
.with_docs_at("/api-docs") | Public docs at the supplied prefix. |
.with_docs_protected(verifier) | Auth-gated docs at /docs; anonymous requests get 401 + RFC 7807 body. |
.with_docs_protected_at("/api-docs", verifier) | Auth-gated at the supplied prefix. |
Public-by-default rationale
The framework’s default-deny posture on model routes does not extend to the docs surface — and the deviation is security-positive, not a convenience trade-off.
/docs/openapi.json is generated from the same ModelMeta IR that
drives the routes. By construction, every endpoint a Ferra
application exposes is documented, with no manual schema to keep
in sync. This structurally closes OWASP API Security Top 10
API9:2023 (Improper Inventory Management) — the gap between the API
a team thinks it exposes and the API actually deployed. Salt
Security research cited in OWASP API9 measures that gap at 40% on
average in production (one cited case: 54 undocumented endpoints, 12
exposing PII). The public default makes the complete inventory
legible to any operator without an opt-in step; a deny-by-default
/docs would force every team to opt into seeing what their own API
exposes, which is the inverse of the API9 recommendation.
For deployments that must restrict who can read the inventory,
.with_docs_protected(verifier) ships in the same release and gates
the surface behind authentication — operators keep that floor
without changing crates.
Rule of thumb: prefer .with_docs() for development, internal
tools, public APIs intentionally discoverable, and AI-consumable
surfaces (the inventory is the feature). Prefer
.with_docs_protected(verifier) for any production deployment of an
internal-only API whose endpoint structure is itself sensitive.
The deviation from default-deny is recorded in ADR-0024 for the curious reader; this guide stands alone.
Naming conventions
Schema names
For a model named Film with no named projections declared, the spec
carries four entries in components/schemas:
| Projection | OpenAPI schema name | Purpose |
|---|---|---|
| Read | Film | Response shape for GET and the body of POST / PUT responses. |
| Create | CreateFilmInput | Request body of POST /films. |
| Update | UpdateFilmInput | Request body of PUT /films/{id}. |
| Collection | FilmCollection | Response shape for GET /films ({ items: [Film], page, per_page, total }). |
The schema name derives from resource_name (the URL path slug) via
PascalCase singularisation.
Per-projection schemas
A model that declares one or more #[ferra(projection(...))] blocks
emits an additional triple of schemas per named projection. For
Film with one named admin projection (#[ferra(projection(name = "admin", read = […], write = […]))]):
| Projection | OpenAPI schema name | Operations served at |
|---|---|---|
| Read | FilmAdmin | GET /admin/films/{id}, GET /admin/films |
| Create | CreateFilmAdminInput | POST /admin/films |
| Update | UpdateFilmAdminInput | PUT /admin/films/{id} |
| Collection | FilmAdminCollection | GET /admin/films (pagination envelope) |
The general rule:
- Read schema:
{Model}{Projection-PascalCase}. - Create schema:
Create{Model}{Projection-PascalCase}Input. - Update schema:
Update{Model}{Projection-PascalCase}Input.
For the default projection the {Projection-PascalCase} segment is
the empty string, so the rule produces Film, CreateFilmInput,
UpdateFilmInput.
Each schema’s properties map carries exactly the field set the
corresponding projection declares — no schema reuse via $ref from
one projection to another. A reader scanning the document sees the
per-projection shape without dereferencing chains.
Names are SDK-friendly: orval, openapi-generator, kiota, and their
peers produce idiomatic, distinguishable types in the target language
without post-processing. The framework’s internal Rust struct names
(FilmReadProjection, FilmAdminReadProjection, …) NEVER leak into
the published document — components/schemas keys, $ref values,
operation IDs, and descriptions all carry only the public names from
the tables above.
x-ferra-promoted-in vendor extension
When a named projection carries default = true with a
breaking_change_version = "X.Y.Z" attestation (per
ADR-0030
§Default-projection promotion safeguards), every operation served
under the bare resource path gains an x-ferra-promoted-in: "X.Y.Z"
vendor extension. SDK generators and downstream tooling
(ferra anvil) surface the silent breaking change to consumers who
might otherwise see only the URL stability.
operationId
Every operation carries a stable operationId:
| Method | Path | operationId |
|---|---|---|
GET | /films | listFilms |
GET | /films/{id} | getFilm |
POST | /films | createFilm |
PUT | /films/{id} | updateFilm |
DELETE | /films/{id} | deleteFilm |
Versioned chains (Foundry::api_version("v1")) prefix the
operationId with the version segment: v1.listFilms,
v1.getFilm, etc.
tags
Every operation carries a single tag — the resource name in
snake_case plural (films, actors). Scalar / Redoc / Swagger UI
group operations by tag for easier navigation.
Field type mapping
Field types map to OpenAPI 3.1 schemas as follows:
| Rust type | OpenAPI |
|---|---|
String | { "type": "string" } |
i32 | { "type": "integer", "format": "int32" } |
i64 | { "type": "integer", "format": "int64" } |
f32 / f64 | { "type": "number" } |
bool | { "type": "boolean" } |
ferra::Id / Uuid | { "type": "string", "format": "uuid" } |
Option<T> | nullable T ({ "type": ["string", "null"] }) and absent from required |
ferra::DateTime | { "type": "string", "format": "date-time" } |
ferra::Date | { "type": "string", "format": "date" } |
Option<T> uses the OpenAPI 3.1 nullable form (a type array
containing "null"), not the deprecated 3.0 nullable: true
keyword.
The two typed time newtypes (ferra::DateTime and ferra::Date)
are recognised by the framework and emit the right format. Direct
use of legacy time crates (chrono::*, time::*) is rejected at
the field-declaration site — see the §“Time vocabulary” section of
ferra-core.md.
Field annotations
Two #[field(...)] attributes shape what appears in each
projection.
#[field(skip)]
Excludes the field from every projection schema and from the wire contract. Useful for server-only state (audit timestamps, internal flags).
#[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.
#[ferra(read_only)]
The field appears in every read schema (Film and any per-projection
read schema where the field is included) marked readOnly: true, and
is structurally absent from every create / update input schema.
Useful for server-assigned values that the consumer can read but
cannot write.
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[ferra(read_only)]
indexed_at: ferra::DateTime,
}
indexed_at appears in Film with readOnly: true; both
CreateFilmInput and UpdateFilmInput omit the property entirely.
If the model also declares a named admin projection that includes
indexed_at in its read = [...] list, the field appears in
FilmAdmin with readOnly: true and is omitted from
CreateFilmAdminInput / UpdateFilmAdminInput.
#[ferra(write_only)]
The field appears in every write schema (CreateFilmInput /
UpdateFilmInput / per-projection input schemas where the field is
included), and is structurally absent from every read schema —
not marked writeOnly: true on the read side, but simply missing.
Useful for fields the consumer must supply on write but should never
see on read (one-shot secrets, transient hints, write-side credentials).
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[ferra(write_only)]
new_password: String,
}
new_password appears in CreateFilmInput and UpdateFilmInput;
the Film read schema omits the property entirely. The framework
guarantees the field cannot leak via a read operation — its absence
is enforced at compile time on the generated read-projection struct.
#[ferra(computed)]
The field is server-derived (e.g., a derived count, a denormalised
projection of joined rows). It appears in every read schema with
readOnly: true, and is structurally absent from every write input
schema — the framework calls FerraComputed::compute() /
compute_async_batch() on read and never accepts the field as inbound
input.
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[ferra(computed)]
review_count: i64,
}
review_count appears in Film with readOnly: true; the create
and update inputs omit the property entirely. See
ferra-core.md § Computed fields
for the FerraComputed trait that drives the compute pipeline.
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:
use ferra::Foundry;
use utoipa_redoc::{Redoc, Servable};
let app = Foundry::new(conn)
.mount::<Film>()
.build() // no .with_docs()
.merge(ferra_openapi::docs_routes( // hand-mount the JSON
ferra_openapi::build_openapi(
ferra_openapi::info("Hello", "0.5.0"),
"Film",
Film::meta(),
),
"/docs",
))
.merge(Redoc::with_url("/redoc", openapi)); // and Redoc on top
The same pattern works with utoipa-rapidoc and utoipa-swagger-ui.
Where the spec is built
build_openapi(info, model_name, meta) assembles the full document
from a model’s static ModelMeta reference. The function runs once
at process startup; the resulting OpenApi value is cached and
served unchanged on every /docs/openapi.json request — no
per-request rebuild.
For the multi-model assembly, build_openapi_for_models(info, &[ModelEntry, ...]) produces a single document covering every
mounted model. Foundry::build() calls this internally; consumers
who want to assemble a spec by hand can call it directly.
Linting your spec
The framework’s CI runs Spectral
against every reference example’s generated /docs/openapi.json,
extending the default spectral:oas ruleset with four Ferra-specific
rules: every operation declares a tags array, every operation
declares an operationId, every request body schema is a $ref into
components/schemas, and every response schema is a $ref into
components/schemas. New linter findings at the warn severity bar
block the build.
Two takeaways for consumer-side CI:
- Spectral is the recommended linter for a Ferra-served API.
The framework’s
.spectral.yaml(in the workspace root) is a reasonable starting ruleset. Copy it into a downstream repo and add deployment-specific rules (e.g., re-enableoas3-api-serversonce the deployment carries a non-emptyserversarray). - The rule selection is not arbitrary. The full
adopted / deferred / rejected matrix — including why Spectral wins
over Redocly, why Vacuum is rejected, and why
progenitor/openapi-generator/orvalsmoke-tests are deferred rather than adopted — lives in the contributor research memo atdocs/research/dropshot-openapi-conformance.md. Curious readers consult that memo; correct use of the gate does not require it.
See also
Foundry— the assembly facade that mounts the docs surface alongside resource routes (lands alongside this crate in 0.5.0).- Error handling — the closed
ERROR_TYPESURI namespace and theProblemDetailsconsumer- side branching pattern. ferra-core§Time vocabulary — the typedDateTime/Datenewtypes that emitformat: date-timeandformat: datein the spec.
For curious readers, the architectural decisions behind the docs
surface are recorded in ADR-0024 (crate scope + public-default
posture), ADR-0026 (closed ERROR_TYPES URI namespace), and
ADR-0003 v2 (Scalar over Swagger UI as the default UI). These
ADRs are not load-bearing for correct use of the feature; this
guide stands alone.
ferra-forge — the #[derive(FerraModel)] derive
Status: 0.2.0 Smelting.
Reader contract. This page stands on its own. A reader with only this page plus standard Rust and Sea-ORM knowledge can produce a compiling entity on the first attempt. Cross-references to ADRs and the constitution are non-load-bearing — every correctness cue is spelled out below.
ferra-forge is the Ferra proc-macro crate. It exports one
procedural derive, #[derive(FerraModel)], which binds a Rust struct
to its static ferra_core::meta::ModelMeta description. The derive
cohabits with Sea-ORM’s DeriveEntityModel on the same struct —
neither replaces the other; both read the struct.
Canonical entity shape (sibling-derive)
The canonical 0.2.0 entity is a plain Rust struct with six derives and two container attributes:
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub release_year: Option<i32>,
pub rating: f64,
}
// Sea-ORM boilerplate that `DeriveEntityModel` relies on.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
Key facts:
DeriveEntityModelis the Sea-ORM half. It owns the#[sea_orm(...)]namespace, generates theEntity,Column,PrimaryKey,Relation, andActiveModeltypes, and is required on everyFerraModelstruct.FerraModelis the Ferra half. It reads#[sea_orm(primary_key)]and#[sea_orm(table_name)], plus the#[ferra(...)]namespace, and emits a staticModelMetathat every downstream Ferra crate reads.Serialize+Deserializeare required by theFerraModelsupertrait bound.ferra-forgedoes not auto-inject them — see the next section.- Visibility must be at least
pub(crate). Private structs trip class 8 of the Troubleshooting table.
Recognised field types:
| Rust type | FieldType variant | Notes |
|---|---|---|
String | String | UTF-8 string. |
i32 | I32 | 32-bit signed integer. |
i64 | I64 | 64-bit signed integer. |
f64 | F64 | 64-bit IEEE-754 float. |
bool | Bool | Boolean. |
Uuid (or ferra_core::id::Id) | Uuid | UUID, always present. |
Option<String> | OptionString | Nullable string. |
Option<i32> | OptionI32 | Nullable 32-bit integer. |
Option<i64> | OptionI64 | Nullable 64-bit integer. |
Option<f64> | OptionF64 | Nullable float. |
Option<bool> | OptionBool | Nullable boolean. |
Option<Uuid> (or Option<Id>) | OptionUuid | Nullable UUID — added in 0.6.0 Welding (FR-014/FR-024). Surfaces in OpenAPI as "type": ["string","null"] with "format": "uuid" (the OpenAPI 3.1 nullable form). Cannot be combined with #[sea_orm(primary_key)] — that contradiction emits FRG-215. |
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.
Three attribute namespaces, two policies
ferra-forge reads three attribute namespaces with deliberately
different handling:
| Namespace | Owner | Purpose | Unknown-key policy |
|---|---|---|---|
#[sea_orm(...)] | Sea-ORM | Persistence shape (table name, primary key, column type) | silently ignored by ferra-forge |
#[ferra(...)] | Ferra | Exposure flags (read_only, write_only, skip, resource) | hard compile_error! |
#[field(...)] | Ferra | Declarative validation rules (min_length, email, …) — see § Validation rules | hard compile_error! (FRG-213) |
The asymmetry is load-bearing. Consider the worked example:
// Security-critical typo. The user MEANT `write_only`.
#[ferra(writeonly)]
pub password_hash: String,
Because #[ferra(writeonly)] is not recognised, the build fails:
error: `#[ferra(writeonly)]` is not a recognized attribute
--> src/models/user.rs:12:13
|
12 | #[ferra(writeonly)]
| ^^^^^^^^^
= help: did you mean `write_only`?
= note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase
If Ferra had accepted the typo, password_hash would quietly serialise
into API responses — a Broken Object Property Level Authorization
(OWASP API Top 10 #3). The hard-fail rule makes this class of bug
unshippable.
Conversely, an unknown-to-Ferra but Sea-ORM-permitted key such as
#[sea_orm(rename_all = "camelCase")] passes through Ferra silently
— it is Sea-ORM’s concern, not Ferra’s, and pre-validating it inside
ferra-forge would saddle Ferra with every Sea-ORM minor-release
vocabulary change.
Recognised #[ferra(...)] keys
Container-level
| Key | Value | Semantics |
|---|---|---|
resource | non-empty string literal | Override the default resource_name in the emitted ModelMeta. |
The default resource_name is the #[sea_orm(table_name)] literal
if present, otherwise snake_case(<StructIdent>) with a simple
English plural suffix (Film → films, UserProfile → user_profiles,
Category → categories, Box → boxes).
Declarative behaviors (
#[behavior(soft_delete)],#[behavior(timestampable)], and the cross-cutting cohort surface they share) are documented separately in behaviors.md. Enrolling a model in a behavior is a container-level act — declaring the behavior on the struct opts every operation on that model into the cohort’s contract.
Field-level
| Flag | readable | writable | skip_api |
|---|---|---|---|
| (none) | true | true | false |
#[ferra(read_only)] | true | false | false |
#[ferra(write_only)] | false | true | false |
#[ferra(skip)] | false | false | true |
#[ferra(read_only)] and #[ferra(write_only)] on the same field
contradict — see class 10. #[ferra(write_only)] on a primary-key
field contradicts the id’s always-readable invariant — see class 12.
Repeated identical flags are idempotent.
#[ferra(write_only)] is structurally excluded from every read
#[ferra(write_only)] is the framework’s load-bearing primitive for
fields that must be inbound-accepting but never outbound-leaking —
canonically a password hash, an API-key plaintext, or any other
write-side credential.
#[derive(DeriveEntityModel, FerraModel, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
#[ferra(write_only)]
pub password_hash: String,
}
What the framework guarantees:
POST /usersandPUT /users/{id}acceptpassword_hashin the request body, validate it, and persist it through the standard insert / update pipeline.- Every read response — single-item
GET /users/{id}, collectionGET /users, and the create / update response envelopes — omitspassword_hash. There is no header, query parameter, orAccept:variant that flips the field on. - The same exclusion applies to every named projection: the
auto-derived
{Model}{Projection}ReadProjectionstruct emitted byferra-forgedoes not have the field as a member, so the type system itself blocks any future handler from re-introducing it.
Structural-by-construction, not runtime-filter
The guarantee is enforced at the Rust type-system layer, not by a
runtime serialiser flag. The ferra-forge proc-macro emits one
pub struct {Model}ReadProjection per model whose field list does
not contain any #[ferra(write_only)] field; mutation and read
handlers serialise through that struct’s allowlist before the JSON
leaves the framework. A future custom handler that hand-writes a
response cannot inadvertently leak password_hash because the field
does not exist on the type the response carries.
Prior to 0.6.5 the exclusion lived only in the OpenAPI emitter and a runtime serialiser filter; the 0.6.5 promotion to a structural guarantee closes the OWASP API #3 mitigation gap.
There is no “opt-in” — FRG-305 rejects the obvious workaround
Listing a #[ferra(write_only)] field in any projection’s
read = [...] is rejected at compile time with diagnostic FRG-305:
error: FRG-305: projection `admin` lists field `password_hash` in `read`,
but the field is declared `#[ferra(write_only)]`
= help: remove the ident from `read = [...]`, or remove `#[ferra(write_only)]`
from the field declaration
If you genuinely need the field to appear in some read surface, the
resolution is to drop #[ferra(write_only)] from the field
declaration entirely. The field then appears in every read by
default; use #[ferra(skip)] or a per-projection read = [...]
allowlist to scope the surfaces that include it. See
projections-and-routing.md for the
projection key set and the auto-derived URL prefix contract.
The full FRG-3NN diagnostic family for write-only-related mistakes
(FRG-302 through FRG-307, FRG-314) is documented in
ferra-forge-diagnostics.md.
Computed fields
A computed field is server-derived: the framework runs the
developer’s compute function after every fetch and before
serialisation, then carries the produced value out through the read
response. The field is excluded from INSERT / UPDATE SQL and
from every write projection’s struct, so submitted values in request
bodies are silently dropped — the only path to set the field is the
compute hook the developer writes.
The substrate ships at 0.6.5 Chasing per ADR-0031 and the
FerraComputed trait surface contract.
The #[ferra(computed)] attribute
Tag the field with #[ferra(computed)] and write an
impl FerraComputed for {Model} block that overrides the method
matching your computation kind:
use ferra::*;
use ferra::compute::{FerraComputed, RequestContext};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Invoice {
#[sea_orm(primary_key)]
pub id: Id,
pub subtotal: f64,
pub tax: f64,
#[ferra(computed)]
pub total: f64,
}
impl FerraComputed for Invoice {
fn compute(&mut self) {
self.total = self.subtotal + self.tax;
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
#[ferra(computed)] implies writable = false and read_only = true.
The framework injects a ferra_core::diagnostics::FerraComputedDefault
bound on the field’s type — see the Compile-time rejections
sub-section below for the consequence when that bound fails.
The three tiers
FerraComputed exposes three default-implemented methods covering the
spectrum from pure derivations to N+1-eliminating batched I/O. An
implementer overrides only the method matching the computation kind.
fn compute(&mut self)— synchronous, zero I/O. The right choice fortotal = subtotal + taxand other in-row derivations. Cost shape: one function call per row, no async overhead. Default: no-op.fn compute_async(&mut self, ctx: &RequestContext)— per-row async with a request-context handle. The right choice for per-row I/O (currency-rate lookup, external HTTP enrichment). Cost shape: one I/O round-trip per row — invoke this tier only when the computation genuinely cannot be batched. At 0.6.5RequestContextis an empty marker (seecompute_async_batchbelow for the pool-threading limitation); it widens in 0.7.x. Default: returnsOk(()).fn compute_async_batch(rows: &mut [Self], ctx: &RequestContext)— collection-level batch hook. Called once per read with the full slice. The default implementation iteratescompute_asyncsequentially over every row; override this method to perform a single batched I/O call across the slice. This is the N+1 elimination path.
Read pipeline
For every read (item or collection) of a model with at least one
#[ferra(computed)] field, the framework runs the pipeline in this
order:
- The repository fetches the row(s) from the data source.
- The framework calls
compute(&mut self)on each row in sequence. - The framework calls
Self::compute_async_batch(&mut rows, ctx).await?once with the full slice. - The framework serialises each row through the projection’s read struct.
Uniform-invocation guarantee. Item reads (GET /invoices/{id})
pass a one-element slice to compute_async_batch. The developer
writes one batch implementation; it covers both the item and
collection surfaces without branching.
N+1 elimination pattern
The batch hook is where you fold a per-row I/O into a single
round-trip. The canonical shape is a WHERE id = ANY($1) query
keyed on the slice’s identifiers:
use ferra::*;
use ferra::compute::{FerraComputed, FerraComputeError, RequestContext};
use std::collections::HashMap;
impl FerraComputed for FilmWithCommentCount {
fn compute(&mut self) {
// No synchronous derivation; the batch hook supplies the value.
}
async fn compute_async_batch(
rows: &mut [Self],
_ctx: &RequestContext,
) -> Result<(), FerraComputeError> {
// Collect every row's id, then fetch the matching counts in
// a single SQL round-trip.
let ids: Vec<Id> = rows.iter().map(|r| r.id).collect();
// At 0.6.5 the pool is threaded through application state; at
// 0.7.x it will live on `RequestContext`. Replace `app_pool()`
// with the pool handle of your choice.
let counts: HashMap<Id, i64> = sqlx::query_as::<_, (Id, i64)>(
"SELECT film_id, COUNT(*) FROM comments \
WHERE film_id = ANY($1) GROUP BY film_id",
)
.bind(&ids[..])
.fetch_all(app_pool())
.await
.map_err(|e| FerraComputeError::new(e.to_string()))?
.into_iter()
.collect();
for row in rows {
row.comment_count = counts.get(&row.id).copied().unwrap_or(0);
}
Ok(())
}
}
0.6.5 limitation — explicit. RequestContext is an empty marker
at this release. Until 0.7.x widens it, thread the database pool
through your application state (a tokio::sync::OnceCell<PgPool>, a
Arc<AppState> in your handlers, or the pattern of your choice) and
reach it from inside the batch hook. The contract for the trait
method does NOT change when RequestContext widens — the parameter
shape is stable; only the values reachable through it grow.
Forward-pointer to the post-v1.0 DataLoader pattern. The
compute_async_batch contract is forward-compatible with a future
request-scoped loader registry on RequestContext: a compute_async
implementation that calls ctx.loader::<L>().load(key).await will
coalesce all loads in the request into one batch_load(keys) call.
Override-based batch hooks and loader-based per-row hooks compose
without conflict.
What the framework does for you
Tagging a field #[ferra(computed)] is one declaration with effects
on both ends of the wire:
| Side | Effect |
|---|---|
| Inbound | The field is silently dropped from request bodies. POST /invoices and PUT /invoices/{id} accept subtotal + tax and ignore any client-supplied total. |
| SQL | INSERT and UPDATE statements skip the column. The database does not have to carry it — and even if your schema persists the column, the framework will not write to it; reads ignore the persisted value and replace it with the compute output. |
| Outbound | The OpenAPI document marks the field readOnly: true on every read schema. SDK generators that consume the spec (orval, openapi-generator, kiota) surface the field as read-only on the client side. |
| Read response | The response carries the value produced by the compute hook — compute() runs first, then compute_async_batch runs once across the slice, then serialisation walks the read projection. |
Missing-impl behaviour (FR-015). A model that declares
#[ferra(computed)] on a field but does not provide an
impl FerraComputed for Model { ... } block — or that provides an
empty impl FerraComputed for Model {} block adopting every default
— is not a compile error. The framework substitutes the trait’s
default methods (no-op compute, Ok(()) async hooks), and the
field is serialised with its type’s Default::default() value. The
framework never panics on this path.
Compile-time rejections
The 0.6.5 FRG-3NN diagnostic family covers the contradictions; the
full catalogue lives in
ferra-forge-diagnostics.md. The
codes that matter for computed fields:
| Code | Trigger | Fix |
|---|---|---|
FRG-303 | #[ferra(write_only)] + #[ferra(computed)] on the same field. A computed value is server-produced — it cannot also be inbound-only. | Drop one of the two flags. Keep #[ferra(computed)] if the value is derived; keep #[ferra(write_only)] if the value is client-supplied and write-only. |
FRG-309 | #[ferra(computed)] + explicit #[ferra(read_only)] on the same field. computed already implies read_only; the second flag is redundant. | Remove the redundant #[ferra(read_only)]. |
The FerraComputedDefault #[diagnostic::on_unimplemented]
triple. The framework injects a FerraComputedDefault bound on
every #[ferra(computed)] field’s type. When the chosen type does
not implement Default, rustc fails the bound and surfaces the
framework-authored diagnostic verbatim:
error[E0277]: `MyType` is used as the type of a `#[ferra(computed)]` field, but it does not implement `Default`
--> src/models/invoice.rs:8:5
|
8 | pub total: MyType,
| ^^^^^^^^^^^^^^^^^ this type needs `impl Default for MyType`
|
= note: Ferra calls `T::default()` when a computed field's compute trait is not implemented (FR-015 — missing implementations are a no-op, not a panic). The `Default` bound is required by the computed-field contract; see `docs/user-guide/ferra-forge.md#computed-fields`. Diagnostic emitted by `ferra_core::diagnostics::FerraComputedDefault`.
Every type in the recognised-field-types table above already
implements Default (numbers default to zero, String to "",
Option<T> to None, Uuid to nil, Id to a fresh v4 UUID). The
diagnostic fires only on custom field types where you’ve forgotten
the #[derive(Default)] line.
Deferred keys and when they land
The following keys are rejected in 0.2.0 (hard compile_error!)
and will arrive in a later roadmap phase. If you reach for any of
them, you get an unknown-key diagnostic — that is intentional, so you
don’t silently compile code that looks correct but does nothing.
| Key | Planned phase | Intended use |
|---|---|---|
hypermedia | 0.3.0 Casting | Control HAL _links emission per field. |
projection / projections | 0.4.0 Refining | Typed projections (ServerFields<M>). |
exposure | 0.4.0 Refining | Exposure::Strict / Exposure::Loose. |
sortable / filterable / searchable | 0.4.0+ | Query DSL primitives. |
cascade | 0.6.0+ | Relation cascade rules. |
auth | 0.8.5 | Per-model auth scopes. |
rate_limit | 0.8.5+ | Tower rate-limit layer binding. |
required | — | Never recognised — nullability is deduced from Option<T>. |
Validation rules
Ferra’s third attribute namespace, #[field(...)], declares
validation rules that fire at the API edge — before persistence,
on every POST (create) and PUT (update) request. A payload
that violates any rule is rejected with a 422 Problem+JSON body
whose errors map names every offending field; a payload that
satisfies every rule flows on to persistence unchanged. Validation
is opt-in: a model carrying no #[field(...)] attributes
behaves identically to one written before the namespace existed.
The full 422 wire shape and consumer-side branching pattern live in Error Handling § Validation failures (422); this section pins the model-side declaration grammar.
The eight rules at a glance
| Rule | Compatible field types | Default English message on violation |
|---|---|---|
min_length = N | String, Option<String> | "must be at least N character(s)" (singular when N == 1) |
max_length = N | String, Option<String> | "must be at most N character(s)" (singular when N == 1) |
min = N | i32, i64, f64, and their Option<…> | "must be at least N" |
max = N | i32, i64, f64, and their Option<…> | "must be at most N" |
pattern = "regex" | String, Option<String> | "does not match the required format" |
email | String, Option<String> | "is not a valid email address" |
url | String, Option<String> | "is not a valid URL" |
required | Option<T> only | "is required" |
Any other rule key is a compile-time error (FRG-213) carrying
a “did you mean” hint when the typo is within Levenshtein
distance 2 of a recognised name. Repeated rules of the same
kind on the same field are FRG-206. Conflicts (min_length > max_length, min > max) are FRG-210 / FRG-211. A rule
applied to an incompatible field type is FRG-207 / FRG-208
/ FRG-209 / FRG-212 depending on the rule. A wrong-shape
literal (a string where an integer is expected, an empty regex)
is FRG-201 through FRG-205. Every code surfaces with a
span anchored on the offending token, and multiple independent
mistakes on the same struct surface in a single cargo check
round-trip rather than the first-and-stop pattern.
Combined-rule example (the most common shape)
The headline call site is a single #[field(...)] carrying
multiple rules separated by commas, on a struct that already
derives FerraModel and Sea-ORM’s DeriveEntityModel:
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 = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1, max_length = 255)]
pub display_name: String,
#[field(min = 0, max = 120)]
pub age: i32,
#[field(email)]
pub contact_email: String,
#[field(pattern = r"^[a-z0-9-]+$")]
pub slug: String,
#[field(url)]
pub homepage: String,
#[field(required)]
pub bio: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
Two or more #[field(...)] attributes on the same field
accumulate in source order — #[field(min_length = 1)] #[field(max_length = 255)] is identical to #[field(min_length = 1, max_length = 255)]. Two #[field(...)] attributes on
two different fields are independent; each field’s rule list is
evaluated in isolation against that field’s value, and every
violation observed on the request is aggregated into a single
422 response (Ferra never short-circuits on the first failed
rule).
Per-rule details
min_length = N and max_length = N
Fires on String and Option<String> fields. The literal N
is a non-negative integer that fits in u32. On Option<String>
the rule applies only when the value is Some(..) — a None is
not a length violation (use required to forbid None outright).
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,
#[field(min_length = 1, max_length = 255)]
pub title: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /films with {"title": ""} returns 422 with
errors.title = ["must be at least 1 character"] (singular —
length rules pluralise only when N != 1). A 300-char title
returns 422 with errors.title = ["must be at most 255 characters"].
Length rules count Unicode scalar values, not grapheme clusters or UTF-8 byte length — see § Unicode counting below.
min = N and max = N
Fires on i32, i64, f64, and their Option<…> forms. The
literal type must match the field’s declared numeric type:
min = 1.5 on an i32 field is FRG-203. Integer literals on
f64 fields are accepted (and coerce to 0.0-style values).
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 = "ratings")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min = 0, max = 10)]
pub score: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /ratings with {"score": 99} returns 422 with
errors.score = ["must be at most 10"].
pattern = "regex"
Fires on String and Option<String> fields. The literal is
a non-empty string carrying a regex-crate
pattern (NOT PCRE). The regex crate has no lookaround and no
backreferences — the syntactic features that drive catastrophic
backtracking in PCRE-style engines are not even expressible.
A raw-string literal (r"…") is the recommended shape so
backslashes are not double-escaped.
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 = "posts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(pattern = r"^[a-z0-9-]+$")]
pub slug: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /posts with {"slug": "Bad Slug!"} returns 422 with
errors.slug = ["does not match the required format"].
Bounded-time guarantee. Every
patternevaluation completes in time linear to the input length —O(m·n)worst-case, wheremis the (compile-time-fixed) pattern size andnis the input length, per theregexcrate’s upstream contract. Combined with Ferra’s default 1 MiB request-body cap, this forecloses ReDoS without a per-request timeout. You can write anyregex-syntax pattern you like and submit it to user input; no pattern, regardless of shape, can cause unbounded matching time.
email
Bare flag on String and Option<String> fields — no value.
Delegates to the upstream HTML5-compliant email validator.
#[field(email = "x")] is FRG-214.
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 = "contacts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(email)]
pub email: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /contacts with {"email": "not-an-email"} returns
422 with errors.email = ["is not a valid email address"].
url
Bare flag on String and Option<String> fields — no value.
Delegates to the upstream url-crate-backed validator.
#[field(url = "x")] is FRG-214.
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 = "links")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(url)]
pub href: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /links with {"href": "not a url"} returns 422 with
errors.href = ["is not a valid URL"].
required
Bare flag on Option<T> fields only — no value.
#[field(required)] on a non-Option field is FRG-212
(non-Option fields are required by Rust’s type system; the
flag is meaningless there). On a POST request, an absent
field or an explicit null both fail with "is required".
On a PUT request, an absent field is “no change” (Ferra’s
existing partial-update semantics) and skips per-field rules,
but an explicit null fails with "is required" — null
on the wire is a positive declaration of “set to none”, which
the rule rejects.
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 = "profiles")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(required)]
pub bio: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /profiles with {"bio": null} (or {}) returns 422
with errors.bio = ["is required"].
Propagation to the OpenAPI document
Every #[field(...)] rule you declare propagates into the
emitted OpenAPI 3.1 document under the canonical JSON Schema
keyword for the rule. SDK generators that consume the spec
(orval, openapi-generator, kiota, …) and UI tooling that
drives form validation off the spec (react-jsonschema-form,
JSON-Forms, …) receive the same rules the server enforces —
no parallel maintenance, no spec/server drift.
#[field(...)] rule | OpenAPI keyword | Where it lands |
|---|---|---|
min_length = N | minLength: N | properties.<field> |
max_length = N | maxLength: N | properties.<field> |
min = N | minimum: N | properties.<field> |
max = N | maximum: N | properties.<field> |
pattern = "<regex>" | pattern: "<regex>" | properties.<field> |
email | format: "email" | properties.<field> |
url | format: "uri" | properties.<field> |
required | (field name added to schema’s required: [...]) | <schema>.required |
Per-projection coverage. Keywords appear on every
projection in which the field appears: the read projection
(Film), the create-input projection (CreateFilmInput), and
the update-input projection (UpdateFilmInput). A field
excluded from one projection (for example, a #[ferra(read_only)]
field is excluded from the create-input projection because it
is not writable) does NOT appear in that projection’s
properties map — but its keywords continue to appear on the
read projection because the rules describe the persisted state,
not only the input shape.
Note on url → format: uri. JSON Schema’s vocabulary
uses uri, not url — the rule name on the Rust side stays
as the friendlier url, but the emitted spec uses the JSON
Schema canonical name. SDK generators look up format: uri.
Combined rules merge into one property schema. A field
carrying #[field(min_length = 1, max_length = 255, pattern = "^[A-Z][a-z]+$")] produces:
"title": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"pattern": "^[A-Z][a-z]+$"
}
The keyword order in the JSON output is not contractual; the keyword set is.
Required behaviour. The #[field(required)] rule on an
Option<T> field adds the field name to the create-input and
update-input projections’ required: [...] arrays — it does
NOT inject a "required": true keyword on the per-property
schema (JSON Schema does not have one; required-ness is a
parent-schema concern). The same array also includes every
non-Option field, matching Rust’s “non-Option means required”
semantics on the wire.
No-rules opt-in. A model that declares zero #[field(...)]
rules emits a spec byte-identical to its 0.5.x equivalent.
Validation surfaces only when you opt in.
Default messages and localisation
Every rule emits a hard-coded English message as its violation
text — the literal strings shown in the table above. Length rules
pluralise “character” only when N != 1: min_length = 1
produces "must be at least 1 character" (singular) while
min_length = 5 produces "must be at least 5 characters"
(plural). Ferra 0.6.0 does not support per-field, per-rule
message overrides or Accept-Language-driven locale negotiation;
both are deferred to a later release.
If you need localised error text on the consumer side, branch
on the field name in the response’s errors map and rewrite
the message client-side. The default English text is a stable
contract within the 0.6.0 line — string-matching against
"must be at least", "is required", etc. is supported until
the localisation work lands. SDK consumers that have to rewrite
the body server-side can do so in a Tower middleware layer
applied after the framework’s IntoResponse runs.
Unicode counting
Length rules count characters as the underlying validation
crate counts them: chars().count() on the underlying &str,
which counts Unicode scalar values. The framework does not
interpose a custom counting policy in 0.6.0 — there is no NFC
normalisation pass, no grapheme-cluster counting, no UTF-16
code-unit counting.
The practical consequence: a 10-emoji string may not have a
length of 10 by your reader’s intuition. The grinning-face
emoji 😀 is one scalar value (one chars() count), but
some emoji sequences (skin-tone modifiers, ZWJ-joined family
emoji) are several scalar values — a single visible glyph may
count as 2, 4, or more. If your length rule needs to count
“what the user sees”, reach for the typed-escape path below
and write a hand-rolled rule that walks
unicode-segmentation
graphemes.
When the eight rules are not enough — typed escape via ferra::garde
The eight-rule namespace is a closed contract surface for
0.6.0. For rule shapes the surface does not cover — a
custom function predicate, a locale-aware rule, a regex
syntax beyond the regex crate, a grapheme-cluster-aware
length count — Ferra re-exports the underlying validation
crate at ferra::garde so you can hand-roll a garde::Validate
impl on your model:
use ferra::garde::{self, Validate};
impl Validate for MyModel {
type Context = ();
fn validate_into(
&self,
_ctx: &Self::Context,
_parent: &mut dyn FnMut() -> garde::Path,
report: &mut garde::error::Report,
) {
// your hand-rolled checks here, appending to `report`
}
}
The escape is the documented fallback — neither encouraged nor
forbidden, used when the closed set is insufficient. It is
not part of the v1 #[field(...)] contract surface; the
rule shape and the trait surface follow the upstream garde
crate’s evolution, not Ferra’s. Use it sparingly: every model
that hand-rolls validation is a model the #[field(...)]
diagnostics no longer protect.
Hand-rolling a
Validateimpl on a model that ALSO carries#[field(...)]rules is a duplicate-impl error at compile time. The two paths are exclusive: either the framework generates the impl from your declarative rules, or you write it yourself.
Composite primary keys
A model can carry two or more #[sea_orm(primary_key)] markers.
Ferra picks them up in declaration order and threads the resulting
multi-segment shape through the route, the Location header, the
_links.self.href URL, the OpenAPI per-item parameters list, and
the find_by_id(...) lookup signature — without consumer-side
glue.
A worked example with (tenant_id: Uuid, document_id: i64):
use ferra::*;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(
Clone, Debug, PartialEq,
DeriveEntityModel, FerraModel,
Serialize, Deserialize,
)]
#[sea_orm(table_name = "documents")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub tenant_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub document_id: i64,
pub title: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
#[sea_orm(auto_increment = false)] is required on every PK field
in a composite key — PostgreSQL SERIAL only synthesises a value
for one column per table, so the application supplies an explicit
value on every insert.
The framework derives:
| Surface | Single-PK shape | Composite-PK shape (2 keys) |
|---|---|---|
| Per-item route | GET /films/{id} | GET /documents/{tenant_id}/{document_id} |
Location header on POST | /films/{id} | /documents/{tenant_id}/{document_id} |
_links.self.href | /films/{id} | /documents/{tenant_id}/{document_id} |
OpenAPI parameters count | 1 entry | 2 entries (in declaration order) |
repo.find_by_id(...) argument | id (scalar) | (tenant_id, document_id) (tuple) |
The repository call site is a typed tuple lookup:
let row = repo.find_by_id((tenant_id, document_id)).await?;
The tuple element order matches the #[sea_orm(primary_key)]
declaration order in the struct — Sea-ORM resolves
PrimaryKeyTrait::ValueType to (Uuid, i64) for the Document
entity above. Reordering the markers in the source struct is
therefore a wire-breaking change for any client URL-templating
against _links.self.href or against the OpenAPI path template.
The _links.self.href shape on a fetched row:
{
"_links": {
"self": { "href": "https://api.example.com/documents/<tenant>/<doc>" },
"collection": { "href": "https://api.example.com/documents" }
}
}
Ferra supports any N >= 1. A three-key composite ((tenant_id, year, sequence)) emits GET /issued_documents/{tenant_id}/{year}/{sequence}
with three OpenAPI parameters. The framework does not impose an
upper bound; in practice 2-4 keys cover the working set
(multi-tenant isolation, time-bucketed sequencing, partition keys).
The N=0 case continues to fail at compile time with the existing
“this model has no primary key” diagnostic — composite primary keys
are a widening of the legacy single-PK rule, not a relaxation.
The FerraRepository<M> surface for composite-PK models is
documented in ferra-db.md § The FerraRepository<M> surface.
Why DeriveEntityModel is required
Skipping the DeriveEntityModel sibling is a class-16 compile error:
error: FerraModel requires DeriveEntityModel on the same struct
help: add DeriveEntityModel to the `#[derive(...)]` list
note: see docs/user-guide/ferra-forge.md § Canonical entity shape — sibling-derive requirement
--> src/models/film.rs:3:12
|
3 | pub struct Film {
| ^^^^
The requirement is not decorative. Phase 0.3.0 Casting wires the
Entity, Column, PrimaryKey, and ActiveModel types that
DeriveEntityModel generates into the router and SQL query builder.
A FerraModel-only struct would compile locally but collapse the
moment ferra-http or ferra-db touches it. The rule fails loud
now so you cannot paint yourself into that corner.
Troubleshooting
Every error class 0.2.0 emits, with the exact message shape and the
fix. Numbering matches data-model.md § 4 of the Smelting spec.
About the rendered shape. Every Ferra-authored diagnostic in this section carries three logical lines — an
error:line, ahelp:line, and anote:line — becauseferra-forgebuilds them as a singlesyn::Erroron stable Rust. Thehelpandnoteappear as indented continuation lines of the error body rather than as separate= help:/= note:sub-lines (that shape requiresproc_macro::Diagnostic, which is nightly-only as of the 2026-04 toolchain). Text content is identical; grep-based CI checks and visual legibility both work.
Class 1 — no primary key
error: this model has no primary key
help: annotate exactly one field `#[sea_orm(primary_key)]`
note: see docs/user-guide/ferra-forge.md § Troubleshooting — missing primary key
Fix. Add #[sea_orm(primary_key)] to the id field. In 0.2.0
Ferra supports one-field primary keys only.
Class 2 — multiple primary keys
error: composite primary keys are not supported in Ferra 0.2.0
help: keep `#[sea_orm(primary_key)]` on exactly one field
Fix. Consolidate into a single id column. Composite keys are a later-phase feature.
Class 3 — unsupported field type
error: type `<T>` is not supported by Ferra in 0.2.0
help: use one of `String`, `i32`, `i64`, `f64`, `bool`, `Uuid`, or `Option<T>` for the first five
Fix. Model with one of the recognised types, or wait for the
phase that adds your type (Decimal, Timestamp, relations are on
the roadmap).
Class 4 — historical (Option<Uuid> rejection — retired in 0.6.0)
Option<Uuid> and Option<Id> are now first-class field types
admitted as FieldType::OptionUuid (0.6.0 Welding User Story 4 —
FR-014). The diagnostic that previously fired here (“Option<Uuid>
is not supported in Ferra 0.2.0”) no longer exists. The class
number is preserved for historical reference; the contradictory
case Option<Uuid> tagged #[sea_orm(primary_key)] is now
rejected with FRG-215 instead — shape and fix below.
error: FRG-215: primary key cannot be optional
= help: primary key fields must always have a value; remove `Option<...>` from the field type or remove `#[sea_orm(primary_key)]` from the field tag
= note: an inserted `None` would be indistinguishable from a row whose key was never assigned, so the round-trip equality semantics a PK guarantees cannot hold; see Spec §Edge Cases "Optional UUID field tagged as primary key"
--> src/film.rs:6:9
|
6 | pub id: Option<Uuid>,
| ^^
Fix. Either drop the Option<...> from the field type (so the
PK is always present), or remove #[sea_orm(primary_key)] from the
tag (so the field becomes a nullable non-key UUID).
The full FRG-2NN matrix and worked examples for every code introduced
in 0.6.0 Welding live in
Compile-time diagnostics. This page
keeps the FRG-215 details inline because the Troubleshooting section
needs to stand alone for consumers reading ferra-forge.md in
isolation.
Class 5 — not a struct with named fields
error: FerraModel must be derived on a struct with named fields (not tuple structs, unit structs, enums, or unions)
Fix. Convert to pub struct Name { ... } with named fields.
Class 6 — generic struct
error: FerraModel does not currently support generic models
help: supply a concrete type — remove the generic parameter
Class 7 — lifetime-parameterised struct
error: FerraModel does not currently support lifetime-parameterised models
help: Ferra models are owned values — remove the lifetime parameter
Class 8 — visibility below pub(crate)
error: FerraModel requires at least `pub(crate)` visibility
help: widen the struct's visibility — e.g., `pub struct ...` or `pub(crate) struct ...`
Class 9a — unknown #[ferra(...)] key (no nearest match)
error: `#[ferra(<key>)]` is not a recognized attribute
note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase
Fix. Consult the Recognised keys table above, or the Deferred keys table if you were trying to use a future-phase key.
Class 9b — unknown #[ferra(...)] key with a near-miss hint (release-blocker family)
error: `#[ferra(writeonly)]` is not a recognized attribute
help: did you mean `write_only`?
note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase
Fix. The help line quotes the intended spelling. The canonical
case is #[ferra(writeonly)] on password_hash — a write-only
bypass that would expose sensitive data. The trybuild fixture for
this diagnostic is marked a release blocker per SC-008.
Class 10 — read_only + write_only contradiction
error: `#[ferra(read_only)]` and `#[ferra(write_only)]` contradict
help: keep at most one of the two flags
Class 11 — #[ferra(required)] on Option<T>
error: `#[ferra(required)]` contradicts `Option<T>`
help: remove the attribute or change the field type
Fix. Nullability is deduced from the type, not from an attribute.
Remove #[ferra(required)] or change the field to a non-Option<T>
type.
Class 12 — #[ferra(write_only)] on the primary key
error: primary keys cannot be write-only
help: remove `#[ferra(write_only)]` from the id field
Class 13 — empty #[ferra(resource = "")]
error: `#[ferra(resource)]` must be a non-empty string
help: remove the attribute to fall back to the default
Class 14 — non-string-literal #[ferra(resource = ...)]
error: `#[ferra(resource)]` value must be a string literal
help: e.g., `#[ferra(resource = "films")]`
Class 15 — missing serde derives
Delivered by rustc E0277 on the emitted _assert_ferra_model_bounds
trait-bound assertion (see § Explicit serde derives are required
above). Span is pinned to your struct’s identifier.
Class 16 — missing DeriveEntityModel sibling
error: FerraModel requires DeriveEntityModel on the same struct
help: add DeriveEntityModel to the `#[derive(...)]` list
note: see docs/user-guide/ferra-forge.md § Canonical entity shape — sibling-derive requirement
Fix. Add DeriveEntityModel to the #[derive(...)] list. See the
Canonical entity shape example above.
- :sea_orm::ModelTrait>()
bound assertion; a failure there surfaces aserror[E0277]: the trait bound<YourModel>: sea_orm::ModelTraitis not satisfied. All three paths reduce to the same fix: addDeriveEntityModelto the#[derive(…)]` list.
Class 17 (pass case) — unknown #[sea_orm(...)] key
An unknown-to-Ferra but Sea-ORM-permitted key such as
#[sea_orm(column_name = "...")] compiles without intervention from
Ferra. This is the asymmetry documented in § Two attribute
namespaces, two policies. If Sea-ORM diagnoses the key itself, that
diagnostic surfaces — Ferra does not layer its own.
Compile-time diagnostics — the FRG-NNN namespace
Status: 0.6.0 Welding (validation-attribute codes, FRG-201 through
FRG-215) + 0.6.5 Chasing (projection / computed / write-only codes,
FRG-301 through FRG-316, with FRG-313 intentionally unallocated).
Reader contract. This page stands on its own. A reader with only this page plus standard Rust and Sea-ORM knowledge can identify the trigger condition for any
FRG-NNNerror in the table below and fix the offending source on the first attempt. Cross-references to ADRs and the constitution are non-load-bearing — every correctness cue is spelled out in the per-code section.
#[derive(FerraModel)] and the #[field(...)] attribute parser emit
typed FRG-NNN codes on every diagnostic. Each code is grep-friendly,
span-anchored on the offending source token, and documented below with
a worked-bad → worked-good example pair plus a one-sentence fix
template.
The codes are emitted via proc-macro-error2’s
emit_with_triple / abort_with_triple helpers — multiple
independent mistakes on the same struct surface in one
cargo check round-trip rather than the first-and-stop pattern. The
emitted shape carries three logical lines (error: + help: +
note:) anchored to the offending token via quote_spanned!; the
note: line in every code below points back to the ferra-forge.md
section that owns the rule’s call-site documentation.
Namespace structure
FRG-000 .. FRG-099 derive-shape errors (existing — class 1..16)
FRG-100 .. FRG-199 #[authorize] errors (post-0.8.0 — reserved)
FRG-200 .. FRG-299 validation-attribute (0.6.0 Welding — FRG-201..FRG-215)
FRG-300 .. FRG-399 projection / computed / (0.6.5 Chasing — FRG-301..FRG-316,
write-only attribute FRG-313 intentionally unallocated)
FRG-400 .. later phases (reserved)
Code numbers are never recycled. A code retired because the class becomes unreachable remains documented as historical (see the FRG-215 note on Class 4 retirement); future codes append to the namespace.
Code matrix
| Code | Trigger (one-line) | Section |
|---|---|---|
FRG-201 | #[field(min_length = …)] value is not a non-negative integer literal | link |
FRG-202 | #[field(max_length = …)] value is not a non-negative integer literal | link |
FRG-203 | #[field(min = …)] value type does not match the field’s numeric type | link |
FRG-204 | #[field(max = …)] value type does not match the field’s numeric type | link |
FRG-205 | #[field(pattern = …)] value is not a non-empty string literal | link |
FRG-206 | The same #[field(...)] rule key appears twice on the same field | link |
FRG-207 | min_length / max_length rule applied to a non-string field | link |
FRG-208 | min / max rule applied to a non-numeric field | link |
FRG-209 | pattern / email / url rule applied to a non-string field | link |
FRG-210 | min_length exceeds max_length on the same field | link |
FRG-211 | min exceeds max on the same field | link |
FRG-212 | #[field(required)] applied to a non-Option<T> field | link |
FRG-213 | Unknown #[field(<key>)] key (with did you mean hint when distance ≤ 2) | link |
FRG-214 | #[field(<flag>)] bare flag given a value (e.g. email = "x") | link |
FRG-215 | Option<Uuid> (or Option<Id>) tagged #[sea_orm(primary_key)] | link |
FRG-301 | A projection’s read = [...] or write = [...] ident does not match any struct field | link |
FRG-302 | #[ferra(write_only)] and #[ferra(read_only)] on the same field | link |
FRG-303 | #[ferra(write_only)] and #[ferra(computed)] on the same field | link |
FRG-304 | #[ferra(write_only)] on a field that carries #[sea_orm(primary_key)] | link |
FRG-305 | A #[ferra(write_only)] field is listed in a projection’s read = [...] | link |
FRG-306 | #[ferra(skip)] combined with a non-empty #[ferra(groups = […])] on the same field | link |
FRG-307 | Legacy #[field(write_only)] form (predates the #[ferra(...)] namespace) | link |
FRG-308 | #[ferra(writeonly)] typo (missing underscore) | link |
FRG-309 | #[ferra(computed)] #[ferra(read_only)] on the same field (redundant) | link |
FRG-310 | Two projections produce the same URL prefix (duplicate path_prefix or two default = true) | link |
FRG-311 | A projection name does not normalise to a URL-safe segment, and no path_prefix override | link |
FRG-312 | A projection’s auto-derived URL prefix collides with an existing resource route (runtime) | link |
FRG-313 | intentionally unallocated — see § FRG-3NN | |
FRG-314 | An explicit write = [...] list omits a #[ferra(write_only)] field on the model | link |
FRG-315 | path_prefix declared on a sub-resource (runtime) | link |
FRG-316 | default = true without promotes_from + breaking_change_version attestations | link |
How to read each entry
Every section below carries:
- Trigger. A one-sentence description of when the error fires plus
the offending
#[field(...)]attribute pattern. - Bad. A 5-15-line worked example showing the mistake. The block compiles in isolation against the canonical entity shape; just paste it into a sandbox crate to reproduce the diagnostic.
- Good. A 5-15-line worked example showing the fix. Replaces the bad block one-for-one — same imports, same struct shape, only the offending attribute (or field type) changes.
- Fix. A one- or two-sentence prescription naming what to change and where.
- Fixture. A repo-relative link to the corresponding compile-fail
fixture under
crates/ferra-forge/tests/diagnostics/. The fixture is the byte-shape contract — a CI-checked.stderrsnapshot proves the diagnostic shape stays stable across releases.
Every code uses the same import preamble. Show it once here so the per-code blocks stay short:
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
The Sea-ORM boilerplate (Relation enum + ActiveModelBehavior
impl) below the struct is identical in every example and is
suppressed in the per-code blocks for brevity. Add it verbatim from
the canonical entity shape
when reproducing locally.
FRG-201 — min_length value is not a non-negative integer literal
Trigger. A #[field(min_length = …)] attribute whose value is
anything other than a non-negative integer literal — a string, a
negative integer, a float, an identifier. The min_length rule needs
a u32-sized count.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = "abc")]
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1)]
pub title: String,
}
Fix. Replace the value with a non-negative integer literal
(e.g. 1, 10, 255).
Fixture. crates/ferra-forge/tests/diagnostics/frg_201_min_length_invalid.rs
FRG-202 — max_length value is not a non-negative integer literal
Trigger. A #[field(max_length = …)] attribute whose value is
anything other than a non-negative integer literal. Same shape as
FRG-201, on the upper bound.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(max_length = "abc")]
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(max_length = 255)]
pub title: String,
}
Fix. Replace the value with a non-negative integer literal
(e.g. 1, 10, 255).
Fixture. crates/ferra-forge/tests/diagnostics/frg_202_max_length_invalid.rs
FRG-203 — min value type does not match the field’s numeric type
Trigger. A #[field(min = …)] attribute whose literal type does
not match the field’s declared numeric type — a float on an i32
field, an integer where a f64 is expected when the literal is
fractional, etc. The literal type must match: min = 1.5 on an
i32 field is rejected; min = 1 on a f64 field is accepted (and
coerces).
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min = 1.5)]
pub rating: i32,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min = 1)]
pub rating: i32,
}
Fix. Use a literal whose Rust type matches the field’s declared
numeric type (i32, i64, or f64). Or change the field’s type to
match the literal.
Fixture. crates/ferra-forge/tests/diagnostics/frg_203_min_type_mismatch.rs
FRG-204 — max value type does not match the field’s numeric type
Trigger. A #[field(max = …)] attribute whose literal type does
not match the field’s declared numeric type. Same shape as FRG-203,
on the upper bound.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(max = 2.5)]
pub rating: i32,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(max = 10)]
pub rating: i32,
}
Fix. Use a literal whose Rust type matches the field’s declared numeric type. Or change the field’s type to match the literal.
Fixture. crates/ferra-forge/tests/diagnostics/frg_204_max_type_mismatch.rs
FRG-205 — pattern value is not a non-empty string literal
Trigger. A #[field(pattern = …)] attribute whose value is not a
non-empty string literal — pattern = "" (empty), pattern = 42
(non-string), pattern = some_const (non-literal). The literal must
be a non-empty regex-crate-syntax pattern.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "posts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(pattern = "")]
pub slug: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "posts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(pattern = r"^[a-z0-9-]+$")]
pub slug: String,
}
Fix. Provide a non-empty regex literal. A raw-string literal
(r"…") is recommended so backslashes are not double-escaped.
Fixture. crates/ferra-forge/tests/diagnostics/frg_205_empty_pattern.rs
FRG-206 — duplicate rule on the same field
Trigger. The same #[field(...)] rule kind appears twice on the
same field — #[field(min_length = 5, min_length = 10)], or two
#[field(...)] attributes each carrying a min_length. Repeated
rules of the same kind are ambiguous; keep at most one per kind per
field.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 5, min_length = 10)]
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 10)]
pub title: String,
}
Fix. Remove the redundant entry. If you intended two different
kinds of bound (a length AND a numeric range), the kinds must differ
— #[field(min_length = 5, max_length = 10)] is fine; two
min_length entries are not.
Fixture. crates/ferra-forge/tests/diagnostics/frg_206_duplicate_rule.rs
FRG-207 — length rule on a non-string field
Trigger. A #[field(min_length = …)] or #[field(max_length = …)]
attribute on a field whose Rust type is not String or
Option<String>. Length rules count Unicode scalar values on a
string body — they are not defined on numeric or other field types.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1)]
pub rating: i32,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min = 1)]
pub rating: i32,
}
Fix. Apply length rules to a String or Option<String> field.
For numeric bounds on a numeric field, use #[field(min = …)] /
#[field(max = …)] instead.
Fixture. crates/ferra-forge/tests/diagnostics/frg_207_length_on_non_string.rs
FRG-208 — numeric rule on a non-numeric field
Trigger. A #[field(min = …)] or #[field(max = …)] attribute on
a field whose Rust type is not i32, i64, f64 (or their
Option<…> form). Numeric range rules are not defined on string,
boolean, or UUID field types.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min = 1)]
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1)]
pub title: String,
}
Fix. Apply numeric range rules to an i32, i64, or f64 field
(or their Option<…> form). For string-length bounds on a string
field, use #[field(min_length = …)] / #[field(max_length = …)]
instead.
Fixture. crates/ferra-forge/tests/diagnostics/frg_208_range_on_non_numeric.rs
FRG-209 — string rule on a non-string field
Trigger. A #[field(pattern = …)], #[field(email)], or
#[field(url)] attribute on a field whose Rust type is not String
or Option<String>. These rules need a string body to evaluate
against.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(email)]
pub age: i32,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "contacts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(email)]
pub email: String,
}
Fix. Apply pattern / email / url to a String or
Option<String> field. If the field genuinely is non-string, the
rule does not apply — drop it.
Fixture. crates/ferra-forge/tests/diagnostics/frg_209_string_rule_on_non_string.rs
FRG-210 — min_length exceeds max_length on the same field
Trigger. A field declares both #[field(min_length = N)] and
#[field(max_length = M)] with N > M — no string length satisfies
both bounds, so every wire value would fail validation.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 10, max_length = 1)]
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1, max_length = 255)]
pub title: String,
}
Fix. Ensure min_length <= max_length. The bounds are inclusive
on both sides, so min_length = max_length (a single fixed length)
is acceptable.
Fixture. crates/ferra-forge/tests/diagnostics/frg_210_min_exceeds_max_length.rs
FRG-211 — min exceeds max on the same field
Trigger. A field declares both #[field(min = N)] and
#[field(max = M)] with N > M — no numeric value satisfies both
bounds. Same shape as FRG-210, on the numeric range rules.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ratings")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min = 10, max = 1)]
pub rating: i32,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ratings")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min = 0, max = 10)]
pub rating: i32,
}
Fix. Ensure min <= max. The bounds are inclusive, so a single
fixed value (min = max) is acceptable.
Fixture. crates/ferra-forge/tests/diagnostics/frg_211_min_exceeds_max.rs
FRG-212 — required applied to a non-Option<T> field
Trigger. A #[field(required)] attribute on a field whose Rust
type is not Option<T>. Non-Option fields are required by Rust’s
type system — the wire value cannot be absent — so the required
flag is meaningless on them. The flag exists for Option<T> fields,
where it forbids null on the wire.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(required)]
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "profiles")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(required)]
pub bio: Option<String>,
}
Fix. Either remove required (the field’s non-Option type
already requires a value) or change the field type to Option<T> if
the wire form should be nullable but rejected on null.
Fixture. crates/ferra-forge/tests/diagnostics/frg_212_required_on_non_option.rs
FRG-213 — unknown #[field(...)] key
Trigger. A #[field(<key>)] invocation whose <key> is not in
the closed #[field(...)] vocabulary. The closed vocabulary is the
eight rules listed in
ferra-forge.md § Validation rules
— min_length, max_length, min, max, pattern, email,
url, required. When the unknown key’s Levenshtein distance to a
recognised key is ≤ 2, the diagnostic carries a did you mean <closest>? help line.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_lenght = 1)]
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1)]
pub title: String,
}
Fix. Apply the spelling the did you mean line suggests, or
consult the closed #[field(...)] vocabulary above. If the key you
wanted is on the deferred list (see
ferra-forge.md § Deferred keys),
that feature has not yet shipped.
Fixture. crates/ferra-forge/tests/diagnostics/frg_213_unknown_field_key.rs
FRG-214 — bare flag given a value
Trigger. A #[field(<flag>)] invocation written as
#[field(<flag> = <value>)] for one of the bare-flag rules. The
flags email, url, and required take no value — they fire
purely on presence. Writing #[field(email = "x")] is a shape
mismatch.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "contacts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(email = "x")]
pub contact_email: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "contacts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(email)]
pub contact_email: String,
}
Fix. Drop the = … part — write the flag bare. The rule fires on
presence; there is nothing to configure.
Fixture. crates/ferra-forge/tests/diagnostics/frg_214_value_shape_mismatch.rs
FRG-215 — primary key cannot be optional
Trigger. A field declared as #[sea_orm(primary_key)] pub id: Option<Uuid> (or Option<Id>). Option<Uuid> was admitted as a
field type in 0.6.0 Welding (User Story 4), but a primary key cannot
be optional — an inserted None would be indistinguishable from a
row whose key was never assigned, so the round-trip equality
semantics a PK guarantees cannot hold.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Option<Uuid>,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
}
Fix. Either drop the Option<...> from the field type (so the
PK is always present) or remove #[sea_orm(primary_key)] from the
tag (so the field becomes a nullable non-key UUID).
Note on Class 4.
Option<Uuid>was previously rejected outright by#[derive(FerraModel)](Class 4 inferra-forge.md§ Troubleshooting). 0.6.0 Welding lifts the rejection — the type is now first-class — and replaces it with this narrower contradiction check. If you came here looking for the old “Option<Uuid>is not supported” diagnostic, that class is retired; the per-rule rejection above is the current diagnostic.
Fixture. crates/ferra-forge/tests/diagnostics/frg_215_optional_primary_key.rs
Compile-time diagnostic wrappers
Some Ferra attributes impose a trait bound on the field’s type — for
example, #[ferra(computed)] requires the field’s type to implement
Default (the framework supplies a compute-time placeholder by calling
T::default()). When the bound is violated, rustc’s default message
would be the language-level
error[E0277]: the trait bound \T: Default` is not satisfied`. That
phrasing is correct but tells the user nothing about why Ferra needs
the bound or how to satisfy it in the Ferra-specific context.
To close that gap, ferra-core ships a small set of wrapper traits
in ferra_core::diagnostics
that re-export the underlying language traits under a framework-authored
name and carry a #[diagnostic::on_unimplemented] annotation. The
compiler reads the annotation and renders the framework’s message /
label / note triple instead of its default E0277 text. The user
sees a Ferra-shaped diagnostic on the first line of compiler output.
The shipped wrappers
| Wrapper trait | Wrapped language trait | Consumed by |
|---|---|---|
FerraComputedDefault | Default (blanket-impl’d for T: Default) | ferra-forge’s compute_emit::emit_computed_bounds, once per #[ferra(computed)] field. |
FerraSend | Send (blanket-impl’d for T: Send + ?Sized) | ferra-forge’s emit_bounds_assertion (the FR-027 class 15 “forgot derives / non-Send field” detector emitted on every #[derive(FerraModel)] struct). |
FerraSync | Sync (blanket-impl’d for T: Sync + ?Sized) | Sibling of FerraSend on the same assertion — carries the framework-authored message for the Sync half of the bound. |
FerraAuthConfigured | none (placeholder) at 0.6.5 | Forward-declared at 0.6.5 with no consumer; Foundry::mount::<M>() will require this bound from 0.8.0 Forging I onward. |
FerraComputedDefault, FerraSend, and FerraSync are the three
wrappers that can fire on user code today. FerraAuthConfigured exists
in 0.6.5 so the 0.8.0 release can emit the bound without a coupled
ferra-core evolution — at 0.6.5 it has no blanket impl and no
consumer.
The canonical diagnostic shape
The compiler renders a #[diagnostic::on_unimplemented] failure as
three lines anchored on the offending type. The message is the
top-line error: …; the label is the inline span annotation pointing
at the offending position; the note is the bottom-line fix hint. For
FerraComputedDefault:
error: `NotDefault` does not implement `Default`, but it is required for fields marked `#[ferra(computed)]`
--> src/model.rs:LL:CC
|
LL | pub total: NotDefault,
| ^^^^^^^^^^ this type must implement `Default` for ferra-forge to supply its compute-time placeholder
|
= note: add `#[derive(Default)]` on `NotDefault`, or provide an explicit `impl Default for NotDefault`.
The framing — is required for fields marked \#[ferra(…)]`plus the explicitferra-forgereference in thelabel— is how a consumer (human or AI) recognises a Ferra wrapper diagnostic versus a generic rustc message. Whenever you see one of these triples, thenote:line names the fix verbatim; apply it on the offending type and re-runcargo check`.
Migration when an upgrade introduces a new wrapper-trait bound
Future Ferra releases land additional bounds (e.g. FerraAuthConfigured
at 0.8.0). When an upgrade triggers a previously-passing compile to
fail with a new wrapper-trait diagnostic:
- Recognise the framing. The
messageline names a Ferra attribute or generation context (required for fields marked \#[ferra(…)]`,this model needs explicit authorization configuration`, …) rather than a bare language trait. - Read the
note:line. It names the concrete fix — a missing#[derive(...)], a missing impl, or a missing attribute on the model. - Apply the fix at the named site. The diagnostic’s
--> path:LINE:COLanchor points at the exact source position; the wrapper-trait substrate guarantees the fix is local to the type or the model under the cursor.
The per-code FRG-3NN sections below cite individual diagnostics; the
wrapper-trait substrate is shared by every emission that imposes a
typed bound on user fields (the FerraComputedDefault consumer site is
documented under FRG-3NN — computed/projection codes that share the wrapper substrate).
Computed-field default-value diagnostic
A #[ferra(computed)] field is computed by the framework at serialise
time. The default emission strategy substitutes T::default() for the
field’s value when reading the entity off the database — so the field’s
type must implement Default. The proc-macro emits a bound assertion
through the FerraComputedDefault wrapper trait (see
Compile-time diagnostic wrappers)
so the diagnostic surfaces the framework-authored triple instead of
rustc’s generic E0277 text.
Bad — a user-named type that does not implement Default.
use ferra_core::compute::FerraComputed;
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use sea_orm::DeriveValueType;
use serde::{Deserialize, Serialize};
/// A user-defined newtype that deliberately omits `#[derive(Default)]`.
/// Every other trait Ferra's substrate requires (`Clone`, `Debug`,
/// `PartialEq`, sea-orm's `ValueType` family via `DeriveValueType`,
/// serde derives) is in place — `Default` is the only missing piece.
#[derive(
Clone, Debug, PartialEq, DeriveValueType, Serialize, Deserialize,
)]
pub struct NotDefault(pub i64);
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[ferra(computed)]
pub custom: NotDefault,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl FerraComputed for Model {}
The diagnostic the developer sees — the framework-authored
FerraComputedDefault #[diagnostic::on_unimplemented] triple,
pinned verbatim by the trybuild golden
crates/ferra-forge/tests/ui/computed_default_missing.{rs,stderr}:
error[E0277]: `NotDefault` is used as the type of a `#[ferra(computed)]` field, but it does not implement `Default`
--> src/model.rs:LL:CC
|
LL | pub custom: NotDefault,
| ^^^^^^^^^^ this type needs `impl Default for NotDefault`
|
= note: Ferra calls `T::default()` when a computed field's compute trait is not implemented (FR-015 — missing implementations are a no-op, not a panic). The `Default` bound is required by the computed-field contract; see `docs/user-guide/ferra-forge.md#computed-fields`. Diagnostic emitted by `ferra_core::diagnostics::FerraComputedDefault`.
= note: required for `NotDefault` to implement `FerraComputedDefault`
help: consider annotating `NotDefault` with `#[derive(Default)]`
Fix. Add #[derive(Default)] (or an explicit impl Default) on
the field’s underlying type:
#[derive(
Default, Clone, Debug, PartialEq, DeriveValueType, Serialize, Deserialize,
)]
pub struct NotDefault(pub i64);
The build now succeeds and the wrapper-trait bound is satisfied via
the blanket impl impl<T: Default> FerraComputedDefault for T {} on
[ferra_core::diagnostics::FerraComputedDefault].
FR-043 — non-computed fields are unaffected. A user-named field
type that does NOT carry #[ferra(computed)] is rejected by the
classifier cascade (type … is not supported by Ferra). The
classifier widening that admits NotDefault above lives behind the
#[ferra(computed)] gate by design; the negative control fixture
crates/ferra-forge/tests/ui/non_computed_user_named_rejected.{rs,stderr}
pins this invariant.
Multi-segment paths (module::MyType) are not admitted at 0.7.0.
Only single-ident user-named types pass the gate. A path-qualified
type continues to be rejected by the classifier cascade even under
the computed gate. Use a use module::MyType as MyType; at the
declaration site to surface the bare ident if the wrapper-trait
diagnostic is desired.
FRG-3NN — projection-attribute diagnostics (0.6.5 Chasing)
The 0.6.5 Chasing release opens the FRG-3NN block for projection /
computed / write-only attribute rejections. Codes FRG-301..FRG-312 and
FRG-314 ship from the proc-macro parser (#[derive(FerraModel)]); codes
FRG-312 and FRG-315 are runtime-detected at Foundry::build() and ship
their full integration-test fixture when the corresponding API surface
(cross-model collision check, nested-mounting) wires in. FRG-313 is
intentionally unallocated — the slot is reserved for namespace
stability; the framework will not recycle the identifier.
See Projections and routing for the user-facing contract; this page documents the compile-time enforcement.
FRG-301 — projection references an unknown field
Trigger. A #[ferra(projection(... read = [...]))] or
write = [...] list contains an ident that does not match any field on
the host struct. The check also fires when an ident references a
field but in the wrong half (e.g. a #[ferra(write_only)] field
listed in a read = [...]; see FRG-305 for that refinement).
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin", read = [ghost_field]))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin", read = [id, title]))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Fix. Replace the unknown ident with a field that exists on the
struct, or add the field. Identifiers in read/write lists must
match field names exactly (case-sensitive).
Fixture. crates/ferra-forge/tests/diagnostics/frg_301_projection_unknown_field.rs
FRG-302 — write_only and read_only on the same field
Trigger. A field carries both #[ferra(write_only)] and
#[ferra(read_only)]. The two flags are mutually exclusive: a
write-only field is inbound-only (e.g. a password on registration),
a read-only field is outbound-only (e.g. a server-set timestamp);
combining them produces a field that can be neither read nor written —
a field nobody can interact with. Diagnostic span on the
second-encountered flag.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[ferra(read_only)]
#[ferra(write_only)]
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[ferra(read_only)]
pub title: String,
}
Fix. Decide which direction the field travels and keep exactly one flag. If the field genuinely flows in one direction only, drop the other attribute.
Fixture. crates/ferra-forge/tests/diagnostics/frg_302_write_only_and_read_only.rs
FRG-303 — write_only and computed on the same field
Trigger. A field carries both #[ferra(write_only)] and
#[ferra(computed)]. A computed field is server-produced — its value
materialises at read time from other fields on the row — so it is
strictly outbound. Tagging it write_only makes the field
unreachable: the framework would never produce it on the read side
and would reject inbound values on the write side. Diagnostic span on
write_only.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub subtotal: f64,
#[ferra(write_only)]
#[ferra(computed)]
pub total: f64,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub subtotal: f64,
#[ferra(computed)]
pub total: f64,
}
Fix. Drop #[ferra(write_only)] — computed already implies
“read shape only” (and also implies read_only; see FRG-309 for the
redundancy-with-read_only check).
Fixture. crates/ferra-forge/tests/diagnostics/frg_303_write_only_and_computed.rs
FRG-304 — write_only on a primary-key field
Trigger. #[ferra(write_only)] on a field that also carries
#[sea_orm(primary_key)]. Primary-key columns surface in
_links.self.href and as /{resource}/{id} URL segments — they
must round-trip on the read side. A write-only PK column would
leave the inserted row structurally unaddressable. Composite-key
models fire FRG-304 independently per PK column, so the developer
resolves the full graph in one round-trip.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "documents_composite")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
#[ferra(write_only)]
pub workspace_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
#[ferra(write_only)]
pub document_id: i32,
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "documents_composite")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub workspace_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub document_id: i32,
pub title: String,
}
Fix. Remove #[ferra(write_only)] from every PK column. If the
column is genuinely sensitive on read, model it as a non-PK column
and keep a separate synthetic primary key.
Fixture. crates/ferra-forge/tests/diagnostics/frg_304_write_only_on_primary_key.rs
FRG-305 — write_only field appears in a read projection
Trigger. A field declared #[ferra(write_only)] is listed in some
projection’s read = [...]. The structural exclusion of write-only
fields from the read shape is the load-bearing security guarantee of
US3 — there is no escape hatch via the projection attribute, even on
an admin-scoped projection. Diagnostic span on the offending ident
inside the read list.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "admin", read = [id, email, password_hash]))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
#[ferra(write_only)]
pub password_hash: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "admin", read = [id, email]))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
#[ferra(write_only)]
pub password_hash: String,
}
Fix. Remove the write-only field from the read = [...] list. If
the field should genuinely appear on the read side, remove
#[ferra(write_only)] from its declaration — but think hard first:
the attribute exists to prevent precisely this leak.
Fixture. crates/ferra-forge/tests/diagnostics/frg_305_write_only_in_read_projection.rs
FRG-306 — skip and non-empty groups on the same field
Trigger. A field carries both #[ferra(skip)] and a non-empty
#[ferra(groups = ["..."])]. skip removes the field from every
projection unconditionally; groups = [...] opts it into specific
projections. The two cannot simultaneously hold. The “both empty”
case (groups = [] next to skip) is allowed — the empty group
list is a no-op. Diagnostic span on the second-encountered attribute.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[ferra(skip)]
#[ferra(groups = ["admin"])]
pub internal_notes: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[ferra(groups = ["admin"])]
pub internal_notes: String,
}
Fix. Decide whether the field is universally hidden (keep skip,
drop the non-empty groups) or scoped to specific projections (drop
skip, keep the groups list).
Fixture. crates/ferra-forge/tests/diagnostics/frg_306_skip_and_groups_contradict.rs
FRG-307 — legacy #[field(write_only)] form
Trigger. A field carries #[field(write_only)]. The
#[field(...)] namespace is reserved for validation rules
(FRG-201..FRG-215); write_only is a projection flag that lives
under the #[ferra(...)] namespace per ADR-0006’s namespace
harmonisation. The parser recognises the pre-ADR-0006 legacy spelling
and emits FRG-307 with help pointing at the canonical
#[ferra(write_only)] spelling.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(write_only)]
pub password_hash: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[ferra(write_only)]
pub password_hash: String,
}
Fix. Rename the attribute namespace from #[field(write_only)] to
#[ferra(write_only)]. This is distinct from FRG-308 (typo within
the #[ferra(...)] namespace): the mistake here is the namespace,
not the spelling.
Fixture. crates/ferra-forge/tests/diagnostics/frg_307_legacy_field_write_only.rs
(See also the sibling release-blocker tests/ui/fail_ferra_writeonly_password_hash.rs fixture cited under FRG-308 — that one carries forward from 0.6.0 and now also exercises the FRG-308 typo path.)
FRG-308 — #[ferra(writeonly)] typo (did you mean write_only?)
Trigger. A field carries #[ferra(writeonly)] — the underscore is
missing. The parser computes the Levenshtein distance to every known
flag in the #[ferra(...)] vocabulary and, when the distance is ≤ 2,
emits FRG-308 with a did you mean hint pointing at the closest
known flag. This is the release-blocker test carried forward
from 0.6.0 Welding — the canonical typo on a password_hash field is
gated on every release.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[ferra(writeonly)]
pub password_hash: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[ferra(write_only)]
pub password_hash: String,
}
Fix. Apply the spelling the did you mean line suggests — for the
writeonly typo, the canonical form is write_only with the
underscore.
Fixture. crates/ferra-forge/tests/ui/fail_ferra_writeonly_password_hash.rs
FRG-309 — computed and read_only redundant
Trigger. A field carries both #[ferra(computed)] and
#[ferra(read_only)]. The first attribute implies the second by
construction — a computed field is server-produced and therefore
appears only on the read shape — so the explicit read_only is
redundant. Diagnostic span on the second-encountered flag with help
suggesting the redundant attribute be dropped.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub subtotal: f64,
#[ferra(computed)]
#[ferra(read_only)]
pub total: f64,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub subtotal: f64,
#[ferra(computed)]
pub total: f64,
}
Fix. Drop #[ferra(read_only)]. computed already implies the
read-only invariant.
Fixture. crates/ferra-forge/tests/diagnostics/frg_309_computed_and_read_only_redundant.rs
FRG-310 — projection URL prefix collision
Trigger. Two projections on the same model produce the same URL prefix. Two failure shapes share this code:
- Duplicate
path_prefix. Two projections each declare the same literalpath_prefix = "...", so both route to the same URL. - Double
default = true. Two projections each carrydefault = true; both target the bare resource path (/{resource}/{id}) and therefore collide. The seconddefault = trueis the offending span.
The proc-macro detects single-model collisions at compile time; the
framework’s Foundry::build() startup pass covers cross-model
collisions as defense-in-depth (see FRG-312 for the
projection-vs-resource case).
Bad (duplicate path_prefix).
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin", path_prefix = "/staff", read = [id, title]))]
#[ferra(projection(name = "manager", path_prefix = "/staff", read = [id, title]))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Bad (double default = true).
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(
name = "v2", default = true,
promotes_from = "auto-derived", breaking_change_version = "2.0.0",
))]
#[ferra(projection(
name = "v3", default = true,
promotes_from = "named:v2", breaking_change_version = "3.0.0",
))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Good.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin", path_prefix = "/staff", read = [id, title]))]
#[ferra(projection(name = "manager", path_prefix = "/manager", read = [id, title]))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Fix. Give each projection a distinct path_prefix, or rename one
of the projections so the auto-derived prefix differs. For the
default = true form, keep at most one default projection per
model — to migrate the default shape, retire the previous
default = true while landing the replacement (and pair the new
default with the promotes_from + breaking_change_version
attestations from FRG-316).
Fixture.
crates/ferra-forge/tests/diagnostics/frg_310_projection_prefix_collision.rs
(duplicate path_prefix form);
crates/ferra-forge/tests/diagnostics/frg_310_double_default_projection.rs
(double default = true form).
FRG-311 — projection name is not a URL-safe segment
Trigger. A projection’s name = "..." cannot be normalised to a
URL-safe segment (it contains characters outside [a-z0-9_-] after
to_lowercase()) AND the projection does not supply an explicit
path_prefix = "..." override. The framework needs a URL-safe segment
to route the projection; when the name cannot produce one, the
developer must either rename the projection or supply a path prefix
verbatim.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin console"))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Good (rename).
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin-console"))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Good (explicit prefix).
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin console", path_prefix = "/admin-console"))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Fix. Rename the projection to use only lowercase ASCII letters,
digits, hyphens, and underscores; or supply an explicit
path_prefix = "/some-segment" to bypass the auto-derive.
Fixture. crates/ferra-forge/tests/diagnostics/frg_311_projection_name_invalid_url_segment.rs
FRG-312 — projection prefix collides with a resource route
Trigger. A projection’s auto-derived URL prefix collides with an
existing resource route mounted elsewhere on the same Foundry. For
example: model A is mounted at /admin, and model B declares a
projection named admin — the projection would shadow the
top-level resource at the shared URL prefix.
Detection site. This collision spans two models, so the proc-macro
cannot see it at compile time (it parses one struct at a time).
Detection lives at Foundry::build(): the runtime walks every
registered resource route plus every projection prefix and panics on
overlap with a FRG-312 error. The proc-macro emits no hint for this
code.
Status at 0.6.5. The runtime collision check ships with the
projection-routing wiring. A dedicated tests/diagnostics/frg_312_*.rs
proc-macro fixture is intentionally absent — the diagnostic surface is
runtime (a panic with the FRG-312 prefix), so the test lives as an
integration assertion alongside other Foundry::build() checks rather
than as a trybuild fixture.
Bad (illustrative — runtime).
// Two models on the same Foundry:
// - `Film` is mounted at the resource route `/admin`.
// - `User` declares a projection named `admin`, auto-deriving to `/users/admin`.
//
// If `Film`'s mount path were `/admin/...` and `User`'s `admin`
// projection auto-derived to a route that overlaps with `Film`'s
// nested URL surface, `Foundry::build()` would panic with FRG-312.
#[derive(Clone, Debug, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "admin", read = [id, email]))]
pub struct Model { /* ... */ }
Fix. Rename one of the colliding sides — either pick a different
projection name (auto-deriving a different prefix), or remount the
conflicting resource under a different top-level path. The
Foundry::build() panic message names both colliding routes so the
rename is mechanical.
Fixture. Runtime-only at 0.6.5; no proc-macro tests/diagnostics/
fixture in this release. The Foundry::build() integration test in
crates/ferra-http/tests/ (lands with the cross-model collision
check) is the byte-shape contract.
FRG-314 — write_only field omitted from an explicit write list
Trigger. A named projection declares an explicit
write = [...] list that omits a #[ferra(write_only)] field on the
host model. The default-write projection (auto-derived when
write = [...] is omitted) already includes every write-only field
by construction; this rejection only applies to projections that opt
into an explicit list. The help suggests adding the missing field or
removing the #[ferra(write_only)] declaration. Diagnostic span on
the projection’s write key.
Bad.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "register", read = [id, email], write = [email]))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
#[ferra(write_only)]
pub password_hash: String,
}
Good (include password_hash in write).
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "register", read = [id, email], write = [email, password_hash]))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
#[ferra(write_only)]
pub password_hash: String,
}
Good (drop explicit write and rely on the auto-derived default).
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "register", read = [id, email]))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
#[ferra(write_only)]
pub password_hash: String,
}
Fix. Add <missing_field> to the projection’s write = [...]
list, OR remove #[ferra(write_only)] from the field’s declaration
if the field genuinely should not be writable through this
projection. If the projection is meant to accept every writable
field, drop the explicit write = [...] and rely on the default.
Fixture. crates/ferra-forge/tests/diagnostics/frg_314_write_only_omitted_from_explicit_write.rs
FRG-315 — path_prefix declared on a sub-resource
Trigger. A model mounted as a sub-resource of another model (Foundry’s nested-mounting pattern) carries path_prefix = "..." on one of its #[ferra(projection(...))] declarations. The URL surface of a nested resource is governed exclusively by the root resource’s projection prefix (ADR-0030 §Nested resources); a sub-resource overriding the URL would break the one-prefix-per-request invariant.
Detection site. Primary detection lives at Foundry::build() time — the proc-macro cannot fully detect sub-resource context (it sees one struct at a time). The proc-macro carries a hint emit at the path_prefix attribute as defense-in-depth.
Status at 0.6.5. The framework’s nested-mounting API ships in a future release. The FRG-315 namespace slot and the parser hint are in place; the runtime fixture lands when nested-mounting becomes wireable.
Bad.
// Imagined nested-mounting flow (future API):
// Foundry::new(conn).mount::<Film>().mount_nested::<Review, Film>().build()
#[derive(Clone, Debug, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "reviews")]
#[ferra(projection(
name = "detail",
path_prefix = "/v2", // ← Review is mounted as a sub-resource of Film;
read = [id, content], // its URL surface MUST come from Film's prefix.
))]
pub struct Model { /* ... */ }
Fix. Remove the path_prefix from the nested model’s projection. To version a sub-resource independently of its parent, version the root resource’s projection — the prefix propagates uniformly to every nested segment per ADR-0030 §Nested resources.
#[ferra(projection(
name = "detail",
// path_prefix removed — the prefix inherits from Film's `detail` projection.
read = [id, content],
))]
FRG-316 — default = true requires promotes_from + breaking_change_version
Trigger. A projection declares default = true without both promotes_from = "..." and breaking_change_version = "..." attestations on the same declaration. Setting default = true replaces the bare-path response shape — a silent breaking change to the public API contract (consumers continue requesting /{resource}/{id} and receive a different schema). The two attestations force the change to be visible in the source.
Why both are required. promotes_from is purely declarative — the macro does not validate it against repository history. Its purpose is to force the developer to name what is being replaced ("auto-derived" for the framework’s default, or "named:<other>" for a previous named-default projection). breaking_change_version surfaces in the OpenAPI document as the x-ferra-promoted-in: "X.Y.Z" vendor extension on every operation under the bare resource path, so SDK consumers and downstream tooling can detect the silent break.
A future release adds a .ferra/projection-defaults.lock file with schema-hash drift detection (Layer 2); at 0.6.5 the Layer-1 attestation is the sole defence.
Bad.
#[ferra(projection(
name = "v2",
default = true, // ← replaces the bare /films/{id} shape silently
read = [id, title],
write = [title],
))]
pub struct Model { /* ... */ }
Fix.
#[ferra(projection(
name = "v2",
default = true,
promotes_from = "auto-derived", // ← attestation of the previous default
breaking_change_version = "2.0.0", // ← app version in which the promotion lands
read = [id, title],
write = [title],
))]
pub struct Model { /* ... */ }
If the previous default was itself a named projection (rather than the auto-derived shape), use promotes_from = "named:<other-projection-name>".
Fixture. crates/ferra-forge/tests/diagnostics/frg_316_default_promotion_missing_attestation.rs
Custom handlers
Most Ferra apps need at least one route that is not pure CRUD — a publish action, a derived collection, an aggregation, a webhook receiver. This page is the authoritative guide to writing those handlers on top of a Ferra-served API. A reader with this page plus standard Rust knowledge produces a compiling action route on the first attempt — no framework source, no ADR, no constitution required.
The companion CRUD / envelope / Tower-stack / typed-extraction reference is ferra-http.md. The router-assembly facade is foundry.md.
When ferra_router::<M> is not enough
The five built-in handlers cover the resource-shaped endpoints — list, read, create, update, delete. They are all you need for ~80 % of the surface a typical app exposes.
You write a custom handler when the route is not one of those five operations. Common reasons:
- Action routes.
POST /films/{id}/publish,POST /invoices/{id}/cancel,POST /accounts/{id}/transfer— verbs on a resource that aren’t expressible asPUT. - Derived collections.
GET /films/popular,GET /actors/{id}/films— collections whose membership is computed, not justSELECT * WHERE .... - Multi-resource projections.
GET /dashboardreturning a join of three resources at once. - Webhook receivers.
POST /webhooks/stripereading a non-Ferra payload shape. - Cross-cutting endpoints.
GET /healthz,GET /metrics.
Custom handlers are plain Axum handlers. They accept Axum extractors, return Result<_, FerraError> for RFC 7807 error continuity, and compose with the framework router via axum::Router::merge or by appending .route(...) on top of Foundry::build() / ferra_router::<M>(state).
The worked example: POST /films/{id}/publish
The complete shape of an action route — model declaration, handler signature, composition with Foundry — in one file. Drop this into a fresh src/main.rs whose Cargo.toml declares the canonical four-dep set (ferra, tokio, axum, serde) and it compiles.
use ferra::prelude::*;
use axum::{
Json, Router,
http::request::Parts,
routing::post,
};
// --- The model -----------------------------------------------------
mod film {
use super::*;
#[derive(
Clone, Debug, PartialEq, Eq,
DeriveEntityModel, FerraModel,
Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub year: Option<i32>,
pub release_date: Option<Date>,
pub published: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub use film::Model as Film;
// --- The action's request body -------------------------------------
// Plain serde struct — nothing Ferra-specific. The `FerraJson<T>`
// extractor on the handler signature accepts any `T: DeserializeOwned`.
#[derive(Deserialize)]
struct PublishRequest {
release_date: Option<Date>,
}
// --- The handler ---------------------------------------------------
// `#[axum::debug_handler]` is optional but turns the dense
// `Handler` trait-bound error into a single, targeted message
// when one of the rules below is violated. Keep it on while
// iterating; it is a no-op at runtime.
#[axum::debug_handler]
async fn publish_film(
// FromRequestParts — `State` reads only the request parts,
// so it can be in any position.
State(state): State<FerraState<Film>>,
// FromRequestParts — `FerraPath` is the typed-extraction
// wrapper around `axum::extract::Path<T>`.
FerraPath(id): FerraPath<i32>,
// FromRequestParts — `Parts` itself, used below for
// building HAL `_links` from the request URL.
parts: Parts,
// FromRequest — `FerraJson` consumes the request body. Body-
// consuming extractors MUST be the LAST argument.
FerraJson(body): FerraJson<PublishRequest>,
) -> Result<Json<ItemResponse<Film>>, FerraError> {
// 1. Read the existing row.
let mut film = state
.repo
.find_by_id(id)
.await
.map_err(FerraError::from)?;
// 2. Apply the action's effect.
film.published = true;
if let Some(d) = body.release_date {
film.release_date = Some(d);
}
// 3. Persist.
let updated = state
.repo
.update(id, film)
.await
.map_err(FerraError::from)?;
// 4. Build the response envelope with HAL `_links`. The two
// helpers live at the facade root (not the prelude) — reach
// them via the fully-qualified path or add an explicit `use`.
let base_url = ferra::base_url_from_parts(&parts);
let links = ferra::build_item_links(
Film::meta(),
&updated.id.to_string(),
&base_url,
);
Ok(Json(ItemResponse { data: updated, links }))
}
// --- Composition with Foundry --------------------------------------
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let conn = ferra::sea_orm::Database::connect(&database_url).await?;
// Foundry assembles the CRUD + `/docs` surface from `conn`.
let api = Foundry::new(conn.clone())
.mount::<Film>()
.with_docs()
.build();
// The action route lives on a sibling sub-router with its own
// `FerraState<Film>`. `DatabaseConnection: Clone` (Sea-ORM pool
// refcount); `FerraState<M>: Clone` (Arc refcount) — both clones
// are cheap.
let custom = Router::new()
.route("/films/{id}/publish", post(publish_film))
.with_state(FerraState::<Film>::new(conn));
let app = api.merge(custom);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
A successful POST /films/42/publish with body {"release_date": "2026-05-10"} returns 200 OK and the same envelope shape as GET /films/42:
{
"id": 42,
"title": "Rust en pratique",
"director": "Alice",
"year": 2025,
"release_date": "2026-05-10",
"published": true,
"_links": {
"self": { "href": "https://api.example.com/films/42" },
"collection": { "href": "https://api.example.com/films" }
}
}
Errors flow through the same FerraError taxonomy the built-in handlers use — a malformed JSON body returns 400 with type: "https://ferra.rs/errors/validation"; a missing row returns 404 with type: "https://ferra.rs/errors/not_found". Clients that handle one Ferra error already handle this one.
Extractor ordering: FromRequest vs FromRequestParts
Axum has two extractor traits. The distinction looks academic until you write your first multi-extractor handler — then it becomes the load-bearing rule.
| Trait | Reads from | Examples | Position |
|---|---|---|---|
FromRequestParts | request parts only (method, URI, headers, state) | State<T>, Path<T>, Query<T>, Parts, HeaderMap, FerraPath<T> | any position |
FromRequest | the whole request, including the body (consumed) | Json<T>, Bytes, String, Form<T>, FerraJson<T> | last argument only |
The rule: at most one body-consuming extractor (FromRequest) per handler, and it MUST be the last argument. Every other extractor is body-agnostic (FromRequestParts) and goes earlier.
This is a fundamental property of HTTP, not an Axum design quirk: a request body is a one-shot stream. Once you have read it, the bytes are gone.
The handler in the worked example follows the rule:
async fn publish_film(
State(state): State<FerraState<Film>>, // FromRequestParts
FerraPath(id): FerraPath<i32>, // FromRequestParts
parts: Parts, // FromRequestParts
FerraJson(body): FerraJson<PublishRequest>, // FromRequest — LAST
) -> Result<...> { ... }
Reorder FerraJson ahead of FerraPath and the build fails. Without #[axum::debug_handler] the compiler reports the violation through a deep trait-bound chain on Handler. With #[axum::debug_handler] the diagnostic points directly at the offending argument.
Multiple body extractors. Axum allows exactly one body-consuming extractor per handler. If you legitimately need to look at the raw bytes and a typed JSON deserialization, do both inside a single FromRequest-implementing wrapper, or read Bytes and parse it explicitly with serde_json::from_slice. There is no Json<A> + Json<B> shape.
Send + Sync + 'static: the bound-set cheat-sheet
Tokio runs handlers on a multi-threaded executor by default. Every value Axum carries through the handler must be sendable across threads and outlive the request. The compiler enforces this through Send + Sync + 'static-shaped bounds on the Handler trait — failures here look daunting at first read.
The cheat-sheet:
| Symptom | Likely cause | Fix |
|---|---|---|
the trait Send is not implemented for ... inside an async fn | a !Send value is held across an .await (Rc<_>, RefCell<_>, MutexGuard from std::sync::Mutex) | use Arc<_>, tokio::sync::Mutex, or restructure so the offending value is dropped before the .await |
the trait Sync is not implemented for ... on State<T> | the state type is not Sync (e.g., it owns a non-Sync cache) | wrap the inner shared state in Arc<RwLock<...>> or restructure to a channel |
argument requires that ... must outlive 'static | the handler captures a non-'static reference (&str from outside the closure, a borrow from the request) | own the data (String, Bytes), or extract it via an extractor |
Handler<_, _> not satisfied with a long, hard-to-read chain | usually one of the three above, hidden behind Handler’s blanket impl | add #[axum::debug_handler] and read the first targeted error it produces |
Notes on the framework’s own types:
FerraState<M>isClone + Send + Sync + 'staticby construction — it wrapsArc<PgRepository<M>>. Pass it through any number of handlers freely.FerraJson<T>/FerraPath<T>add no bounds beyond the innerT: DeserializeOwned + Send + 'staticthat Axum already requires.FerraErrorisSend + Sync + 'staticand implementsaxum::response::IntoResponse— returnResult<_, FerraError>from any handler signature.
If your handler async fn body holds a !Send value across an .await, the bound failure surfaces in the Handler impl, not in the body. Move the value into a synchronous block or drop it before the await.
Friendlier diagnostics with #[axum::debug_handler]
Axum’s Handler trait has a blanket impl that erases per-argument type information by the time the bound failure is reported. Without help, a single wrong extractor produces a multi-screen not satisfied chain.
#[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:
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()
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>
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
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:
use std::sync::Arc;
use ferra::PgRepository;
async fn count_films(
State(repo): State<Arc<PgRepository<Film>>>,
) -> Result<Json<u64>, FerraError> {
let page = repo.find_page(1, 1).await.map_err(FerraError::from)?;
Ok(Json(page.total))
}
FerraState<M>: FromRef<Arc<PgRepository<M>>> makes this swap zero-effort — Axum derives the inner extractor from the outer state. Use it when the handler genuinely does not need the rest of the state.
Cross-references (for the curious reader)
Pointers below trace decisions back to their arbitration. They are not required reading for correct use of the APIs above — every guarantee stated on this page stands on its own.
ferra-http.md— the typed-extraction reference, the full RFC 7807 taxonomy, and the Tower layer-ordering diagram. Cross-read it when wiring observability or auth around custom handlers.foundry.md— the router-assembly facade. The composition patterns above attach action routes to aFoundry::build()output.ferra-error-handling.md— the closedERROR_TYPESURI namespace and the consumer-side error-branching pattern.ferra-core.md§“Time vocabulary” —DateandDateTime(used in the worked example’srelease_datefield).
Error handling
Every Ferra-served HTTP response that is not a success carries an
RFC 7807 problem-details body (Content-Type: application/problem+json).
Every body’s type field is a URI from a closed set the framework
maintains as a single source of truth.
This page is the reference for that closed set: the URI table, the HTTP status / title / example body for each variant, and the consumer-side branching pattern.
The closed ERROR_TYPES URI set
The framework can emit exactly eight type URIs on the wire. Every
literal in framework source (and in any consumer crate) MUST appear
in this set; a CI gate (error_types_closed.sh) fails the build on
any drift.
| URI | HTTP status | Title | Source |
|---|---|---|---|
https://ferra.rs/errors/not_found | 404 Not Found | Resource Not Found | FerraError::NotFound |
https://ferra.rs/errors/validation | 400 Bad Request | Validation Error | FerraError::Validation (extractor failures: malformed JSON body, unparseable path parameter) |
https://ferra.rs/errors/validation_failed | 422 Unprocessable Content | Validation Failed | FerraError::Unprocessable (well-formed body whose values violate #[field(...)] rules — see § Validation failures (422)) |
https://ferra.rs/errors/conflict | 409 Conflict | Conflict | FerraError::Conflict |
https://ferra.rs/errors/internal | 500 Internal Server Error | Internal Server Error | FerraError::Internal |
https://ferra.rs/errors/payload_too_large | 413 Payload Too Large | Payload Too Large | request-body-limit middleware |
https://ferra.rs/errors/rate_limited | 429 Too Many Requests | Too Many Requests | rate-limit middleware |
https://ferra.rs/errors/unauthorized | 401 Unauthorized | Unauthorized | Foundry::with_docs_protected(...) |
Three of the eight 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.
The validation (400) and validation_failed (422) URIs are
intentionally distinct. A malformed request body (JSON parse
failure, type mismatch on a required field, an Id parameter that
will not parse) returns 400 — the request was syntactically
unprocessable. A well-formed request body whose values violate
the model’s declarative #[field(...)] rules returns 422 — the
request parsed cleanly but its semantics were rejected. The split
follows RFC 9110 §15.5.1 (400) vs §15.5.21 (422).
Worked example bodies
not_found — 404
Returned by every read / update / delete that resolves to zero matching rows.
{
"type": "https://ferra.rs/errors/not_found",
"title": "Resource Not Found",
"status": 404,
"detail": "films/c2bb1f10-72b8-486a-9f2b-c92e4a2cdf41 not found"
}
validation — 400
Returned when an inbound JSON body or a path parameter fails the
framework’s typed deserialisation, or when an Id parameter fails
to parse. Declarative-rule violations on a well-formed body are
the separate 422 path — see § Validation failures (422).
{
"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 on the 400 path carries a flat list of error
messages per field name. The 422 path uses a sibling shape — see
the worked example linked above.
conflict — 409
Returned when a write would violate a unique constraint (duplicate slug, primary-key collision, etc.).
{
"type": "https://ferra.rs/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "title 'Casablanca' is already in use"
}
internal — 500
Returned for any database / infrastructure failure not classifiable
as one of the typed variants above. detail is always the constant
literal "internal server error" — no internal path fragment, no
crate name, no underlying-library substring leaks (constitution §I).
{
"type": "https://ferra.rs/errors/internal",
"title": "Internal Server Error",
"status": 500,
"detail": "internal server error"
}
payload_too_large — 413
Emitted when an inbound request body exceeds the framework’s default 1 MiB cap (constitution §I — DoS protection).
{
"type": "https://ferra.rs/errors/payload_too_large",
"title": "Payload Too Large",
"status": 413,
"detail": "request body too large"
}
rate_limited — 429
Emitted by the framework’s default rate-limiter on mutation routes
(POST / PUT / DELETE). The response carries a Retry-After header
when the limiter can compute a sensible delay.
{
"type": "https://ferra.rs/errors/rate_limited",
"title": "Too Many Requests",
"status": 429,
"detail": "rate limit exceeded"
}
unauthorized — 401
Emitted by Foundry::with_docs_protected(verifier) when an
unauthenticated request reaches the docs surface. New in 0.5.0.
{
"type": "https://ferra.rs/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "authentication required"
}
validation_failed — 422
Returned when an inbound POST or PUT body parses cleanly but
violates one or more #[field(...)] rules declared on the target
model. The full worked example with multi-field aggregation lives
in § Validation failures (422); the
short form:
{
"type": "https://ferra.rs/errors/validation_failed",
"title": "Validation Failed",
"status": 422,
"detail": "validation failed",
"errors": {
"title": ["must be at least 1 character"]
}
}
The errors object on this variant is shaped
Map<String, Vec<String>> keyed by the wire-side field name —
every violation observed during the validation pass is included
(no short-circuiting), so a consumer never needs to retry to
discover further violations.
Validation failures (422)
A 422 response fires when the request body is well-formed — it
parses as JSON and deserialises into the model’s request shape —
but one or more values violate the declarative #[field(...)]
rules on the target model. Validation runs before persistence
on every POST (create) and PUT (update) handler, so an
invalid payload never reaches the database. The wire shape is
identical across create and update.
This is the 422 path. The 400 validation path above continues
to denote extractor failures — a body that does not parse as
JSON at all, a path parameter that will not parse as the
declared type. Both paths share the application/problem+json
envelope; only the 422 path carries the per-field errors map.
The model-side declaration grammar — every supported rule, its
default English message, and the compile-time FRG-2NN codes
that protect typos — is documented in
ferra-forge.md § Validation rules.
This section pins the wire shape your consumer sees.
Worked example: multi-field violation
For a Film resource declared as:
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1, max_length = 255)]
pub title: String,
#[field(min = 0, max = 10)]
pub rating: i32,
}
a request that violates two fields simultaneously:
POST /films HTTP/1.1
Content-Type: application/json
{ "title": "", "rating": 99 }
receives:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://ferra.rs/errors/validation_failed",
"title": "Validation Failed",
"status": 422,
"detail": "validation failed",
"errors": {
"rating": ["must be at most 10"],
"title": ["must be at least 1 character"]
}
}
Both fields appear in the same response. Ferra never short-circuits
on the first failed rule, so a consumer that submits a payload with
N invalid fields receives N entries in errors in a single
round-trip. Within a single field, every rule that fired produces
its own message — #[field(min_length = 5, email)] on a value of
"ab" produces errors.<field> = ["must be at least 5 characters", "is not a valid email address"] (two entries).
Assertion contract for SDK consumers
A consumer driving validation off the wire response asserts the following invariants on a 422:
| Wire field | Value | Notes |
|---|---|---|
| HTTP status | 422 | always equal to the body’s status integer |
Content-Type header | application/problem+json | identical envelope to every other Ferra error |
type | "https://ferra.rs/errors/validation_failed" | pinned URI; closed-namespace member (table above) |
title | "Validation Failed" | distinct from "Validation Error" (the 400 path’s title) |
status | 422 (integer) | |
detail | "validation failed" | constant literal — does NOT echo input back into the response (constitution §I — no reflected-XSS on the error path) |
errors | Map<String, Vec<String>> | keys are wire-side field names (the same names that appear in the request body’s JSON); values are non-empty arrays of human-readable English messages, one entry per rule that fired |
The errors map’s keys preserve the request’s wire-side names —
when a model uses serde’s #[serde(rename = "...")] to map a
wire-side name to a different Rust-side field name, the errors
map keys by the wire-side name (what the consumer submitted and
what they will inspect).
_links is never present on a 422 body. Validation failure is
terminal — there is no resource yet, no self-link to thread, no
hypermedia to follow. A consumer that branches on the response’s
_links.self.href should treat 422 as the absent-link case.
Update-vs-create symmetry
The validation step runs identically on POST /<resource> and
PUT /<resource>/{id}. The 422 body is identical across both.
A field omitted from a PUT body is “no change” (Ferra’s
existing partial-update semantics) and bypasses that field’s
per-field rules. A field present with explicit null on a PUT
body for a #[field(required)] Option<T> field fails with
["is required"] — null on the wire is a positive declaration
of “set to none”, which the rule rejects.
Consumer-side branching pattern
Add one branch to the existing match on the wire type field:
match body["type"].as_str() {
Some("https://ferra.rs/errors/validation_failed") => {
// 422 path — read body["errors"] as Map<String, Vec<String>>
// and surface each (field, msg) pair into your form's
// per-field error UI.
}
Some("https://ferra.rs/errors/validation") => {
// 400 path — extractor failure; the body shape differs
// (flat `errors` list, not the per-field map).
}
// ... other arms ...
_ => /* unreachable on a non-fabricated error */,
}
Server-side, when handling a FerraError value, the 422 path
reads through the new variant:
use ferra::FerraError;
fn observe(err: &FerraError) {
match err {
FerraError::Unprocessable(_) => {
// 422 — declarative-rule violation
}
FerraError::Validation(_) => {
// 400 — extractor failure
}
// other arms unchanged
_ => {}
}
}
FerraError::Unprocessable is the typed home for the 422 path;
FerraError::error_type returns
"https://ferra.rs/errors/validation_failed" on this variant.
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.
match body["type"].as_str() {
Some("https://ferra.rs/errors/not_found") => /* 404 path */,
Some("https://ferra.rs/errors/validation") => /* 400 path; extractor failure */,
Some("https://ferra.rs/errors/validation_failed") => /* 422 path; #[field(...)] rule violation */,
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:
use ferra::FerraError;
fn log_one(err: &FerraError) {
tracing::warn!(error_type = err.error_type(), "request failed");
}
FerraError::error_type returns one of the five URIs that map
through the typed enum (not_found, validation, validation_failed,
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 eight 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
eight URIs above fails OpenAPI schema validation against the spec.
Observability events
Two structured tracing events fire alongside the wire surface of the
error namespace and the deprecation surface. Both follow the
<snake-cased crate name>::<event segment> target convention so an
operator can filter them independently via tracing-subscriber’s
EnvFilter (RUST_LOG=ferra_http::sunset=warn, etc.).
| Target | Level | Trigger | Documented at |
|---|---|---|---|
ferra_http::rate_limit | WARN | every 429 emission from the governor middleware | this file § “rate_limited — 429” |
ferra_http::sunset | WARN | once per (version, sunset) per process when a request lands past the declared sunset date | foundry.md § “Post-sunset warn-event” |
The post-sunset event carries five fields (version, sunset,
days_overdue, http.method, http.target); the rate-limit event
carries three (http.method, http.target, http.retry_after_seconds).
Both event-schemas are contracts under ADR-0015 §“event schema is a
contract” — additive widenings only, never renames.
When the observability Cargo feature is on, the framework ALSO
advances a metrics::counter! named ferra.sunset.post_sunset_hits
on every post-sunset hit (regardless of the once-per-process dedup
that gates the warn-event). See foundry.md § “Post-sunset
warn-event” for the full surface.
Adding a new URI
The framework’s URI namespace is closed by design: a new variant requires a coordinated change across three surfaces.
- Add the URI literal to
ERROR_TYPESincrates/ferra-core/src/error.rs. - Extend the OpenAPI emitter so
ProblemDetails.typecarries the new value in itsenumconstraint (the emitter readsERROR_TYPESdirectly; this step is automatic once step 1 lands). - Publish at least one HTML anchor on
https://ferra.rs/errors/<variant>returning HTTP 200, so that AI agents and human readers reach a stable documentation page for the new variant.
The release-blocking CI gates verify (1) source-side closure
(error_types_closed.sh) and (3) network-side resolution (the URI
probe). New variants without all three gates green fail the build.
The closed-namespace contract is recorded in ADR-0026. The contract is not load-bearing for consumer correctness — the URI table above is self-contained.