Custom Extractors

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against axum 0.8.9 (axum-core 0.5.6, axum-macros 0.5.1, axum-extra 0.12.6) on Rust 1.99 (edition 2024), checked on 2026-10-06. Built-in extractors cover the HTTP primitives; custom extractors turn them into domain concepts — "the signed-in user", "the requested page", "a validated JSON body with JSON errors" — so that handlers receive exactly what they need and never repeat parsing or authorisation code. The built-in extractors and the ordering rules are on Extractors.

Implementing FromRequestParts

An extractor that only needs the request head implements FromRequestParts<S>. Since axum 0.8 the trait uses native async fn (Rust 1.75+), so the #[async_trait] attribute that 0.7 code needed must be removed:

use axum::{
    Json, Router,
    extract::{FromRequestParts, Query},
    http::{StatusCode, request::Parts},
    routing::get,
};
use serde::{Deserialize, Serialize};

/// `?page=2&per_page=50`, validated and clamped.
#[derive(Debug, Clone, Copy, Serialize)]
pub struct Pagination {
    pub page: u32,
    pub per_page: u32,
}

impl Pagination {
    pub fn offset(self) -> u64 {
        u64::from(self.page - 1) * u64::from(self.per_page)
    }
}

#[derive(Deserialize)]
struct RawPagination {
    page: Option<u32>,
    per_page: Option<u32>,
}

impl<S> FromRequestParts<S> for Pagination
where
    S: Send + Sync,
{
    type Rejection = (StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let Query(raw) = Query::<RawPagination>::from_request_parts(parts, state)
            .await
            .map_err(|_| (StatusCode::BAD_REQUEST, "page and per_page must be positive integers"))?;
        let page = raw.page.unwrap_or(1);
        if page == 0 {
            return Err((StatusCode::BAD_REQUEST, "page starts at 1"));
        }
        Ok(Pagination { page, per_page: raw.per_page.unwrap_or(20).clamp(1, 100) })
    }
}

async fn list_books(pagination: Pagination) -> Json<(Pagination, u64)> {
    Json((pagination, pagination.offset()))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books", get(list_books));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
  • The trait is generic over the state S. Writing impl<S> ... where S: Send + Sync makes the extractor usable with any router; constrain S (or use FromRef, below) when you need something from the state.

  • Rejection can be any IntoResponse type. A tuple (StatusCode, &str) is fine for a demo; real applications use their AppError (see Error Handling).

  • parts is &mut: an extractor can insert into parts.extensions to pass data on, which is how Cached works.

Implementing FromRequest

An extractor that reads the body implements FromRequest<S> and receives the whole Request by value. It must therefore be the last handler argument. A common case is a JSON body whose rejections are JSON as well:

use axum::{
    Json, Router,
    extract::{FromRequest, Request, rejection::JsonRejection},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::post,
};
use serde::{Deserialize, de::DeserializeOwned};
use serde_json::json;

/// Like `Json<T>`, but rejections are RFC 9457 Problem Details.
pub struct ApiJson<T>(pub T);

pub struct ApiJsonRejection(JsonRejection);

impl IntoResponse for ApiJsonRejection {
    fn into_response(self) -> Response {
        let status = self.0.status();
        let body = json!({ "type": "about:blank", "title": "Invalid JSON body", "status": status.as_u16(), "detail": self.0.body_text() });
        (status, [("content-type", "application/problem+json")], body.to_string()).into_response()
    }
}

impl<S, T> FromRequest<S> for ApiJson<T>
where
    S: Send + Sync,
    T: DeserializeOwned,
{
    type Rejection = ApiJsonRejection;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req, state).await.map_err(ApiJsonRejection)?;
        Ok(ApiJson(value))
    }
}

#[derive(Deserialize)]
struct NewAuthor {
    name: String,
}

