packages/omo-codex/plugin/skills/programming/references/rust/axum-stack.md
The canonical production HTTP service in Rust 2026.
[dependencies]
axum = { version = "0.8", features = ["macros", "tracing", "ws", "multipart"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = [
"trace", "compression-gzip", "compression-br",
"timeout", "cors", "request-id", "sensitive-headers",
"limit", "set-header",
] }
# Errors / observability
anyhow = "1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
color-eyre = "0.6"
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio-rustls", "postgres", "uuid", "macros",
"migrate", "json",
] }
# Serialization / validation
serde = { version = "1", features = ["derive"] }
serde_json = "1"
validator = { version = "0.18", features = ["derive"] }
# Types
uuid = { version = "1", features = ["v4", "v7", "serde"] }
jiff = { version = "0.1", features = ["serde"] }
# Config
config = { version = "0.14", features = ["toml", "yaml"] }
secrecy = { version = "0.10", features = ["serde"] }
# OpenAPI (optional but recommended)
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
utoipa-axum = "0.1"
utoipa-swagger-ui = { version = "8", features = ["axum"] }
src/
main.rs # binary entry
lib.rs # re-exports + app builder
config.rs # Settings type + loader
state.rs # AppState (shared via Arc)
routes/
mod.rs # Router::new() composition
health.rs
users.rs
middleware/
mod.rs
auth.rs
request_id.rs
models/
mod.rs
user.rs
error.rs # AppError + IntoResponse impl
db/
mod.rs
migrations/
migrations/ # sqlx migrations
// src/error.rs
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("unauthorized")]
Unauthorized,
#[error("validation: {0}")]
Validation(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("internal")]
Internal(#[from] anyhow::Error),
#[error("database")]
Database(#[from] sqlx::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not_found", self.to_string()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", "unauthorized".into()),
AppError::Validation(m) => (StatusCode::UNPROCESSABLE_ENTITY, "validation", m.clone()),
AppError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m.clone()),
AppError::Database(e) => {
tracing::error!(error = ?e, "database error");
(StatusCode::INTERNAL_SERVER_ERROR, "database", "internal".into())
}
AppError::Internal(e) => {
tracing::error!(error = ?e, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal", "internal".into())
}
};
(status, Json(json!({"error": {"code": code, "message": message}}))).into_response()
}
}
pub type AppResult<T> = std::result::Result<T, AppError>;
Pattern: business errors return AppResult<T>; the IntoResponse impl translates them to HTTP. sqlx::Error and anyhow::Error auto-convert via From. Internal-bucket errors are logged but never leak their Debug representation to clients.
// src/state.rs
use std::sync::Arc;
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub config: Arc<crate::config::Settings>,
pub http: reqwest::Client,
}
impl AppState {
pub async fn new(config: crate::config::Settings) -> anyhow::Result<Self> {
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(config.db.max_connections)
.acquire_timeout(std::time::Duration::from_secs(3))
.connect(config.db.url.expose_secret())
.await?;
sqlx::migrate!("./migrations").run(&db).await?;
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
.build()?;
Ok(Self { db, config: Arc::new(config), http })
}
}
// src/routes/users.rs
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;
use crate::{error::{AppError, AppResult}, state::AppState};
#[derive(Debug, Deserialize, Validate)]
pub struct CreateUser {
#[validate(email)]
pub email: String,
#[validate(length(min = 1, max = 100))]
pub name: String,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
pub name: String,
pub created_at: jiff::Timestamp,
}
#[tracing::instrument(skip(state, body))]
pub async fn create_user(
State(state): State<AppState>,
Json(body): Json<CreateUser>,
) -> AppResult<(axum::http::StatusCode, Json<User>)> {
body.validate().map_err(|e| AppError::Validation(e.to_string()))?;
let id = Uuid::now_v7();
let user = sqlx::query_as!(
User,
r#"INSERT INTO users (id, email, name, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING id, email, name, created_at as "created_at: jiff::Timestamp""#,
id, body.email, body.name
)
.fetch_one(&state.db)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db) if db.code().as_deref() == Some("23505") =>
AppError::Conflict("email already exists".into()),
_ => AppError::Database(e),
})?;
Ok((axum::http::StatusCode::CREATED, Json(user)))
}
#[tracing::instrument(skip(state))]
pub async fn get_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> AppResult<Json<User>> {
sqlx::query_as!(
User,
r#"SELECT id, email, name, created_at as "created_at: jiff::Timestamp"
FROM users WHERE id = $1"#,
id
)
.fetch_optional(&state.db)
.await?
.map(Json)
.ok_or(AppError::NotFound)
}
// src/routes/mod.rs
use axum::{routing::{get, post}, Router};
use tower_http::{
compression::CompressionLayer,
cors::CorsLayer,
request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
sensitive_headers::SetSensitiveHeadersLayer,
timeout::TimeoutLayer,
trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer},
};
use std::time::Duration;
use crate::state::AppState;
mod health;
mod users;
pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/health", get(health::handler))
.route("/users", post(users::create_user))
.route("/users/:id", get(users::get_user))
.with_state(state);
Router::new()
.nest("/api/v1", api)
.layer(
tower::ServiceBuilder::new()
.layer(SetSensitiveHeadersLayer::new([
axum::http::header::AUTHORIZATION,
axum::http::header::COOKIE,
]))
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().include_headers(false))
.on_response(DefaultOnResponse::new().latency_unit(tower_http::LatencyUnit::Millis)),
)
.layer(PropagateRequestIdLayer::x_request_id())
.layer(TimeoutLayer::new(Duration::from_secs(30)))
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive()), // tighten in production
)
}
Order matters: outermost layer wraps the request first. Trace before timeout so timeouts get logged. Compression after trace so trace sees the original body size.
// src/main.rs
use my_app::{config::Settings, routes, state::AppState};
#[tokio::main]
async fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
init_tracing();
let config = Settings::load()?;
let state = AppState::new(config.clone()).await?;
let app = routes::router(state);
let listener = tokio::net::TcpListener::bind(&config.bind).await?;
tracing::info!(addr = %config.bind, "listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn,tower_http=info"));
fmt().with_env_filter(filter).with_target(false).json().init();
}
async fn shutdown_signal() {
let ctrl_c = async { tokio::signal::ctrl_c().await.expect("ctrl_c handler"); };
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("signal handler").recv().await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
tracing::info!("shutting down");
}
// src/middleware/auth.rs
use axum::{
extract::{Request, State},
http::header::AUTHORIZATION,
middleware::Next,
response::Response,
};
use crate::{error::AppError, state::AppState};
#[derive(Clone, Debug)]
pub struct AuthUser { pub id: uuid::Uuid }
pub async fn require_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let token = request.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
let claims = verify_jwt(token, &state.config.jwt_secret)?;
request.extensions_mut().insert(AuthUser { id: claims.sub });
Ok(next.run(request).await)
}
Apply with .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) on the subroutes that need it.
// tests/users.rs
#[tokio::test]
async fn creates_user() {
let pool = test_db().await; // helper that spins up a transactional DB
let state = AppState::new_test(pool).await.unwrap();
let app = my_app::routes::router(state);
let request = axum::http::Request::builder()
.uri("/api/v1/users")
.method("POST")
.header("content-type", "application/json")
.body(axum::body::Body::from(
serde_json::to_vec(&serde_json::json!({"email": "[email protected]", "name": "A"})).unwrap()
)).unwrap();
let response = tower::ServiceExt::oneshot(app, request).await.unwrap();
assert_eq!(response.status(), 201);
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.unwrap();
let user: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(user["email"], "[email protected]");
}
tower::ServiceExt::oneshot calls the router directly without binding a socket. Tests run in parallel without port collisions.
// src/config.rs
use secrecy::{Secret, ExposeSecret};
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Settings {
pub bind: String,
pub db: Database,
pub jwt_secret: Secret<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Database {
pub url: Secret<String>,
pub max_connections: u32,
}
impl Settings {
pub fn load() -> anyhow::Result<Self> {
let cfg = config::Config::builder()
.add_source(config::File::with_name("config/default").required(false))
.add_source(config::File::with_name(&format!(
"config/{}", std::env::var("APP_ENV").unwrap_or_else(|_| "dev".into())
)).required(false))
.add_source(config::Environment::with_prefix("APP").separator("__"))
.build()?;
Ok(cfg.try_deserialize()?)
}
}
Secret<T> from the secrecy crate hides the value in Debug/Display to prevent accidental log leakage. Access via .expose_secret() only where needed.
Add utoipa derive macros on your DTOs and handlers, mount Swagger UI at /swagger-ui:
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)]
#[openapi(
paths(routes::users::create_user, routes::users::get_user),
components(schemas(routes::users::User, routes::users::CreateUser))
)]
struct ApiDoc;
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(utoipa_axum::routes!(routes::users::create_user, routes::users::get_user))
.split_for_parts();
let app = router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api));
0.0.0.0 in containers, 127.0.0.1 for local-only services.RUST_LOG=info,sqlx=warn (or use EnvFilter defaults as shown).sqlx::migrate! block). Fail fast on schema mismatch.tower::limit::RateLimitLayer or token-bucket middleware for public endpoints.tower_http::limit::RequestBodyLimitLayer to bound request size.CompressionLayer.CorsLayer::permissive() to production.SetSensitiveHeadersLayer.with_graceful_shutdown so deploys roll without dropping requests.cargo chef for incremental Docker builds.error_for_status()? on outbound reqwest — 4xx silently succeeds.Result<T, sqlx::Error> from handlers — leak DB details to clients. Always go through AppError.Json<T> extractor before validation — invalid JSON returns axum's default 422 with no body shape. Wrap in a ValidatedJson<T> extractor that runs validator and returns AppError..await on slow external calls — exhausts the pool. Acquire late, release early.tracing::instrument on handlers — losing per-request span correlation.RequestBodyLimitLayer — DoS surface. Default axum has no limit.