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. Writingimpl<S> ... where S: Send + Syncmakes the extractor usable with any router; constrainS(or useFromRef, below) when you need something from the state. -
Rejectioncan be anyIntoResponsetype. A tuple(StatusCode, &str)is fine for a demo; real applications use theirAppError(see Error Handling). -
partsis&mut: an extractor can insert intoparts.extensionsto pass data on, which is howCachedworks.
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();
}
Source: axum: Implementing FromRequest
$ 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"}
Source: axum: Implementing FromRequest
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();
}
Source: axum: Accessing other extractors in FromRequest or FromRequestParts implementations and OptionalFromRequestParts
$ 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
Source: axum: Accessing other extractors in FromRequest or FromRequestParts implementations and OptionalFromRequestParts
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)](orFromRequestif 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 aroundJson<T>becomes a JSON extractor with your own name. -
#[from_request(rejection(MyError))]converts the inner rejection withFrom, 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();
}
Source: axum-macros: derive FromRequest
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();
}
Source: axum-extra: WithRejection and Cached
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:
-
enable axum’s built-in
TRACEevents:RUST_LOG=axum::rejection=tracelogs every rejection with its type and body; -
log inside your own rejection type’s
IntoResponse(as theApiErrorimpls above could do withtracing::warn!); -
log all
4xxresponses in aTraceLayeron_responsecallback (see Logging & Tracing).
Source: axum: Logging rejections
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.