async fn create_author(ApiJson(author): ApiJson<NewAuthor>) -> (StatusCode, String) {
    (StatusCode::CREATED, format!("created {}\n", author.name))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/authors", post(create_author));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s -H 'content-type: application/json' -d '{"nome":"Le Guin"}' localhost:3000/api/v1/authors
{"detail":"Failed to deserialize the JSON body into the target type: missing field `name` at line 1 column 18","status":422,"title":"Invalid JSON body","type":"about:blank"}

Why a type cannot implement both

axum implements FromRequest<S, ViaParts> for every FromRequestParts<S> type, so that parts extractors can also be used in the last position. If you implement both traits for the same type, the two implementations conflict (E0119, "conflicting implementations"). Implement only the trait that matches what the extractor reads: FromRequestParts if it never touches the body.

Calling other extractors: RequestPartsExt

Inside an extractor you can run any other extractor. parts.extract::<T>() (from axum::RequestPartsExt) is shorthand for T::from_request_parts(parts, &()) when the inner extractor needs no state, and extract_with_state passes it on. For body extractors, axum::RequestExt offers req.extract() and req.extract_parts(). The Bookshelf CurrentUser extractor reads the Authorization header through axum-extra’s TypedHeader and looks the token up in a session store held in state:

use std::collections::HashMap;
use std::sync::{Arc, RwLock};

use axum::{
    RequestPartsExt, Router,
    extract::{FromRef, FromRequestParts, OptionalFromRequestParts},
    http::{StatusCode, request::Parts},
    response::{IntoResponse, Response},
    routing::get,
};
use axum_extra::{
    TypedHeader,
    headers::{Authorization, authorization::Bearer},
};

#[derive(Debug, Clone)]
pub struct CurrentUser {
    pub id: u64,
    pub name: String,
    pub librarian: bool,
}

/// Token -> user. A real application uses sessions or JWTs (see the Security pages).
#[derive(Clone, Default)]
pub struct Tokens(Arc<RwLock<HashMap<String, CurrentUser>>>);

#[derive(Clone, Default, FromRef)]
pub struct AppState {
    tokens: Tokens,
}

pub enum AuthError {
    Missing,
    Invalid,
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let msg = match self {
            AuthError::Missing => "missing bearer token",
            AuthError::Invalid => "invalid or expired token",
        };
        (StatusCode::UNAUTHORIZED, [("www-authenticate", "Bearer")], msg).into_response()
    }
}

impl<S> FromRequestParts<S> for CurrentUser
where
    S: Send + Sync,
    Tokens: FromRef<S>,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let TypedHeader(Authorization(bearer)) =
            parts.extract::<TypedHeader<Authorization<Bearer>>>().await.map_err(|_| AuthError::Missing)?;
        let tokens = Tokens::from_ref(state);
        let users = tokens.0.read().map_err(|_| AuthError::Invalid)?;
        users.get(bearer.token()).cloned().ok_or(AuthError::Invalid)
    }
}

/// `Option<CurrentUser>`: `None` when no token was sent, but still 401 for a bad token.
impl<S> OptionalFromRequestParts<S> for CurrentUser
where
    S: Send + Sync,
    Tokens: FromRef<S>,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Option<Self>, Self::Rejection> {
        if !parts.headers.contains_key("authorization") {
            return Ok(None);
        }
        <CurrentUser as FromRequestParts<S>>::from_request_parts(parts, state).await.map(Some)
    }
}

async fn account(user: CurrentUser) -> String {
    format!("signed in as {} (librarian: {})\n", user.name, user.librarian)
}

async fn home(user: Option<CurrentUser>) -> String {
    match user {
        Some(u) => format!("welcome back, {}\n", u.name),
        None => "welcome, guest\n".to_owned(),
    }
}

