ferra-http — the Axum CRUD handler layer
The 0.4.0 Refining deliverable. Turns a #[derive(FerraModel, DeriveEntityModel)] entity into a fully-wired Axum router with RFC 7807 errors, HAL-lite hypermedia, pagination, and constitutional security defaults — zero hand-written routes, zero hand-written error mapping.
This page is the authoritative user-guide for ferra-http. A reader with only this page plus standard Rust knowledge produces a compiling ferra_router::<Film>(state) call site on the first attempt — no ADR, no constitution, and no framework source is required.
Zero-to-API in five lines
#![allow(unused)]
fn main() {
use ferra::*;
let conn = sea_orm::Database::connect(&database_url).await?;
let state = FerraState::<Film>::new(conn);
let app = ferra_router::<Film>(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
}
Drop one #[derive(FerraModel, DeriveEntityModel, Serialize, Deserialize)] on a struct whose PK is i32 (or any Display + DeserializeOwned + Send + Sync + 'static) and you get the five CRUD endpoints (GET /films / GET /films/:id / POST /films / PUT /films/:id / DELETE /films/:id), a Location header on creation, a Warning: 214 header on per_page clamp, and an application/problem+json body on every error — with no further code.
See examples/hello-ferra/ for the full worked example with PostgreSQL container + Rust-coded migrations.
The FerraState<M> constructor
#![allow(unused)]
fn main() {
pub struct FerraState<M> { pub repo: Arc<PgRepository<M>> }
impl<M> FerraState<M> {
pub fn new(pool: DatabaseConnection) -> Self;
}
}
#[non_exhaustive]— construct viaFerraState::new(pool); struct-literal construction is not supported. Future phases add fields (tracing context, auth identity) without breaking this.#[derive(Clone)]— cloning is a reference-count bump onArc<PgRepository<M>>. Every request clones.- Implements
axum::extract::FromRef<FerraState<M>> for Arc<PgRepository<M>>. Custom handlers that only need the repository writeState(repo): State<Arc<PgRepository<M>>>; the Ferra handlers all takeState(state): State<FerraState<M>>.
Instantiate one FerraState<M> per model per database; cheaply share the same DatabaseConnection across models by calling FerraState::new(conn.clone()) multiple times.
The five CRUD handlers
All five handlers are generic over M: FerraModel + <Sea-ORM bounds>. You rarely name them directly — ferra_router<M> mounts them for you. When you do, the HTTP contracts are:
| Handler | Method | Path | Request | Success | Error surfaces |
|---|---|---|---|---|---|
get_item<M> | GET | /{resource}/:id | — | 200 OK + Json<ItemResponse<M>> | 400 on path-extractor rejection · 404 on missing row · 500 on DB error |
get_collection<M> | GET | /{resource} | ?page={u32}&per_page={u32} | 200 OK + optional Warning: 214 … + Json<CollectionResponse<M>> | 500 on DB error |
create_item<M> | POST | /{resource} | Json<M> body | 201 Created + Location: {base}/{resource}/{id} + Json<ItemResponse<M>> | 400 on body parse · 409 on unique violation · 413 on oversized body · 429 on rate limit · 500 |
update_item<M> | PUT | /{resource}/:id | Json<M> body | 200 OK + Json<ItemResponse<M>> | 400 · 404 · 409 · 413 · 429 · 500 |
delete_item<M> | DELETE | /{resource}/:id | — | 204 No Content (no body) | 400 · 404 · 429 · 500 |
Path id is authoritative on update and delete — any id field in the request body is ignored for the WHERE clause. {resource} is <M as FerraModel>::meta().resource_name (e.g. "films").
RFC 7807 error taxonomy
Every error from every handler is rendered as application/problem+json per RFC 7807 §3. Six variants, locked through 0.6.0 Welding:
| Variant | HTTP status | type URI | title |
|---|---|---|---|
FerraError::NotFound { resource, id } | 404 | https://ferra.rs/errors/not_found | Resource Not Found |
FerraError::Validation(ValidationErrors) | 400 | https://ferra.rs/errors/validation | Validation Error |
FerraError::Conflict(String) | 409 | https://ferra.rs/errors/conflict | Conflict |
FerraError::Internal(String) | 500 | https://ferra.rs/errors/internal | Internal Server Error |
| (middleware) | 413 | https://ferra.rs/errors/payload_too_large | Payload Too Large 1 |
| (middleware) | 429 | https://ferra.rs/errors/rate_limited | Too Many Requests 1 |
Worked body for a 404:
{
"type": "https://ferra.rs/errors/not_found",
"title": "Resource Not Found",
"status": 404,
"detail": "films/42 not found"
}
Worked body for a 400 from a malformed JSON request:
{
"type": "https://ferra.rs/errors/validation",
"title": "Validation Error",
"status": 400,
"detail": "validation failed",
"errors": {
"errors": [
{ "field": "body", "code": "invalid_json", "message": "expected `,` or `}` at line 1 column 14" }
]
}
}
Internals never leak. The detail field on a 500 is always the literal string "internal server error"; the inner sea_orm::DbErr is dropped before reaching the HTTP body. The Axum "Failed to deserialize … into the target type: " prefix and any axum::extract::rejection:: substring are stripped at the extractor boundary; only human-readable messages reach errors[*].message.
Worked body for a 429 from the rate-limit mapper:
{
"type": "https://ferra.rs/errors/rate_limited",
"title": "Too Many Requests",
"status": 429,
"detail": "rate limit exceeded; retry after 3 seconds"
}
The response also carries the Retry-After header from the underlying rate-limit component verbatim (integer seconds). When the component does not emit a Retry-After header, detail degrades gracefully to the literal string "rate limit exceeded" and the header is absent from the response. The rate-limit mapper mounts only alongside the framework’s default rate-limit component — consumer-provided rate-limiters produce their own error shape (see ADR-0015 for rationale).
The HAL-lite envelope
Every successful body carries _links so an AI agent can navigate the API without prior URL knowledge.
ItemResponse<M> — single-item envelope
Model fields are flattened at the top level via #[serde(flatten)]. A Film { id: 1, title: "Rust en pratique", director: "Alice", year: Some(2025) } serializes as:
{
"id": 1,
"title": "Rust en pratique",
"director": "Alice",
"year": 2025,
"_links": {
"self": { "href": "https://api.example.com/films/1" },
"collection": { "href": "https://api.example.com/films" }
}
}
CollectionResponse<M> — paginated envelope
{
"items": [ /* array of ItemResponse<M> */ ],
"total": 25,
"page": 2,
"per_page": 10,
"_links": {
"self": { "href": "https://api.example.com/films?page=2&per_page=10" },
"next": { "href": "https://api.example.com/films?page=3&per_page=10" },
"prev": { "href": "https://api.example.com/films?page=1&per_page=10" },
"first": { "href": "https://api.example.com/films?page=1&per_page=10" },
"last": { "href": "https://api.example.com/films?page=3&per_page=10" }
}
}
On boundary pages, next or prev serialize as JSON null (never omitted). On total == 0, last_page == 1 and first == last == self.
Building links manually
#![allow(unused)]
fn main() {
pub fn build_item_links(meta: &ModelMeta, id: &str, base_url: &str) -> ItemLinks;
pub fn build_collection_links(
meta: &ModelMeta,
page: u32,
per_page: u32,
total: u64,
base_url: &str,
) -> CollectionLinks;
}
Use these when writing a custom handler that returns an envelope-shaped body.
Pagination
#![allow(unused)]
fn main() {
pub struct PaginationParams { pub page: Option<u32>, pub per_page: Option<u32> }
impl PaginationParams {
pub const fn with(page: Option<u32>, per_page: Option<u32>) -> Self;
pub fn resolved_page(&self) -> u32; // default 1; 0 → 1
pub fn resolved_per_page(&self) -> u32; // default 20; clamp to [1, 100]
pub fn clamped_per_page(&self) -> Option<u32>;
}
}
PaginationParams is #[non_exhaustive] — external struct-literal construction is rejected. For code that needs to build a PaginationParams outside an Axum Query<_> extractor (integration tests, admin tools, batch jobs), use the stable with(page, per_page) constructor:
#![allow(unused)]
fn main() {
use ferra::PaginationParams;
let p = PaginationParams::with(Some(3), Some(50));
assert_eq!(p.resolved_page(), 3);
assert_eq!(p.resolved_per_page(), 50);
let defaulted = PaginationParams::with(None, None);
assert_eq!(defaulted.resolved_page(), 1);
assert_eq!(defaulted.resolved_per_page(), 20);
let clamped = PaginationParams::with(None, Some(500));
assert_eq!(clamped.resolved_per_page(), 100);
assert_eq!(clamped.clamped_per_page(), Some(100));
}
The constructor stores the raw Option<u32> inputs as-is and applies the clamp / default lazily through the resolved_* / clamped_per_page methods — values produced via with(...) are observationally identical to those that reach a handler through the HTTP extraction path.
- Default:
page=1,per_page=20. - Cap:
per_page ≤ 100(hard limit). Values below 1 or above 100 are silently clamped and the response carriesWarning: 214 - "per_page clamped to {N} (max 100)"per RFC 7234 §5.5.4. - The response body’s
per_pagefield reports the clamped value (so clients can detect the clamp without parsing theWarningheader).
Worked curl:
$ curl -D - 'http://localhost:3000/films?per_page=500'
HTTP/1.1 200 OK
warning: 214 - "per_page clamped to 100 (max 100)"
content-type: application/json
...
{"items":[...],"total":523,"page":1,"per_page":100,"_links":{...}}
Base-URL extractor
#![allow(unused)]
fn main() {
pub fn base_url_from_parts(parts: &http::request::Parts) -> String;
}
Produces the base_url prefix used by build_item_links / build_collection_links. Behavior:
- Scheme: first comma-separated token of
X-Forwarded-Protoif it matches the{http, https}allowlist; otherwise"http". - Host: the
Hostheader if present and non-empty; otherwise"localhost". - Output format:
"{scheme}://{host}". No trailing slash.
Anti-spoofing: X-Forwarded-Proto: javascript (or any non-allowlisted scheme) falls back to http. A spoofed header cannot inject a javascript: URL into _links.*.href by construction.
Deploying behind a TLS-terminating load balancer? Ensure the LB sets X-Forwarded-Proto: https so the generated links match the client-visible scheme.
Custom extractors
See custom-handlers.md — the rationale for FerraJson<T> / FerraPath<T> (RFC 7807 error continuity), the FromRequest vs FromRequestParts ordering rule, the Send + Sync + 'static cheat-sheet, and a worked end-to-end POST /films/{id}/publish action route.
Security defaults
ferra_router::<M>(state) mounts a deny-by-default Tower layer stack — a consumer who writes exactly those five lines is already constitution-§I compliant:
| Layer | Default | Override path |
|---|---|---|
tower_http::cors::CorsLayer::new() | Deny all cross-origin | FerraLayer::cors(...) at 0.7.0 Pre-tempering |
| 413 body-limit mapper | Rewrites stock text/plain → application/problem+json with type: .../payload_too_large | — (permanent) |
tower_http::limit::RequestBodyLimitLayer | 1 MiB | FerraLayer::body_limit(...) at 0.7.0 |
tower_http::trace::TraceLayer | Body sampling OFF (never logs PII) | Per-route opt-in at 0.12.0 Sheen |
tower_governor::GovernorLayer on mutation endpoints | per_second(2) + burst_size(5) + peer-socket-IP key | Full config at 0.9.0 Forging III |
PaginationParams clamp (FR-017) | Loud Warning: 214 header on every clamp | — (constitutional) |
X-Forwarded-Proto allowlist | {http, https} only | — (constitutional) |
429 wire format — the framework’s default rate-limit component’s 429 responses are rewritten to application/problem+json with type: https://ferra.rs/errors/rate_limited, preserving the Retry-After header from tower_governor verbatim. See the worked body in §“RFC 7807 error taxonomy” above. Oversized bodies produce the same application/problem+json shape — a client parsing type never sees a mixed wire format.
Rate-limit observability event. Each time the rate-limit mapper rewrites a 429, it emits a single structured tracing::warn! event so an operator tailing logs can see rate-limit rejections without reading the framework source:
- Target:
ferra_http::rate_limit— namespaced so an operator can isolate it viaEnvFilter(RUST_LOG=ferra_http::rate_limit=warn). - Message:
rate limit exceeded. - Severity:
WARN. - Fields (deliberately minimal):
http.method— the request HTTP method (e.g."POST"). Always present.http.target— the request path-and-query (e.g."/films"or"/films?page=2"). Always present.http.retry_after_seconds— integer seconds parsed from the inboundRetry-Afterheader. Present only when the header parses as a non-negative integer; genuinely absent otherwise (not recorded as0).
Request and response body content never appear in this event — only the three fields listed above. The schema is pinned minimal so the framework’s dedicated observability phase at 0.12.0 Sheen can absorb it into span-based telemetry (adding trace_id, span_id, and peer-IP once the trusted-proxy story lands) without breaking a consumer’s existing EnvFilter or custom tracing::Layer.
Wire up a subscriber in your host binary’s main to surface these events:
#![allow(unused)]
fn main() {
tracing_subscriber::fmt()
.with_env_filter("ferra_http::rate_limit=warn")
.init();
}
Layer placement (FR-006). The rate-limit mapper mounts as the outer layer of the mutation sub-router only, wrapping tower_governor::GovernorLayer from the outside (the last .layer() call on that sub-router). Consumer-added middleware appended to ferra_router::<M>(state) wraps the mapper from further outside — see §“Layer ordering — how .layer() composes” below for the full diagram.
Layer ordering — how .layer() composes
Tower’s .layer() wraps the current router as the inner service, so the last .layer() call becomes the outermost layer. A .layer(MyCustomLayer::new()) appended to ferra_router::<M>(state) therefore wraps every Ferra-provided layer from the outside.
Side-by-side: the conceptual stack (top = outermost, bottom = innermost) and the literal .layer() call sequence in ferra_router::<M>(state) (top = first call, bottom = last call). Arrows map the inversion:
CONCEPTUAL stack (request top→bottom): LITERAL call sequence (first→last in source):
outer → CorsLayer .layer(DefaultBodyLimit::disable()) ← first call (innermost)
body_limit_mapper .layer(TraceLayer::new_for_http())
RequestBodyLimitLayer(1 MiB) .layer(RequestBodyLimitLayer::new(1 MiB))
TraceLayer .layer(middleware::from_fn(body_limit_mapper))
DefaultBodyLimit::disable .layer(CorsLayer::new()) ← last call (outermost)
(on mutation sub-router:)
rate_limit_mapper [on mutation sub-router:]
GovernorLayer .layer(GovernorLayer { ... }) ← first call on mutations
inner → handlers .layer(middleware::from_fn(rate_limit_mapper)) ← last (outer)
Reading it: a request enters at CorsLayer (outermost), traverses down to the handler, and the response retraces the stack back out. In the source, the call order is inverted — the first .layer() call is the innermost layer on the merged router, and the last .layer() call is the outermost.
Mutation sub-router subtlety. GovernorLayer (rate-limit) and rate_limit_mapper (the 429 RFC 7807 wrapper) mount on the mutation sub-router only — never on the merged router and never on read routes. rate_limit_mapper is the last .layer() call on that sub-router, so it wraps GovernorLayer from the outside and sees the 429s that layer produces. Placing a custom observability layer is a decision about which sub-router you wrap — consumers who want to observe only mutations attach to a sub-router; consumers who want to observe everything attach to the merged router returned by ferra_router::<M>(state).
Worked consumer example. A consumer appending two custom layers on top of the framework stack:
#![allow(unused)]
fn main() {
let app = ferra_router::<Film>(state)
.layer(MyObservabilityLayer::new()) // outer: sees requests before the framework
.layer(MyShedLoadLayer::new()); // appended AFTER → now the TRUE outermost
}
On incoming requests: MyShedLoadLayer runs first, then MyObservabilityLayer, then the framework stack (CORS → body_limit_mapper → RequestBodyLimitLayer → TraceLayer → DefaultBodyLimit::disable → handlers; with the mutation-only branch adding rate_limit_mapper → GovernorLayer before mutation handlers). On the way back out, responses traverse the stack in reverse.
See also §“RFC 7807 error taxonomy” for the 429 wire body the rate_limit_mapper produces.
The ferra facade
[dependencies] ferra = { package = "ferra-rs", version = "0.5" } + use ferra::*; is the canonical entry point at 0.4.0. The package = "ferra-rs" alias is required because the ferra name on crates.io is owned by an unrelated project; the framework ships as ferra-rs and Cargo’s package alias keeps ferra::* working in your Rust code (see Getting Started). Every item on this page — FerraState, FerraError, FerraJson, FerraPath, PaginationParams, Link, ItemLinks, CollectionLinks, ItemResponse, CollectionResponse, build_item_links, build_collection_links, base_url_from_parts, the five handlers, and ferra_router — is reached from this single import, alongside the #[derive(FerraModel)] macro and the ferra-core / ferra-db public surface.
Consumers do not depend on ferra-core, ferra-db, ferra-forge, or ferra-http directly. The 0.4.0 compile-fail trybuild fixture also pins ferra::Statement, ferra::execute_unprepared, ferra::raw_sql, and ferra::sea_orm::Statement as unreachable — there is no raw-SQL entry point on the facade.
Worked end-to-end example
use ferra::*;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
mod film {
use super::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub year: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub use film::Model as Film;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let conn = sea_orm::Database::connect(&database_url).await?;
let app = ferra_router::<Film>(FerraState::new(conn));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
See examples/hello-ferra/ for the full three-command boot sequence (docker compose up -d postgres or podman compose up -d postgres → sea-orm-cli migrate up → cargo run -p hello-ferra) and the smoke.sh script that exercises all seven wire-format contracts.
Troubleshooting
413 vs 400 on oversized bodies
A request whose body exceeds the 1 MiB default cap is rejected either with 413 Payload Too Large or with 400 Bad Request, depending on a single precondition: whether the request declares a Content-Length header.
| Request | Response code | type URI | Responsible mapper |
|---|---|---|---|
Body > 1 MiB with Content-Length declared | 413 | https://ferra.rs/errors/payload_too_large | body_limit_mapper (FR-033a) |
Body > 1 MiB without Content-Length (streaming / chunked) | 400 | https://ferra.rs/errors/validation (single errors[0].code = "invalid_json") | FerraJson<T>::from_request surfacing as FerraError::Validation |
When Content-Length is present, RequestBodyLimitLayer short-circuits at the layer level before the stream is consumed — hence the early 413. When the header is absent, the stream is consumed up to the cap, the read fails, and the JSON extractor rejects the request as a parse error (still machine-readable, still RFC 7807, still parseable via the same generic type-dispatch path you use for the other validation failures).
Both outcomes are semantically correct — the oversized input is rejected either way. The divergence is intentional at 0.4.1. A future release may unify the two paths under a single 413 (an anvil-class cleanup candidate); the Story 3 regression test pins the current 400 fallback so any such rework must explicitly update or remove it. If you can set Content-Length on your client, do — your clients then see a single, predictable 413.
Other diagnostics
- 413 with
application/problem+jsonbody — request body exceeds 1 MiB. Split the request or (at 0.7.0 Pre-tempering) raise the limit viaFerraLayer::body_limit(...). - 429 with
Retry-After— peer IP hit the mutation rate limit (per_second(2)/burst_size(5)). Back off for the number of seconds inRetry-After. Warning: 214 - "per_page clamped ..."header — yourper_pagequery parameter was out of[1, 100]. Useper_page ≤ 100to silence it.problem+jsonwithtype: ".../validation"anderrors[0].code = "invalid_path_param"— the path segment after/{resource}/is not a valid primary key forM(e.g.GET /films/abcwhenFilm.id: i32). The built-in path extractor rejects before the handler runs.- 500 with
detail: "internal server error"— the DB surfaced an error the framework does not map. Inspect logs / tracing for theDebugform ofFerraError::Internal; the HTTP body intentionally says nothing more. - CORS preflight fails —
CorsLayer::new()denies all cross-origin by default. At 0.4.0, CORS is not configurable; at 0.7.0 Pre-temperingFerraLayer::cors(...)ships the override surface.