name: rust-axum
description: | Use when this capability is needed.
metadata:
author: adxptived
Quick Navigation
Axum Web Framework
Axum is the Tower-native, Tokio-first HTTP framework. It composes with any Tower middleware and has zero magic — handlers are plain async functions.
Quick Reference
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Minimal Server
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(root))
.route("/health", get(health));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
async fn root() -> &'static str { "Hello, world!" }
async fn health() -> axum::http::StatusCode { axum::http::StatusCode::OK }
Routing
use axum::{
routing::{delete, get, post, put},
Router,
};
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user).put(update_user).delete(delete_user))
.nest("/api/v1", api_v1_router())
.nest_service("/static", tower_http::services::ServeDir::new("public"));
fn api_v1_router() -> Router<AppState> {
Router::new()
.route("/posts", get(list_posts))
}
State Management
use axum::{extract::State, Router};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db: Arc<DbPool>,
config: Arc<Config>,
}
async fn get_user(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<u64>,
) -> Result<axum::Json<User>, AppError> {
let user = state.db.find_user(id).await?;
Ok(axum::Json(user))
}
let state = Arc::new(AppState { db, config });
let app = Router::new()
.route("/users/:id", get(get_user))
.with_state(state);
Extractors
Extractors are types that implement FromRequest or FromRequestParts. They run before the handler.
use axum::{
extract::{Json, Path, Query, State},
http::HeaderMap,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
per_page: Option<u32>,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
async fn list_users(
State(db): State<Arc<DbPool>>,
Query(pagination): Query<Pagination>,
headers: HeaderMap,
) -> axum::Json<Vec<User>> {
let page = pagination.page.unwrap_or(1);
let per_page = pagination.per_page.unwrap_or(20);
axum::Json(db.list_users(page, per_page).await.unwrap())
}
async fn create_user(
State(db): State<Arc<DbPool>>,
Json(payload): Json<CreateUser>,
) -> Result<(axum::http::StatusCode, axum::Json<User>), AppError> {
let user = db.create_user(&payload.name, &payload.email).?;
((axum::http::StatusCode::CREATED, axum::(user)))
}
Error Handling
The idiomatic pattern: define AppError, implement IntoResponse.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
#[derive(Debug)]
pub enum AppError {
NotFound(String),
Unauthorized,
InvalidInput(String),
Internal(anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".into()),
AppError::InvalidInput(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
AppError::Internal(e) => {
tracing::error!("Internal error: {e:#}");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> {
AppError::(err)
}
}
((id): Path<>) <Json<Post>, AppError> {
= db.(id).
.(AppError::Internal)?
.(|| AppError::(()))?;
((post))
}
Middleware
Axum uses Tower middleware. Prefer tower_http for common needs:
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
compression::CompressionLayer,
request_id::{MakeRequestUuid, SetRequestIdLayer, PropagateRequestIdLayer},
};
use axum::http::header;
let app = Router::new()
.route("/", get(handler))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]),
)
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
.layer(PropagateRequestIdLayer::x_request_id());
Custom Middleware
use axum::{middleware, extract::Request, response::Response};
async fn auth_middleware(
State(state): State<Arc<AppState>>,
mut req: Request,
next: middleware::Next,
) -> Result<Response, AppError> {
let token = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
let user = state.auth.validate_token(token).await
.map_err(|_| AppError::Unauthorized)?;
req.extensions_mut().insert(user);
Ok(next.run(req).await)
}
let protected = Router::new()
.route("/profile", get(get_profile))
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
WebSockets
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
loop {
tokio::select! {
Some(Ok(msg)) = socket.recv() => {
match msg {
Message::Text(text) => {
if socket.send(Message::Text(format!("echo: {text}"))).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
else => break,
}
}
}
Graceful Shutdown
use tokio::signal;
#[tokio::main]
async fn main() {
let app = build_router();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
println!();
}
Project Structure
src/
├── main.rs # Entry point: build app, bind listener
├── lib.rs # App builder fn (for integration tests)
├── router.rs # Route definitions
├── state.rs # AppState definition
├── error.rs # AppError + IntoResponse
├── handlers/
│ ├── users.rs
│ └── posts.rs
├── middleware/
│ ├── auth.rs
│ └── logging.rs
└── models/ # Domain types
├── user.rs
└── post.rs
Common Gotchas
#[derive(Clone)]
struct AppState {
db: Arc<Pool<Postgres>>,
}
async fn handler(
State(s): State<AppState>,
Path(id): Path<u64>,
Json(body): Json<Input>,
) {}
async fn handler(Path((user_id, post_id)): Path<(u64, u64)>) {}
Anti-Patterns
async fn bad(Json(body): Json<Input>, Path(id): Path<u64>) {}
async fn good(Path(id): Path<u64>, Json(body): Json<Input>) {}
struct AppState { db: Pool<Postgres> }
#[derive(Clone)]
struct AppState { db: Arc<Pool<Postgres>> }
Production Checklist
- Put body extractors last in handler signatures.
- Keep
AppState cheap to clone; use Arc for expensive shared resources.
- Convert domain errors through one
IntoResponse path.
- Add
TraceLayer, request IDs, timeouts, and CORS deliberately.
- Exercise routes through
Router::oneshot or real listener integration tests.
- Verify graceful shutdown drains in-flight requests.
References
Source: adxptived/Rust-Skills — distributed by TomeVault.