Custom handlers
Most Ferra apps need at least one route that is not pure CRUD — a publish action, a derived collection, an aggregation, a webhook receiver. This page is the authoritative guide to writing those handlers on top of a Ferra-served API. A reader with this page plus standard Rust knowledge produces a compiling action route on the first attempt — no framework source, no ADR, no constitution required.
The companion CRUD / envelope / Tower-stack / typed-extraction reference is ferra-http.md. The router-assembly facade is foundry.md.
When ferra_router::<M> is not enough
The five built-in handlers cover the resource-shaped endpoints — list, read, create, update, delete. They are all you need for ~80 % of the surface a typical app exposes.
You write a custom handler when the route is not one of those five operations. Common reasons:
- Action routes.
POST /films/{id}/publish,POST /invoices/{id}/cancel,POST /accounts/{id}/transfer— verbs on a resource that aren’t expressible asPUT. - Derived collections.
GET /films/popular,GET /actors/{id}/films— collections whose membership is computed, not justSELECT * WHERE .... - Multi-resource projections.
GET /dashboardreturning a join of three resources at once. - Webhook receivers.
POST /webhooks/stripereading a non-Ferra payload shape. - Cross-cutting endpoints.
GET /healthz,GET /metrics.
Custom handlers are plain Axum handlers. They accept Axum extractors, return Result<_, FerraError> for RFC 7807 error continuity, and compose with the framework router via axum::Router::merge or by appending .route(...) on top of Foundry::build() / ferra_router::<M>(state).
The worked example: POST /films/{id}/publish
The complete shape of an action route — model declaration, handler signature, composition with Foundry — in one file. Drop this into a fresh src/main.rs whose Cargo.toml declares the canonical four-dep set (ferra, tokio, axum, serde) and it compiles.
use ferra::prelude::*;
use axum::{
Json, Router,
http::request::Parts,
routing::post,
};
// --- The model -----------------------------------------------------
mod film {
use super::*;
#[derive(
Clone, Debug, PartialEq, Eq,
DeriveEntityModel, FerraModel,
Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub year: Option<i32>,
pub release_date: Option<Date>,
pub published: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub use film::Model as Film;
// --- The action's request body -------------------------------------
// Plain serde struct — nothing Ferra-specific. The `FerraJson<T>`
// extractor on the handler signature accepts any `T: DeserializeOwned`.
#[derive(Deserialize)]
struct PublishRequest {
release_date: Option<Date>,
}
// --- The handler ---------------------------------------------------
// `#[axum::debug_handler]` is optional but turns the dense
// `Handler` trait-bound error into a single, targeted message
// when one of the rules below is violated. Keep it on while
// iterating; it is a no-op at runtime.
#[axum::debug_handler]
async fn publish_film(
// FromRequestParts — `State` reads only the request parts,
// so it can be in any position.
State(state): State<FerraState<Film>>,
// FromRequestParts — `FerraPath` is the typed-extraction
// wrapper around `axum::extract::Path<T>`.
FerraPath(id): FerraPath<i32>,
// FromRequestParts — `Parts` itself, used below for
// building HAL `_links` from the request URL.
parts: Parts,
// FromRequest — `FerraJson` consumes the request body. Body-
// consuming extractors MUST be the LAST argument.
FerraJson(body): FerraJson<PublishRequest>,
) -> Result<Json<ItemResponse<Film>>, FerraError> {
// 1. Read the existing row.
let mut film = state
.repo
.find_by_id(id)
.await
.map_err(FerraError::from)?;
// 2. Apply the action's effect.
film.published = true;
if let Some(d) = body.release_date {
film.release_date = Some(d);
}
// 3. Persist.
let updated = state
.repo
.update(id, film)
.await
.map_err(FerraError::from)?;
// 4. Build the response envelope with HAL `_links`. The two
// helpers live at the facade root (not the prelude) — reach
// them via the fully-qualified path or add an explicit `use`.
let base_url = ferra::base_url_from_parts(&parts);
let links = ferra::build_item_links(
Film::meta(),
&updated.id.to_string(),
&base_url,
);
Ok(Json(ItemResponse { data: updated, links }))
}
// --- Composition with Foundry --------------------------------------
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let conn = ferra::sea_orm::Database::connect(&database_url).await?;
// Foundry assembles the CRUD + `/docs` surface from `conn`.
let api = Foundry::new(conn.clone())
.mount::<Film>()
.with_docs()
.build();
// The action route lives on a sibling sub-router with its own
// `FerraState<Film>`. `DatabaseConnection: Clone` (Sea-ORM pool
// refcount); `FerraState<M>: Clone` (Arc refcount) — both clones
// are cheap.
let custom = Router::new()
.route("/films/{id}/publish", post(publish_film))
.with_state(FerraState::<Film>::new(conn));
let app = api.merge(custom);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
A successful POST /films/42/publish with body {"release_date": "2026-05-10"} returns 200 OK and the same envelope shape as GET /films/42:
{
"id": 42,
"title": "Rust en pratique",
"director": "Alice",
"year": 2025,
"release_date": "2026-05-10",
"published": true,
"_links": {
"self": { "href": "https://api.example.com/films/42" },
"collection": { "href": "https://api.example.com/films" }
}
}
Errors flow through the same FerraError taxonomy the built-in handlers use — a malformed JSON body returns 400 with type: "https://ferra.rs/errors/validation"; a missing row returns 404 with type: "https://ferra.rs/errors/not_found". Clients that handle one Ferra error already handle this one.
Extractor ordering: FromRequest vs FromRequestParts
Axum has two extractor traits. The distinction looks academic until you write your first multi-extractor handler — then it becomes the load-bearing rule.
| Trait | Reads from | Examples | Position |
|---|---|---|---|
FromRequestParts | request parts only (method, URI, headers, state) | State<T>, Path<T>, Query<T>, Parts, HeaderMap, FerraPath<T> | any position |
FromRequest | the whole request, including the body (consumed) | Json<T>, Bytes, String, Form<T>, FerraJson<T> | last argument only |
The rule: at most one body-consuming extractor (FromRequest) per handler, and it MUST be the last argument. Every other extractor is body-agnostic (FromRequestParts) and goes earlier.
This is a fundamental property of HTTP, not an Axum design quirk: a request body is a one-shot stream. Once you have read it, the bytes are gone.
The handler in the worked example follows the rule:
#![allow(unused)]
fn main() {
async fn publish_film(
State(state): State<FerraState<Film>>, // FromRequestParts
FerraPath(id): FerraPath<i32>, // FromRequestParts
parts: Parts, // FromRequestParts
FerraJson(body): FerraJson<PublishRequest>, // FromRequest — LAST
) -> Result<...> { ... }
}
Reorder FerraJson ahead of FerraPath and the build fails. Without #[axum::debug_handler] the compiler reports the violation through a deep trait-bound chain on Handler. With #[axum::debug_handler] the diagnostic points directly at the offending argument.
Multiple body extractors. Axum allows exactly one body-consuming extractor per handler. If you legitimately need to look at the raw bytes and a typed JSON deserialization, do both inside a single FromRequest-implementing wrapper, or read Bytes and parse it explicitly with serde_json::from_slice. There is no Json<A> + Json<B> shape.
Send + Sync + 'static: the bound-set cheat-sheet
Tokio runs handlers on a multi-threaded executor by default. Every value Axum carries through the handler must be sendable across threads and outlive the request. The compiler enforces this through Send + Sync + 'static-shaped bounds on the Handler trait — failures here look daunting at first read.
The cheat-sheet:
| Symptom | Likely cause | Fix |
|---|---|---|
the trait Send is not implemented for ... inside an async fn | a !Send value is held across an .await (Rc<_>, RefCell<_>, MutexGuard from std::sync::Mutex) | use Arc<_>, tokio::sync::Mutex, or restructure so the offending value is dropped before the .await |
the trait Sync is not implemented for ... on State<T> | the state type is not Sync (e.g., it owns a non-Sync cache) | wrap the inner shared state in Arc<RwLock<...>> or restructure to a channel |
argument requires that ... must outlive 'static | the handler captures a non-'static reference (&str from outside the closure, a borrow from the request) | own the data (String, Bytes), or extract it via an extractor |
Handler<_, _> not satisfied with a long, hard-to-read chain | usually one of the three above, hidden behind Handler’s blanket impl | add #[axum::debug_handler] and read the first targeted error it produces |
Notes on the framework’s own types:
FerraState<M>isClone + Send + Sync + 'staticby construction — it wrapsArc<PgRepository<M>>. Pass it through any number of handlers freely.FerraJson<T>/FerraPath<T>add no bounds beyond the innerT: DeserializeOwned + Send + 'staticthat Axum already requires.FerraErrorisSend + Sync + 'staticand implementsaxum::response::IntoResponse— returnResult<_, FerraError>from any handler signature.
If your handler async fn body holds a !Send value across an .await, the bound failure surfaces in the Handler impl, not in the body. Move the value into a synchronous block or drop it before the await.
Friendlier diagnostics with #[axum::debug_handler]
Axum’s Handler trait has a blanket impl that erases per-argument type information by the time the bound failure is reported. Without help, a single wrong extractor produces a multi-screen not satisfied chain.
#![allow(unused)]
fn main() {
#[axum::debug_handler]
async fn my_handler(/* … */) -> Result<…> { … }
}
The proc-macro re-checks the arguments one by one and emits a single targeted error per offending argument:
- Json must be the last extractor
- the trait FromRequestParts is not implemented for …
- Send is not satisfied because …
It is purely a development aid — it adds no runtime cost and emits no code in release builds. Keep it on every custom handler while iterating; remove it only if a transitive dep needs the un-decorated handler signature for some other proc-macro to see.
It does not (and cannot) flag every misuse — extractor ordering and bound failures are visible to it; subtle data-flow bugs and panic-on-unwrap paths are not.
FerraJson<T> and FerraPath<T> — RFC 7807 error continuity
Axum’s stock Json<T> and Path<T> produce text/plain 400 bodies on rejection. A consumer that handles Ferra’s application/problem+json taxonomy elsewhere on the API gets a mixed wire format on the rejection path — two parsers, two error shapes, two test surfaces.
FerraJson<T> and FerraPath<T> are thin newtypes around the stock extractors that intercept the rejection and remap it to FerraError::Validation:
#![allow(unused)]
fn main() {
pub struct FerraJson<T>(pub T); // wraps axum::Json<T>
pub struct FerraPath<T>(pub T); // wraps axum::extract::Path<T>
}
The output on a malformed body looks like every other Ferra 400:
{
"type": "https://ferra.rs/errors/validation",
"title": "Validation Error",
"status": 400,
"detail": "validation failed",
"errors": {
"errors": [
{ "field": "body", "code": "invalid_json",
"message": "expected `,` or `}` at line 1 column 14" }
]
}
}
Use the wrappers in every custom handler that takes JSON or path parameters. The single-token swap from Json<T> → FerraJson<T> and Path<T> → FerraPath<T> is the only difference.
What if I genuinely want stock Axum extraction? You can — they coexist. A handler that takes axum::Json<T> produces stock 400 text/plain on rejection; one that takes FerraJson<T> produces RFC 7807. Pick per handler.
The errors[*].code vocabulary at this release is {"invalid_json", "invalid_path_param"}. It widens when field-level validation lands, which is additive — existing consumer branches keep matching.
Composing with Foundry, ferra_router::<M>, or both
Three composition patterns, in order of how often you reach for them:
1. Append routes on top of Foundry::build()
#![allow(unused)]
fn main() {
let api = Foundry::new(conn.clone())
.mount::<Film>()
.with_docs()
.build();
let custom = Router::new()
.route("/films/{id}/publish", post(publish_film))
.with_state(FerraState::<Film>::new(conn));
let app: Router = api.merge(custom);
}
The framework’s Tower stack (CORS, body-limit, tracing, rate-limit on mutation routes) wraps every Ferra-mounted resource. Routes you append on the outer Router see the framework’s outer layers but not the per-resource inner layers — your action endpoint does NOT inherit the rate-limit layer that mounts on the CRUD mutation sub-router. Add your own where needed.
2. Append routes on top of a single-resource ferra_router::<M>
#![allow(unused)]
fn main() {
let state = FerraState::<Film>::new(conn);
let app: Router = ferra_router::<Film>(state.clone())
.route("/films/{id}/publish", post(publish_film))
.with_state(state);
}
Use this shape when you have exactly one resource and no Foundry chain — you skip docs but keep the per-resource Tower stack on the CRUD endpoints.
3. Hand-rolled Router with no Ferra CRUD at all
#![allow(unused)]
fn main() {
let app: Router = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/films/{id}/publish", post(publish_film))
.with_state(FerraState::<Film>::new(conn));
}
For apps that are only action routes, with no CRUD surface to derive. You retain FerraJson / FerraPath / FerraError and the response envelopes; you give up the auto-generated CRUD, the HAL _links builder pre-wired into the response, and the docs surface.
In all three patterns, consumer-added .layer(...) calls on the final Router wrap the framework stack from the outside. Place observability and authentication layers there; place per-route policy where the policy belongs (a sub-router scoped to that route).
Reaching the repository directly
FerraState<M> exposes pub repo: Arc<PgRepository<M>>. A custom handler that only needs the database adapter (no other state) can extract just that:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use ferra::PgRepository;
async fn count_films(
State(repo): State<Arc<PgRepository<Film>>>,
) -> Result<Json<u64>, FerraError> {
let page = repo.find_page(1, 1).await.map_err(FerraError::from)?;
Ok(Json(page.total))
}
}
FerraState<M>: FromRef<Arc<PgRepository<M>>> makes this swap zero-effort — Axum derives the inner extractor from the outer state. Use it when the handler genuinely does not need the rest of the state.
Cross-references (for the curious reader)
Pointers below trace decisions back to their arbitration. They are not required reading for correct use of the APIs above — every guarantee stated on this page stands on its own.
ferra-http.md— the typed-extraction reference, the full RFC 7807 taxonomy, and the Tower layer-ordering diagram. Cross-read it when wiring observability or auth around custom handlers.foundry.md— the router-assembly facade. The composition patterns above attach action routes to aFoundry::build()output.ferra-error-handling.md— the closedERROR_TYPESURI namespace and the consumer-side error-branching pattern.ferra-core.md§“Time vocabulary” —DateandDateTime(used in the worked example’srelease_datefield).