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

Custom handlers

Most Ferra apps need at least one route that is not pure CRUD — a publish action, a derived collection, an aggregation, a webhook receiver. This page is the authoritative guide to writing those handlers on top of a Ferra-served API. A reader with this page plus standard Rust knowledge produces a compiling action route on the first attempt — no framework source, no ADR, no constitution required.

The companion CRUD / envelope / Tower-stack / typed-extraction reference is ferra-http.md. The router-assembly facade is foundry.md.


When ferra_router::<M> is not enough

The five built-in handlers cover the resource-shaped endpoints — list, read, create, update, delete. They are all you need for ~80 % of the surface a typical app exposes.

You write a custom handler when the route is not one of those five operations. Common reasons:

  • Action routes. POST /films/{id}/publish, POST /invoices/{id}/cancel, POST /accounts/{id}/transfer — verbs on a resource that aren’t expressible as PUT.
  • Derived collections. GET /films/popular, GET /actors/{id}/films — collections whose membership is computed, not just SELECT * WHERE ....
  • Multi-resource projections. GET /dashboard returning a join of three resources at once.
  • Webhook receivers. POST /webhooks/stripe reading a non-Ferra payload shape.
  • Cross-cutting endpoints. GET /healthz, GET /metrics.

Custom handlers are plain Axum handlers. They accept Axum extractors, return Result<_, FerraError> for RFC 7807 error continuity, and compose with the framework router via axum::Router::merge or by appending .route(...) on top of Foundry::build() / ferra_router::<M>(state).


The worked example: POST /films/{id}/publish

The complete shape of an action route — model declaration, handler signature, composition with Foundry — in one file. Drop this into a fresh src/main.rs whose Cargo.toml declares the canonical four-dep set (ferra, tokio, axum, serde) and it compiles.

use ferra::prelude::*;
use axum::{
    Json, Router,
    http::request::Parts,
    routing::post,
};

// --- The model -----------------------------------------------------

mod film {
    use super::*;

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

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

    impl ActiveModelBehavior for ActiveModel {}
}

pub use film::Model as Film;

// --- The action's request body -------------------------------------

// Plain serde struct — nothing Ferra-specific. The `FerraJson<T>`
// extractor on the handler signature accepts any `T: DeserializeOwned`.
#[derive(Deserialize)]
struct PublishRequest {
    release_date: Option<Date>,
}

// --- The handler ---------------------------------------------------

// `#[axum::debug_handler]` is optional but turns the dense
// `Handler` trait-bound error into a single, targeted message
// when one of the rules below is violated. Keep it on while
// iterating; it is a no-op at runtime.
#[axum::debug_handler]
async fn publish_film(
    // FromRequestParts — `State` reads only the request parts,
    //                   so it can be in any position.
    State(state): State<FerraState<Film>>,
    // FromRequestParts — `FerraPath` is the typed-extraction
    //                   wrapper around `axum::extract::Path<T>`.
    FerraPath(id): FerraPath<i32>,
    // FromRequestParts — `Parts` itself, used below for
    //                   building HAL `_links` from the request URL.
    parts: Parts,
    // FromRequest — `FerraJson` consumes the request body. Body-
    //              consuming extractors MUST be the LAST argument.
    FerraJson(body): FerraJson<PublishRequest>,
) -> Result<Json<ItemResponse<Film>>, FerraError> {
    // 1. Read the existing row.
    let mut film = state
        .repo
        .find_by_id(id)
        .await
        .map_err(FerraError::from)?;

    // 2. Apply the action's effect.
    film.published = true;
    if let Some(d) = body.release_date {
        film.release_date = Some(d);
    }

    // 3. Persist.
    let updated = state
        .repo
        .update(id, film)
        .await
        .map_err(FerraError::from)?;

    // 4. Build the response envelope with HAL `_links`. The two
    //    helpers live at the facade root (not the prelude) — reach
    //    them via the fully-qualified path or add an explicit `use`.
    let base_url = ferra::base_url_from_parts(&parts);
    let links = ferra::build_item_links(
        Film::meta(),
        &updated.id.to_string(),
        &base_url,
    );

    Ok(Json(ItemResponse { data: updated, links }))
}

// --- Composition with Foundry --------------------------------------

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let database_url = std::env::var("DATABASE_URL")?;
    let conn = ferra::sea_orm::Database::connect(&database_url).await?;

    // Foundry assembles the CRUD + `/docs` surface from `conn`.
    let api = Foundry::new(conn.clone())
        .mount::<Film>()
        .with_docs()
        .build();

    // The action route lives on a sibling sub-router with its own
    // `FerraState<Film>`. `DatabaseConnection: Clone` (Sea-ORM pool
    // refcount); `FerraState<M>: Clone` (Arc refcount) — both clones
    // are cheap.
    let custom = Router::new()
        .route("/films/{id}/publish", post(publish_film))
        .with_state(FerraState::<Film>::new(conn));

    let app = api.merge(custom);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