#[tokio::main]
async fn main() {
    let state = AppState::default();
    state.tokens.0.write().unwrap().insert(
        "demo-token-not-a-secret".to_owned(),
        CurrentUser { id: 1, name: "Ursula".to_owned(), librarian: true },
    );
    let app = Router::new().route("/", get(home)).route("/account", get(account)).with_state(state);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s localhost:3000/
welcome, guest
$ curl -s -H 'authorization: Bearer demo-token-not-a-secret' localhost:3000/
welcome back, Ursula
$ curl -si -H 'authorization: Bearer nope' localhost:3000/account | head -1
HTTP/1.1 401 Unauthorized

Tokens: FromRef<S> lets the extractor work with any application state that contains a Tokens, instead of tying it to one AppState type — the substate pattern from State & Dependency Injection. The OptionalFromRequestParts implementation gives Option<CurrentUser> the right semantics: an anonymous visitor is fine on the home page, but a forged token is still an error. The real Bookshelf extractor validates a session cookie or a JWT instead of a token map (see Sessions & Cookies and JWT & Bearer Tokens).

Deriving extractors

axum-macros can derive both traits for structs, which removes most boilerplate:

  • On a struct whose fields are extractors, #[derive(FromRequestParts)] (or FromRequest if the last field reads the body) runs each field’s extractor. This groups common arguments into one parameter.

  • #[from_request(via(Extractor))] builds the type by running another extractor and unwrapping it: a newtype around Json<T> becomes a JSON extractor with your own name.

  • #[from_request(rejection(MyError))] converts the inner rejection with From, so the rejection type is yours.

use axum::{
    Json, Router,
    extract::{FromRequest, FromRequestParts, Path, rejection::{JsonRejection, PathRejection}},
    http::{HeaderMap, StatusCode},
    response::{IntoResponse, Response},
    routing::post,
};
use serde::Deserialize;
use serde_json::json;

pub struct ApiError {
    status: StatusCode,
    detail: String,
}

impl From<JsonRejection> for ApiError {
    fn from(r: JsonRejection) -> Self {
        ApiError { status: r.status(), detail: r.body_text() }
    }
}

impl From<PathRejection> for ApiError {
    fn from(r: PathRejection) -> Self {
        ApiError { status: r.status(), detail: r.body_text() }
    }
}

// HeaderMap cannot fail: its rejection type is Infallible.
impl From<std::convert::Infallible> for ApiError {
    fn from(never: std::convert::Infallible) -> Self {
        match never {}
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        (self.status, Json(json!({ "status": self.status.as_u16(), "detail": self.detail }))).into_response()
    }
}

/// A JSON body whose rejection is an `ApiError`.
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
pub struct AppJson<T>(pub T);

/// Several parts extractors grouped into one argument.
#[derive(FromRequestParts)]
#[from_request(rejection(ApiError))]
pub struct ReviewTarget {
    #[from_request(via(Path))]
    isbn: String,
    headers: HeaderMap,
}

#[derive(Deserialize)]
struct NewReview {
    rating: u8,
}

async fn add_review(target: ReviewTarget, AppJson(review): AppJson<NewReview>) -> String {
    let lang = target.headers.get("accept-language").and_then(|v| v.to_str().ok()).unwrap_or("en");
    format!("{} stars for {} ({lang})\n", review.rating, target.isbn)
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books/{isbn}/reviews", post(add_review));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The derive reports a rejection only through From conversions, so the rejection type of every field needs a From impl into ApiError — here JsonRejection, PathRejection, and Infallible for HeaderMap, which cannot fail (the empty match never {} is how Rust converts an uninhabited type). The official customize-extractor-error example compares this derive approach with a hand-written extractor and with WithRejection.

WithRejection and Cached from axum-extra

When you want to customise a rejection for one handler only, axum-extra’s WithRejection<E, R> runs extractor E and converts its rejection into R with From, without defining a new type:

use axum::{
    Json, Router,
    extract::rejection::JsonRejection,
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::post,
};
use axum_extra::extract::{Cached, WithRejection};
use serde::Deserialize;

#[derive(Debug, thiserror::Error)]
enum ApiError {
    #[error(transparent)]
    Json(#[from] JsonRejection),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let ApiError::Json(rejection) = self;
        (rejection.status(), format!("bad request body: {}\n", rejection.body_text())).into_response()
    }
}

#[derive(Deserialize)]
struct LoanRequest {
    member: String,
}

/// An expensive extractor we do not want to run twice per request.
#[derive(Clone)]
struct RequestCost(u32);

impl<S: Send + Sync> axum::extract::FromRequestParts<S> for RequestCost {
    type Rejection = StatusCode;
    async fn from_request_parts(_: &mut axum::http::request::Parts, _: &S) -> Result<Self, StatusCode> {
        Ok(RequestCost(42)) // imagine a database lookup here
    }
}

async fn lend(
    Cached(cost): Cached<RequestCost>, // runs RequestCost once; later Cached<RequestCost> reuse the value
    WithRejection(Json(loan), _): WithRejection<Json<LoanRequest>, ApiError>,
) -> String {
    format!("lent to {} (cost {})\n", loan.member, cost.0)
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/loans", post(lend));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Cached<T> stores the extracted value in the request extensions, so when a middleware (through from_extractor) and the handler both extract Cached<CurrentUser>, the user is loaded once. T must be Clone.

Logging rejections

Rejections are responses produced before your handler runs, so your handler’s logging never sees them. Three options, from least to most effort:

  1. enable axum’s built-in TRACE events: RUST_LOG=axum::rejection=trace logs every rejection with its type and body;

  2. log inside your own rejection type’s IntoResponse (as the ApiError impls above could do with tracing::warn!);

  3. log all 4xx responses in a TraceLayer on_response callback (see Logging & Tracing).

The official customize-path-rejection example shows a Path wrapper that turns axum’s text rejection into a JSON body with the failing parameter’s name and value, using ErrorKind from PathRejection::FailedToDeserializePathParams.

Where the books are outdated

Yoon (Beginning Axum, chapter 4) and most material written for axum 0.6/0.7 annotate extractor impls with #[async_trait] and a 'life0 lifetime soup in the generated signatures. Since axum 0.8 the traits use native async fn; leaving the attribute in place causes a "lifetime parameters or bounds on method do not match the trait declaration" error (E0195). Remove it and the async-trait dependency.