← Docs

Recipe: Rust axum handler scaffold

Drop-in pattern for a typed JSON handler with shared state, request extraction, and error mapping.

Cargo.toml

[dependencies]
axum = "0.7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"

main.rs

use axum::{Router, routing::post, extract::State, Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    db_pool: sqlx::PgPool,
}

#[derive(Deserialize)]
struct CreateUser {
    email: String,
}

#[derive(Serialize)]
struct UserResponse {
    id: String,
    email: String,
}

async fn create_user(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<CreateUser>,
) -> Result<Json<UserResponse>, (StatusCode, String)> {
    let row = sqlx::query_as!(UserRow, "INSERT INTO users (email) VALUES ($1) RETURNING id, email", payload.email)
        .fetch_one(&state.db_pool)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    Ok(Json(UserResponse { id: row.id.to_string(), email: row.email }))
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();
    let state = Arc::new(AppState { db_pool: setup_db().await });
    let app = Router::new()
        .route("/users", post(create_user))
        .with_state(state);
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Notes

  • · State extractor requires Clone — wrap in Arc for cheap sharing.
  • · Return Result<Json<T>, (StatusCode, String)> for clean error mapping.
  • · axum 0.7 uses axum::serve instead of the older Server::bind.