A successful POST /films/42/publish with body {"release_date": "2026-05-10"} returns 200 OK and the same envelope shape as GET /films/42:

{
  "id": 42,
  "title": "Rust en pratique",
  "director": "Alice",
  "year": 2025,
  "release_date": "2026-05-10",
  "published": true,
  "_links": {
    "self":       { "href": "https://api.example.com/films/42" },
    "collection": { "href": "https://api.example.com/films" }
  }
}

Errors flow through the same FerraError taxonomy the built-in handlers use — a malformed JSON body returns 400 with type: "https://ferra.rs/errors/validation"; a missing row returns 404 with type: "https://ferra.rs/errors/not_found". Clients that handle one Ferra error already handle this one.


Extractor ordering: FromRequest vs FromRequestParts

Axum has two extractor traits. The distinction looks academic until you write your first multi-extractor handler — then it becomes the load-bearing rule.

TraitReads fromExamplesPosition
FromRequestPartsrequest parts only (method, URI, headers, state)State<T>, Path<T>, Query<T>, Parts, HeaderMap, FerraPath<T>any position
FromRequestthe whole request, including the body (consumed)Json<T>, Bytes, String, Form<T>, FerraJson<T>last argument only

The rule: at most one body-consuming extractor (FromRequest) per handler, and it MUST be the last argument. Every other extractor is body-agnostic (FromRequestParts) and goes earlier.

This is a fundamental property of HTTP, not an Axum design quirk: a request body is a one-shot stream. Once you have read it, the bytes are gone.

The handler in the worked example follows the rule:

#![allow(unused)]
fn main() {
async fn publish_film(
    State(state): State<FerraState<Film>>,   // FromRequestParts
    FerraPath(id): FerraPath<i32>,            // FromRequestParts
    parts: Parts,                             // FromRequestParts
    FerraJson(body): FerraJson<PublishRequest>, // FromRequest — LAST
) -> Result<...> { ... }
}

Reorder FerraJson ahead of FerraPath and the build fails. Without #[axum::debug_handler] the compiler reports the violation through a deep trait-bound chain on Handler. With #[axum::debug_handler] the diagnostic points directly at the offending argument.

Multiple body extractors. Axum allows exactly one body-consuming extractor per handler. If you legitimately need to look at the raw bytes and a typed JSON deserialization, do both inside a single FromRequest-implementing wrapper, or read Bytes and parse it explicitly with serde_json::from_slice. There is no Json<A> + Json<B> shape.


Send + Sync + 'static: the bound-set cheat-sheet

Tokio runs handlers on a multi-threaded executor by default. Every value Axum carries through the handler must be sendable across threads and outlive the request. The compiler enforces this through Send + Sync + 'static-shaped bounds on the Handler trait — failures here look daunting at first read.

The cheat-sheet:

SymptomLikely causeFix
the trait Send is not implemented for ... inside an async fna !Send value is held across an .await (Rc<_>, RefCell<_>, MutexGuard from std::sync::Mutex)use Arc<_>, tokio::sync::Mutex, or restructure so the offending value is dropped before the .await
the trait Sync is not implemented for ... on State<T>the state type is not Sync (e.g., it owns a non-Sync cache)wrap the inner shared state in Arc<RwLock<...>> or restructure to a channel
argument requires that ... must outlive 'staticthe handler captures a non-'static reference (&str from outside the closure, a borrow from the request)own the data (String, Bytes), or extract it via an extractor
Handler<_, _> not satisfied with a long, hard-to-read chainusually one of the three above, hidden behind Handler’s blanket impladd #[axum::debug_handler] and read the first targeted error it produces

Notes on the framework’s own types:

  • FerraState<M> is Clone + Send + Sync + 'static by construction — it wraps Arc<PgRepository<M>>. Pass it through any number of handlers freely.
  • FerraJson<T> / FerraPath<T> add no bounds beyond the inner T: DeserializeOwned + Send + 'static that Axum already requires.
  • FerraError is Send + Sync + 'static and implements axum::response::IntoResponse — return Result<_, FerraError> from any handler signature.

If your handler async fn body holds a !Send value across an .await, the bound failure surfaces in the Handler impl, not in the body. Move the value into a synchronous block or drop it before the await.


Friendlier diagnostics with #[axum::debug_handler]

Axum’s Handler trait has a blanket impl that erases per-argument type information by the time the bound failure is reported. Without help, a single wrong extractor produces a multi-screen not satisfied chain.

#![allow(unused)]
fn main() {
#[axum::debug_handler]
async fn my_handler(/* … */) -> Result<…> { … }
}

The proc-macro re-checks the arguments one by one and emits a single targeted error per offending argument:

  • Json must be the last extractor
  • the trait FromRequestParts is not implemented for …
  • Send is not satisfied because …

