Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ferra-http — the Axum CRUD handler layer

The 0.4.0 Refining deliverable. Turns a #[derive(FerraModel, DeriveEntityModel)] entity into a fully-wired Axum router with RFC 7807 errors, HAL-lite hypermedia, pagination, and constitutional security defaults — zero hand-written routes, zero hand-written error mapping.

This page is the authoritative user-guide for ferra-http. A reader with only this page plus standard Rust knowledge produces a compiling ferra_router::<Film>(state) call site on the first attempt — no ADR, no constitution, and no framework source is required.


Zero-to-API in five lines

#![allow(unused)]
fn main() {
use ferra::*;

let conn = sea_orm::Database::connect(&database_url).await?;
let state = FerraState::<Film>::new(conn);
let app = ferra_router::<Film>(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
}

Drop one #[derive(FerraModel, DeriveEntityModel, Serialize, Deserialize)] on a struct whose PK is i32 (or any Display + DeserializeOwned + Send + Sync + 'static) and you get the five CRUD endpoints (GET /films / GET /films/:id / POST /films / PUT /films/:id / DELETE /films/:id), a Location header on creation, a Warning: 214 header on per_page clamp, and an application/problem+json body on every error — with no further code.

See examples/hello-ferra/ for the full worked example with PostgreSQL container + Rust-coded migrations.


The FerraState<M> constructor

#![allow(unused)]
fn main() {
pub struct FerraState<M> { pub repo: Arc<PgRepository<M>> }
impl<M> FerraState<M> {
    pub fn new(pool: DatabaseConnection) -> Self;
}
}
  • #[non_exhaustive] — construct via FerraState::new(pool); struct-literal construction is not supported. Future phases add fields (tracing context, auth identity) without breaking this.
  • #[derive(Clone)] — cloning is a reference-count bump on Arc<PgRepository<M>>. Every request clones.
  • Implements axum::extract::FromRef<FerraState<M>> for Arc<PgRepository<M>>. Custom handlers that only need the repository write State(repo): State<Arc<PgRepository<M>>>; the Ferra handlers all take State(state): State<FerraState<M>>.

Instantiate one FerraState<M> per model per database; cheaply share the same DatabaseConnection across models by calling FerraState::new(conn.clone()) multiple times.


The five CRUD handlers

All five handlers are generic over M: FerraModel + <Sea-ORM bounds>. You rarely name them directly — ferra_router<M> mounts them for you. When you do, the HTTP contracts are:

HandlerMethodPathRequestSuccessError surfaces
get_item<M>GET/{resource}/:id200 OK + Json<ItemResponse<M>>400 on path-extractor rejection · 404 on missing row · 500 on DB error
get_collection<M>GET/{resource}?page={u32}&per_page={u32}200 OK + optional Warning: 214 … + Json<CollectionResponse<M>>500 on DB error
create_item<M>POST/{resource}Json<M> body201 Created + Location: {base}/{resource}/{id} + Json<ItemResponse<M>>400 on body parse · 409 on unique violation · 413 on oversized body · 429 on rate limit · 500
update_item<M>PUT/{resource}/:idJson<M> body200 OK + Json<ItemResponse<M>>400 · 404 · 409 · 413 · 429 · 500
delete_item<M>DELETE/{resource}/:id204 No Content (no body)400 · 404 · 429 · 500

Path id is authoritative on update and delete — any id field in the request body is ignored for the WHERE clause. {resource} is <M as FerraModel>::meta().resource_name (e.g. "films").


RFC 7807 error taxonomy

Every error from every handler is rendered as application/problem+json per RFC 7807 §3. Six variants, locked through 0.6.0 Welding:

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

Worked body for a 404:

{
  "type":   "https://ferra.rs/errors/not_found",
  "title":  "Resource Not Found",
  "status": 404,
  "detail": "films/42 not found"
}

Worked body for a 400 from a malformed JSON request:

{
  "type":   "https://ferra.rs/errors/validation",
  "title":  "Validation Error",
  "status": 400,
  "detail": "validation failed",
  "errors": {
    "errors": [
      { "field": "body", "code": "invalid_json", "message": "expected `,` or `}` at line 1 column 14" }
    ]
  }
}

Internals never leak. The detail field on a 500 is always the literal string "internal server error"; the inner sea_orm::DbErr is dropped before reaching the HTTP body. The Axum "Failed to deserialize … into the target type: " prefix and any axum::extract::rejection:: substring are stripped at the extractor boundary; only human-readable messages reach errors[*].message.

Worked body for a 429 from the rate-limit mapper:

{
  "type":   "https://ferra.rs/errors/rate_limited",
  "title":  "Too Many Requests",
  "status": 429,
  "detail": "rate limit exceeded; retry after 3 seconds"
}

The response also carries the Retry-After header from the underlying rate-limit component verbatim (integer seconds). When the component does not emit a Retry-After header, detail degrades gracefully to the literal string "rate limit exceeded" and the header is absent from the response. The rate-limit mapper mounts only alongside the framework’s default rate-limit component — consumer-provided rate-limiters produce their own error shape (see ADR-0015 for rationale).


The HAL-lite envelope

Every successful body carries _links so an AI agent can navigate the API without prior URL knowledge.

ItemResponse<M> — single-item envelope

Model fields are flattened at the top level via #[serde(flatten)]. A Film { id: 1, title: "Rust en pratique", director: "Alice", year: Some(2025) } serializes as:

{
  "id": 1,
  "title": "Rust en pratique",
  "director": "Alice",
  "year": 2025,
  "_links": {
    "self":       { "href": "https://api.example.com/films/1" },
    "collection": { "href": "https://api.example.com/films" }
  }
}

CollectionResponse<M> — paginated envelope

{
  "items": [ /* array of ItemResponse<M> */ ],
  "total": 25,
  "page": 2,
  "per_page": 10,
  "_links": {
    "self":  { "href": "https://api.example.com/films?page=2&per_page=10" },
    "next":  { "href": "https://api.example.com/films?page=3&per_page=10" },
    "prev":  { "href": "https://api.example.com/films?page=1&per_page=10" },
    "first": { "href": "https://api.example.com/films?page=1&per_page=10" },
    "last":  { "href": "https://api.example.com/films?page=3&per_page=10" }
  }
}

On boundary pages, next or prev serialize as JSON null (never omitted). On total == 0, last_page == 1 and first == last == self.

#![allow(unused)]
fn main() {
pub fn build_item_links(meta: &ModelMeta, id: &str, base_url: &str) -> ItemLinks;
pub fn build_collection_links(
    meta: &ModelMeta,
    page: u32,
    per_page: u32,
    total: u64,
    base_url: &str,
) -> CollectionLinks;
}

Use these when writing a custom handler that returns an envelope-shaped body.


Pagination

#![allow(unused)]
fn main() {
pub struct PaginationParams { pub page: Option<u32>, pub per_page: Option<u32> }
impl PaginationParams {
    pub const fn with(page: Option<u32>, per_page: Option<u32>) -> Self;
    pub fn resolved_page(&self) -> u32;          // default 1; 0 → 1
    pub fn resolved_per_page(&self) -> u32;      // default 20; clamp to [1, 100]
    pub fn clamped_per_page(&self) -> Option<u32>;
}
}

PaginationParams is #[non_exhaustive] — external struct-literal construction is rejected. For code that needs to build a PaginationParams outside an Axum Query<_> extractor (integration tests, admin tools, batch jobs), use the stable with(page, per_page) constructor:

#![allow(unused)]
fn main() {
use ferra::PaginationParams;

let p = PaginationParams::with(Some(3), Some(50));
assert_eq!(p.resolved_page(), 3);
assert_eq!(p.resolved_per_page(), 50);

let defaulted = PaginationParams::with(None, None);
assert_eq!(defaulted.resolved_page(), 1);
assert_eq!(defaulted.resolved_per_page(), 20);

let clamped = PaginationParams::with(None, Some(500));
assert_eq!(clamped.resolved_per_page(), 100);
assert_eq!(clamped.clamped_per_page(), Some(100));
}

The constructor stores the raw Option<u32> inputs as-is and applies the clamp / default lazily through the resolved_* / clamped_per_page methods — values produced via with(...) are observationally identical to those that reach a handler through the HTTP extraction path.

  • Default: page=1, per_page=20.
  • Cap: per_page ≤ 100 (hard limit). Values below 1 or above 100 are silently clamped and the response carries Warning: 214 - "per_page clamped to {N} (max 100)" per RFC 7234 §5.5.4.
  • The response body’s per_page field reports the clamped value (so clients can detect the clamp without parsing the Warning header).

Worked curl:

$ curl -D - 'http://localhost:3000/films?per_page=500'
HTTP/1.1 200 OK
warning: 214 - "per_page clamped to 100 (max 100)"
content-type: application/json
...
{"items":[...],"total":523,"page":1,"per_page":100,"_links":{...}}

Base-URL extractor

#![allow(unused)]
fn main() {
pub fn base_url_from_parts(parts: &http::request::Parts) -> String;
}

Produces the base_url prefix used by build_item_links / build_collection_links. Behavior:

  • Scheme: first comma-separated token of X-Forwarded-Proto if it matches the {http, https} allowlist; otherwise "http".
  • Host: the Host header if present and non-empty; otherwise "localhost".
  • Output format: "{scheme}://{host}". No trailing slash.

Anti-spoofing: X-Forwarded-Proto: javascript (or any non-allowlisted scheme) falls back to http. A spoofed header cannot inject a javascript: URL into _links.*.href by construction.

Deploying behind a TLS-terminating load balancer? Ensure the LB sets X-Forwarded-Proto: https so the generated links match the client-visible scheme.


Custom extractors

See custom-handlers.md — the rationale for FerraJson<T> / FerraPath<T> (RFC 7807 error continuity), the FromRequest vs FromRequestParts ordering rule, the Send + Sync + 'static cheat-sheet, and a worked end-to-end POST /films/{id}/publish action route.


Security defaults

ferra_router::<M>(state) mounts a deny-by-default Tower layer stack — a consumer who writes exactly those five lines is already constitution-§I compliant:

LayerDefaultOverride path
tower_http::cors::CorsLayer::new()Deny all cross-originFerraLayer::cors(...) at 0.7.0 Pre-tempering
413 body-limit mapperRewrites stock text/plainapplication/problem+json with type: .../payload_too_large— (permanent)
tower_http::limit::RequestBodyLimitLayer1 MiBFerraLayer::body_limit(...) at 0.7.0
tower_http::trace::TraceLayerBody sampling OFF (never logs PII)Per-route opt-in at 0.12.0 Sheen
tower_governor::GovernorLayer on mutation endpointsper_second(2) + burst_size(5) + peer-socket-IP keyFull config at 0.9.0 Forging III
PaginationParams clamp (FR-017)Loud Warning: 214 header on every clamp— (constitutional)
X-Forwarded-Proto allowlist{http, https} only— (constitutional)

429 wire format — the framework’s default rate-limit component’s 429 responses are rewritten to application/problem+json with type: https://ferra.rs/errors/rate_limited, preserving the Retry-After header from tower_governor verbatim. See the worked body in §“RFC 7807 error taxonomy” above. Oversized bodies produce the same application/problem+json shape — a client parsing type never sees a mixed wire format.

Rate-limit observability event. Each time the rate-limit mapper rewrites a 429, it emits a single structured tracing::warn! event so an operator tailing logs can see rate-limit rejections without reading the framework source:

  • Target: ferra_http::rate_limit — namespaced so an operator can isolate it via EnvFilter (RUST_LOG=ferra_http::rate_limit=warn).
  • Message: rate limit exceeded.
  • Severity: WARN.
  • Fields (deliberately minimal):
    • http.method — the request HTTP method (e.g. "POST"). Always present.
    • http.target — the request path-and-query (e.g. "/films" or "/films?page=2"). Always present.
    • http.retry_after_seconds — integer seconds parsed from the inbound Retry-After header. Present only when the header parses as a non-negative integer; genuinely absent otherwise (not recorded as 0).

Request and response body content never appear in this event — only the three fields listed above. The schema is pinned minimal so the framework’s dedicated observability phase at 0.12.0 Sheen can absorb it into span-based telemetry (adding trace_id, span_id, and peer-IP once the trusted-proxy story lands) without breaking a consumer’s existing EnvFilter or custom tracing::Layer.

Wire up a subscriber in your host binary’s main to surface these events:

#![allow(unused)]
fn main() {
tracing_subscriber::fmt()
    .with_env_filter("ferra_http::rate_limit=warn")
    .init();
}

Layer placement (FR-006). The rate-limit mapper mounts as the outer layer of the mutation sub-router only, wrapping tower_governor::GovernorLayer from the outside (the last .layer() call on that sub-router). Consumer-added middleware appended to ferra_router::<M>(state) wraps the mapper from further outside — see §“Layer ordering — how .layer() composes” below for the full diagram.

Layer ordering — how .layer() composes

Tower’s .layer() wraps the current router as the inner service, so the last .layer() call becomes the outermost layer. A .layer(MyCustomLayer::new()) appended to ferra_router::<M>(state) therefore wraps every Ferra-provided layer from the outside.

Side-by-side: the conceptual stack (top = outermost, bottom = innermost) and the literal .layer() call sequence in ferra_router::<M>(state) (top = first call, bottom = last call). Arrows map the inversion:

CONCEPTUAL stack (request top→bottom):        LITERAL call sequence (first→last in source):

 outer → CorsLayer                              .layer(DefaultBodyLimit::disable())   ← first call (innermost)
         body_limit_mapper                      .layer(TraceLayer::new_for_http())
         RequestBodyLimitLayer(1 MiB)           .layer(RequestBodyLimitLayer::new(1 MiB))
         TraceLayer                             .layer(middleware::from_fn(body_limit_mapper))
         DefaultBodyLimit::disable              .layer(CorsLayer::new())              ← last call (outermost)
         (on mutation sub-router:)             
           rate_limit_mapper                    [on mutation sub-router:]
           GovernorLayer                          .layer(GovernorLayer { ... })       ← first call on mutations
 inner → handlers                                .layer(middleware::from_fn(rate_limit_mapper))  ← last (outer)

Reading it: a request enters at CorsLayer (outermost), traverses down to the handler, and the response retraces the stack back out. In the source, the call order is inverted — the first .layer() call is the innermost layer on the merged router, and the last .layer() call is the outermost.

Mutation sub-router subtlety. GovernorLayer (rate-limit) and rate_limit_mapper (the 429 RFC 7807 wrapper) mount on the mutation sub-router only — never on the merged router and never on read routes. rate_limit_mapper is the last .layer() call on that sub-router, so it wraps GovernorLayer from the outside and sees the 429s that layer produces. Placing a custom observability layer is a decision about which sub-router you wrap — consumers who want to observe only mutations attach to a sub-router; consumers who want to observe everything attach to the merged router returned by ferra_router::<M>(state).

Worked consumer example. A consumer appending two custom layers on top of the framework stack:

#![allow(unused)]
fn main() {
let app = ferra_router::<Film>(state)
    .layer(MyObservabilityLayer::new())   // outer: sees requests before the framework
    .layer(MyShedLoadLayer::new());       // appended AFTER → now the TRUE outermost
}

On incoming requests: MyShedLoadLayer runs first, then MyObservabilityLayer, then the framework stack (CORS → body_limit_mapperRequestBodyLimitLayerTraceLayerDefaultBodyLimit::disable → handlers; with the mutation-only branch adding rate_limit_mapperGovernorLayer before mutation handlers). On the way back out, responses traverse the stack in reverse.

See also §“RFC 7807 error taxonomy” for the 429 wire body the rate_limit_mapper produces.


The ferra facade

[dependencies] ferra = { package = "ferra-rs", version = "0.5" } + use ferra::*; is the canonical entry point at 0.4.0. The package = "ferra-rs" alias is required because the ferra name on crates.io is owned by an unrelated project; the framework ships as ferra-rs and Cargo’s package alias keeps ferra::* working in your Rust code (see Getting Started). Every item on this page — FerraState, FerraError, FerraJson, FerraPath, PaginationParams, Link, ItemLinks, CollectionLinks, ItemResponse, CollectionResponse, build_item_links, build_collection_links, base_url_from_parts, the five handlers, and ferra_router — is reached from this single import, alongside the #[derive(FerraModel)] macro and the ferra-core / ferra-db public surface.

Consumers do not depend on ferra-core, ferra-db, ferra-forge, or ferra-http directly. The 0.4.0 compile-fail trybuild fixture also pins ferra::Statement, ferra::execute_unprepared, ferra::raw_sql, and ferra::sea_orm::Statement as unreachable — there is no raw-SQL entry point on the facade.


Worked end-to-end example

use ferra::*;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

mod film {
    use super::*;

    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
    #[sea_orm(table_name = "films")]
    pub struct Model {
        #[sea_orm(primary_key)]
        pub id: i32,
        #[sea_orm(unique)]
        pub title: String,
        pub director: String,
        pub year: Option<i32>,
    }

    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
    pub enum Relation {}

    impl ActiveModelBehavior for ActiveModel {}
}

pub use film::Model as Film;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let database_url = std::env::var("DATABASE_URL")?;
    let conn = sea_orm::Database::connect(&database_url).await?;
    let app = ferra_router::<Film>(FerraState::new(conn));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

See examples/hello-ferra/ for the full three-command boot sequence (docker compose up -d postgres or podman compose up -d postgressea-orm-cli migrate upcargo run -p hello-ferra) and the smoke.sh script that exercises all seven wire-format contracts.


Troubleshooting

413 vs 400 on oversized bodies

A request whose body exceeds the 1 MiB default cap is rejected either with 413 Payload Too Large or with 400 Bad Request, depending on a single precondition: whether the request declares a Content-Length header.

RequestResponse codetype URIResponsible mapper
Body > 1 MiB with Content-Length declared413https://ferra.rs/errors/payload_too_largebody_limit_mapper (FR-033a)
Body > 1 MiB without Content-Length (streaming / chunked)400https://ferra.rs/errors/validation (single errors[0].code = "invalid_json")FerraJson<T>::from_request surfacing as FerraError::Validation

When Content-Length is present, RequestBodyLimitLayer short-circuits at the layer level before the stream is consumed — hence the early 413. When the header is absent, the stream is consumed up to the cap, the read fails, and the JSON extractor rejects the request as a parse error (still machine-readable, still RFC 7807, still parseable via the same generic type-dispatch path you use for the other validation failures).

Both outcomes are semantically correct — the oversized input is rejected either way. The divergence is intentional at 0.4.1. A future release may unify the two paths under a single 413 (an anvil-class cleanup candidate); the Story 3 regression test pins the current 400 fallback so any such rework must explicitly update or remove it. If you can set Content-Length on your client, do — your clients then see a single, predictable 413.

Other diagnostics

  • 413 with application/problem+json body — request body exceeds 1 MiB. Split the request or (at 0.7.0 Pre-tempering) raise the limit via FerraLayer::body_limit(...).
  • 429 with Retry-After — peer IP hit the mutation rate limit (per_second(2) / burst_size(5)). Back off for the number of seconds in Retry-After.
  • Warning: 214 - "per_page clamped ..." header — your per_page query parameter was out of [1, 100]. Use per_page ≤ 100 to silence it.
  • problem+json with type: ".../validation" and errors[0].code = "invalid_path_param" — the path segment after /{resource}/ is not a valid primary key for M (e.g. GET /films/abc when Film.id: i32). The built-in path extractor rejects before the handler runs.
  • 500 with detail: "internal server error" — the DB surfaced an error the framework does not map. Inspect logs / tracing for the Debug form of FerraError::Internal; the HTTP body intentionally says nothing more.
  • CORS preflight failsCorsLayer::new() denies all cross-origin by default. At 0.4.0, CORS is not configurable; at 0.7.0 Pre-tempering FerraLayer::cors(...) ships the override surface.

  1. emitted by a middleware mapper (the 413 body-limit mapper or the 429 rate-limit mapper), not by FerraError::IntoResponse. The wire shape is identical to the four handler-produced variants — a client that handles one type URI handles them all through the same generic RFC 7807 code path. ↩2