axum-extra
|
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-extra 0.12.6 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06.
axum-extra holds utilities that are useful but not essential, or that have not yet earned a place in axum’s stable
core. It is versioned in lockstep with axum (axum 0.8 pairs with axum-extra 0.10 through 0.12) and maintained in the same
repository. WithRejection and Cached are covered on Custom Extractors.
Opt-in features (0.12)
axum-extra 0.12 made almost everything opt-in: its only default feature is tracing. Enable what you use:
| Feature | Enables |
|---|---|
|
|
|
|
|
|
|
|
|
multi-value |
|
|
|
newline-delimited JSON streams; |
|
the response types below and
|
|
a |
|
|
[dependencies]
axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["typed-header", "cookie-private", "typed-routing", "query", "form",
"json-lines", "erased-json", "attachment", "middleware"] }
Source: axum-extra: Feature flags
TypedHeader
HeaderMap gives you strings; TypedHeader<T> parses one header into a type from the headers crate — Authorization<Bearer>, UserAgent, ContentType, IfNoneMatch, CacheControl, Cookie, Host, and dozens more — and rejects with 400 when it is malformed. It was moved from axum to axum-extra in 0.7. It also implements
IntoResponseParts, so typed headers can be returned in tuples.
use axum::{Router, routing::get};
use axum_extra::{
TypedHeader,
headers::{Authorization, CacheControl, UserAgent, authorization::Bearer},
};
async fn whoami(
TypedHeader(agent): TypedHeader<UserAgent>,
auth: Option<TypedHeader<Authorization<Bearer>>>, // None if missing, 400 if malformed
) -> (TypedHeader<CacheControl>, String) {
let who = match auth {
Some(TypedHeader(Authorization(bearer))) => format!("token of {} bytes", bearer.token().len()),
None => "anonymous".to_owned(),
};
(TypedHeader(CacheControl::new().with_no_store()), format!("{who} using {}\n", agent.as_str()))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/whoami", get(whoami));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum-extra: TypedHeader
Cookie jars
CookieJar reads the Cookie header and, when returned, writes Set-Cookie headers for every change. Because a jar is
both an extractor and a response part, the pattern is "extract, modify, return":
-
CookieJar— plain cookies; the client can read and alter them. -
SignedCookieJar— cookies with an HMAC signature: readable by the client, but tampering is detected (the cookie is ignored). -
PrivateCookieJar— cookies encrypted and authenticated with AES-GCM: neither readable nor alterable.
Signed and private jars need a cookie::Key they can obtain with FromRef from the state:
use axum::{
Router,
extract::FromRef,
response::{IntoResponse, Redirect},
routing::{get, post},
};
use axum_extra::extract::{
PrivateCookieJar,
cookie::{Cookie, Key, SameSite},
};
#[derive(Clone)]
struct AppState {
cookie_key: Key,
}
impl FromRef<AppState> for Key {
fn from_ref(state: &AppState) -> Self {
state.cookie_key.clone()
}
}
async fn remember_shelf(jar: PrivateCookieJar) -> (PrivateCookieJar, Redirect) {
let cookie = Cookie::build(("last_shelf", "science-fiction"))
.path("/")
.http_only(true)
.secure(true)
.same_site(SameSite::Lax)
.build();
(jar.add(cookie), Redirect::to("/"))
}
async fn home(jar: PrivateCookieJar) -> impl IntoResponse {
match jar.get("last_shelf") {
Some(c) => format!("welcome back to the {} shelf\n", c.value()),
None => "welcome\n".to_owned(),
}
}
#[tokio::main]
async fn main() {
// In production, load at least 64 bytes from a secret store: Key::from(&bytes).
// Key::generate() makes every restart invalidate existing cookies.
let state = AppState { cookie_key: Key::generate() };
let app = Router::new().route("/", get(home)).route("/shelves/remember", post(remember_shelf)).with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum-extra: PrivateCookieJar
Key::from panics if given fewer than 64 bytes; with the cookie-key-expansion feature, Key::derive_from expands a
32-byte master key. Rotating the key invalidates every cookie signed or encrypted with the old one. Session cookies,
cookie attributes and session fixation are covered on Sessions & Cookies.
Typed routing
#[derive(TypedPath)] ties a path template to a struct. The same struct is the extractor (it must come first in the
handler) and the way to build links, so a renamed route cannot leave broken links behind:
use axum::{Router, response::Html};
use axum_extra::routing::{RouterExt, TypedPath};
use serde::Deserialize;
#[derive(TypedPath, Deserialize)]
#[typed_path("/books/{isbn}")]
struct BookPath {
isbn: String,
}
#[derive(TypedPath, Deserialize)]
#[typed_path("/books/{isbn}/reviews/{id}")]
struct ReviewPath {
isbn: String,
id: u32,
}
async fn show_book(BookPath { isbn }: BookPath) -> Html<String> {
let first_review = ReviewPath { isbn: isbn.clone(), id: 1 };
Html(format!("<h1>{isbn}</h1><a href=\"{}\">first review</a>", first_review.to_uri()))
}
async fn show_review(ReviewPath { isbn, id }: ReviewPath) -> String {
format!("review {id} of {isbn}\n")
}
#[tokio::main]
async fn main() {
let app = Router::new().typed_get(show_book).typed_get(show_review);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum-extra: TypedPath
#[typed_path("...", rejection(MyError))] customises the rejection. Since 0.12.6 the macro refuses old :param
segments at compile time, mirroring axum’s runtime check.
Multi-value Query and Form
axum’s Query and Form use serde_urlencoded, which cannot collect repeated keys. axum-extra’s versions use
serde_html_form, which deserialises ?genre=sf&genre=fantasy into a Vec — the format HTML checkboxes and
multi-selects produce. OptionalQuery<T> yields None when the query string is absent and still rejects invalid ones.
For an optional path capture, see Optional path captures: Option<Path<T>>, not OptionalPath below: axum 0.8 handles it natively.
use axum::{Router, routing::get};
use axum_extra::extract::{OptionalQuery, Query};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Filters {
#[serde(default)]
genre: Vec<String>,
#[serde(default)]
year: Option<u16>,
}
async fn search(Query(filters): Query<Filters>) -> String {
format!("{filters:?}\n")
}
#[derive(Debug, Deserialize)]
struct Sort {
by: String,
}
async fn sorted(OptionalQuery(sort): OptionalQuery<Sort>) -> String {
format!("{sort:?}\n")
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/v1/books", get(search)).route("/api/v1/authors", get(sorted));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum-extra: Query and OptionalQuery
$ curl -s 'localhost:3000/api/v1/books?genre=sf&genre=fantasy&year=1969'
Filters { genre: ["sf", "fantasy"], year: Some(1969) }
$ curl -s 'localhost:3000/api/v1/authors'
None
Source: axum-extra: Query and OptionalQuery
Host (the Host header, or X-Forwarded-Host/Forwarded when present) was moved here from axum in 0.8 and has
been deprecated since 0.12.4, because trusting forwarded headers is a deployment decision; read the header you trust
explicitly instead.
Optional path captures: Option<Path<T>>, not OptionalPath
When one handler serves both /blog and /blog/{page}, use axum’s own Option<Path<T>>. Since axum 0.8, Path
implements OptionalFromRequestParts: the handler gets None on a route with no captures and Some on a route with
one, and an invalid capture (/blog/abc, /blog/0) is still rejected with 400 Bad Request instead of being
silently treated as absent.
use std::num::NonZeroU32;
use axum::{Router, extract::Path, routing::get};
async fn blog(page: Option<Path<NonZeroU32>>) -> String {
let page = page.map_or(1, |Path(p)| p.get());
format!("page {page}\n")
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/blog", get(blog)).route("/blog/{page}", get(blog));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum: Path and axum: OptionalFromRequestParts
$ curl -s localhost:3000/blog
page 1
$ curl -s localhost:3000/blog/3
page 3
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/blog/abc
400
Source: axum: Path
axum-extra’s OptionalPath<T> (behind the optional-path feature) predates this. It was deprecated in axum-extra
0.10.0, the release that accompanied axum 0.8, and in 0.12.6 it is still present but marked
#[deprecated = "Use Option<Path<_>> instead"], so using it triggers a deprecation warning (an error under
-D warnings). Treat it as legacy: replace OptionalPath(page): OptionalPath<T> with page: Option<Path<T>> and drop
the optional-path feature.
Responses: Either, JsonLines, ErasedJson, Attachment, FileStream
use std::convert::Infallible;
use axum::{Json, Router, http::StatusCode, routing::get};
use axum_extra::{
either::Either,
json,
json_lines::AsResponse,
response::{Attachment, ErasedJson, JsonLines},
};
use futures::stream::{self, Stream};
use serde::Serialize;
#[derive(Serialize)]
struct Book {
title: &'static str,
year: u16,
}
// Either: two different response types without boxing or `.into_response()`
async fn maybe_book(axum::extract::Path(id): axum::extract::Path<u32>) -> Either<Json<Book>, StatusCode> {
if id == 1 { Either::E1(Json(Book { title: "Dune", year: 1965 })) } else { Either::E2(StatusCode::NOT_FOUND) }
}
// JsonLines: one JSON document per line, streamed (it sets no Content-Type; add one if clients need it)
async fn export() -> JsonLines<impl Stream<Item = Result<Book, Infallible>>, AsResponse> {
JsonLines::new(stream::iter([
Ok(Book { title: "Dune", year: 1965 }),
Ok(Book { title: "The Dispossessed", year: 1974 }),
]))
}
// ErasedJson: JSON without naming a type; the json! macro also accepts serialisable values
async fn stats() -> ErasedJson {
json!({ "books": 2, "authors": ["Herbert", "Le Guin"] })
}
// Attachment: Content-Disposition: attachment; filename="catalogue.csv"
async fn download() -> Attachment<String> {
Attachment::new("isbn,title\n9780441013593,Dune\n".to_owned())
.filename("catalogue.csv")
.content_type("text/csv")
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/books/{id}", get(maybe_book))
.route("/export.ndjson", get(export))
.route("/stats", get(stats))
.route("/catalogue.csv", get(download));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum-extra: response
-
Either,Either3…Either8implementIntoResponse(andFromRequestParts, trying each extractor in turn). -
JsonLinesis also an extractor for NDJSON request bodies, yielding a stream ofT; it respects the body limit since 0.12.5 (see Server-Sent Events & Streaming). -
FileStreamstreams anyAsyncRead(atokio::fs::File, an object-store download) with a filename, content length and HTTP range support (into_range_response); see File Uploads. -
Protobuf<T>(featureprotobuf, prost 0.14) is aJson-like extractor and response for protocol buffers. -
Since 0.12.6,
AttachmentandFileStreamescape quotes and backslashes in filenames, closing a header-parameter injection.
option_layer
A layer that is configured at start-up — compression only in production, a rate limiter only when configured — is an
Option<L>. tower’s Either handles that, but the two branches have different response body types; axum-extra’s
option_layer maps both to axum::body::Body (since 0.12) so the result can be passed to Router::layer directly:
use std::time::Duration;
use axum::{Router, http::StatusCode, routing::get};
use axum_extra::middleware::option_layer;
use tower_http::timeout::TimeoutLayer;
#[tokio::main]
async fn main() {
let timeout_secs: Option<u64> = std::env::var("REQUEST_TIMEOUT_SECS").ok().and_then(|v| v.parse().ok());
let timeout = timeout_secs
.map(|s| TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(s)));
let app = Router::new().route("/", get(|| async { "ok" })).layer(option_layer(timeout));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum-extra: option_layer
Where the books are outdated
Yoon (Beginning Axum, chapter 2) imports TypedHeader and the cookie jars from axum-extra, which is correct for
0.8, but enables axum-extra with its pre-0.12 default features. With axum-extra 0.12 every feature used must be listed
explicitly (for example cached, with-rejection, typed-routing), or the imports fail with "unresolved import". The
Host extractor that older code takes from axum::extract lives in axum-extra since axum 0.8 and is deprecated since
axum-extra 0.12.4. Older code that uses OptionalPath for an optional capture should switch to axum’s
Option<Path<T>> (see Optional path captures: Option<Path<T>>, not OptionalPath).
References
-
axum: Path and OptionalFromRequestParts (replacing the deprecated
OptionalPath)