It is purely a development aid — it adds no runtime cost and emits no code in release builds. Keep it on every custom handler while iterating; remove it only if a transitive dep needs the un-decorated handler signature for some other proc-macro to see.

It does not (and cannot) flag every misuse — extractor ordering and bound failures are visible to it; subtle data-flow bugs and panic-on-unwrap paths are not.


FerraJson<T> and FerraPath<T> — RFC 7807 error continuity

Axum’s stock Json<T> and Path<T> produce text/plain 400 bodies on rejection. A consumer that handles Ferra’s application/problem+json taxonomy elsewhere on the API gets a mixed wire format on the rejection path — two parsers, two error shapes, two test surfaces.

FerraJson<T> and FerraPath<T> are thin newtypes around the stock extractors that intercept the rejection and remap it to FerraError::Validation:

#![allow(unused)]
fn main() {
pub struct FerraJson<T>(pub T);   // wraps axum::Json<T>
pub struct FerraPath<T>(pub T);   // wraps axum::extract::Path<T>
}

The output on a malformed body looks like every other Ferra 400:

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

Use the wrappers in every custom handler that takes JSON or path parameters. The single-token swap from Json<T>FerraJson<T> and Path<T>FerraPath<T> is the only difference.

What if I genuinely want stock Axum extraction? You can — they coexist. A handler that takes axum::Json<T> produces stock 400 text/plain on rejection; one that takes FerraJson<T> produces RFC 7807. Pick per handler.

The errors[*].code vocabulary at this release is {"invalid_json", "invalid_path_param"}. It widens when field-level validation lands, which is additive — existing consumer branches keep matching.


Composing with Foundry, ferra_router::<M>, or both

Three composition patterns, in order of how often you reach for them:

1. Append routes on top of Foundry::build()

#![allow(unused)]
fn main() {
let api = Foundry::new(conn.clone())
    .mount::<Film>()
    .with_docs()
    .build();

let custom = Router::new()
    .route("/films/{id}/publish", post(publish_film))
    .with_state(FerraState::<Film>::new(conn));

let app: Router = api.merge(custom);
}

The framework’s Tower stack (CORS, body-limit, tracing, rate-limit on mutation routes) wraps every Ferra-mounted resource. Routes you append on the outer Router see the framework’s outer layers but not the per-resource inner layers — your action endpoint does NOT inherit the rate-limit layer that mounts on the CRUD mutation sub-router. Add your own where needed.

2. Append routes on top of a single-resource ferra_router::<M>

#![allow(unused)]
fn main() {
let state = FerraState::<Film>::new(conn);
let app: Router = ferra_router::<Film>(state.clone())
    .route("/films/{id}/publish", post(publish_film))
    .with_state(state);
}

Use this shape when you have exactly one resource and no Foundry chain — you skip docs but keep the per-resource Tower stack on the CRUD endpoints.

3. Hand-rolled Router with no Ferra CRUD at all

#![allow(unused)]
fn main() {
let app: Router = Router::new()
    .route("/healthz", get(|| async { "ok" }))
    .route("/films/{id}/publish", post(publish_film))
    .with_state(FerraState::<Film>::new(conn));
}

For apps that are only action routes, with no CRUD surface to derive. You retain FerraJson / FerraPath / FerraError and the response envelopes; you give up the auto-generated CRUD, the HAL _links builder pre-wired into the response, and the docs surface.

In all three patterns, consumer-added .layer(...) calls on the final Router wrap the framework stack from the outside. Place observability and authentication layers there; place per-route policy where the policy belongs (a sub-router scoped to that route).


Reaching the repository directly

FerraState<M> exposes pub repo: Arc<PgRepository<M>>. A custom handler that only needs the database adapter (no other state) can extract just that:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use ferra::PgRepository;

async fn count_films(
    State(repo): State<Arc<PgRepository<Film>>>,
) -> Result<Json<u64>, FerraError> {
    let page = repo.find_page(1, 1).await.map_err(FerraError::from)?;
    Ok(Json(page.total))
}
}

FerraState<M>: FromRef<Arc<PgRepository<M>>> makes this swap zero-effort — Axum derives the inner extractor from the outer state. Use it when the handler genuinely does not need the rest of the state.


Cross-references (for the curious reader)

Pointers below trace decisions back to their arbitration. They are not required reading for correct use of the APIs above — every guarantee stated on this page stands on its own.

  • ferra-http.md — the typed-extraction reference, the full RFC 7807 taxonomy, and the Tower layer-ordering diagram. Cross-read it when wiring observability or auth around custom handlers.
  • foundry.md — the router-assembly facade. The composition patterns above attach action routes to a Foundry::build() output.
  • ferra-error-handling.md — the closed ERROR_TYPES URI namespace and the consumer-side error-branching pattern.
  • ferra-core.md §“Time vocabulary” — Date and DateTime (used in the worked example’s release_date field).