Claude Code for Rust Web with Axum: Extractors, Middleware, and Async Handlers — Claude Skills 360 Blog
Blog / Development / Claude Code for Rust Web with Axum: Extractors, Middleware, and Async Handlers
Development

Claude Code for Rust Web with Axum: Extractors, Middleware, and Async Handlers

Published: August 15, 2026
Read time: 9 min read
By: Claude Skills 360

Axum’s design composing Tower middleware makes it the cleanest Rust web framework for building APIs: handlers are async functions with type-safe extractors, middleware is pluggable via tower::ServiceBuilder, and the compiler catches most errors before runtime. Claude Code generates correct Axum code — understanding the extractor system, proper state sharing with Arc, and the thiserror pattern for structured error handling.

This guide covers Axum with Claude Code: extractors, application state, error handling, Tower middleware, sqlx for PostgreSQL, and testing.

CLAUDE.md for Axum Projects

## Rust/Axum Stack
- Rust stable, Axum 0.7, Tower 0.4
- Async runtime: Tokio (tokio = { features = ["full"] })
- Database: sqlx 0.7 with PostgreSQL, compile-time checked queries
- Error handling: thiserror for domain errors, anyhow for app startup
- JSON: serde_json, axum::Json extractor
- Auth: JWT with jsonwebtoken crate

## Patterns  
- AppState in Arc<AppState> passed via Extension or State extractor
- Custom extractors for auth — implement FromRequestParts
- Errors implementing IntoResponse — never panic in handlers
- sqlx::query_as! macro for compile-time query verification
- Test with axum::test utilities + TestServer from axum-test crate

Application Structure

Set up an Axum application with database pool, JWT auth,
and a layered middleware stack.
// src/main.rs
use std::sync::Arc;
use axum::{Router, middleware};
use sqlx::PgPool;
use tower::ServiceBuilder;
use tower_http::{
    cors::{CorsLayer, Any},
    trace::TraceLayer,
    request_id::{MakeRequestUuid, SetRequestIdLayer, PropagateRequestIdLayer},
};

mod auth;
mod errors;
mod handlers;
mod models;
mod middleware as mw;

#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
    pub jwt_secret: String,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();

    let database_url = std::env::var("DATABASE_URL")?;
    let db = PgPool::connect(&database_url).await?;
    sqlx::migrate!("./migrations").run(&db).await?;

    let state = Arc::new(AppState {
        db,
        jwt_secret: std::env::var("JWT_SECRET")?,
    });

    let app = Router::new()
        .merge(handlers::orders::router())
        .merge(handlers::auth::router())
        .with_state(state)
        .layer(
            ServiceBuilder::new()
                .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
                .layer(TraceLayer::new_for_http())
                .layer(PropagateRequestIdLayer::x_request_id())
                .layer(
                    CorsLayer::new()
                        .allow_origin(Any)
                        .allow_methods(Any)
                        .allow_headers(Any),
                ),
        );

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    tracing::info!("listening on {}", listener.local_addr()?);
    axum::serve(listener, app).await?;

    Ok(())
}

Type-Safe Extractors

Create a custom CurrentUser extractor that validates the JWT
and returns the user. Use it in all protected routes.
// src/auth/extractor.rs
use axum::{
    async_trait,
    extract::{FromRequestParts, State},
    http::{request::Parts, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use crate::AppState;

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String, // user_id
    pub exp: usize,
    pub roles: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct CurrentUser {
    pub user_id: String,
    pub roles: Vec<String>,
}

#[async_trait]
impl FromRequestParts<Arc<AppState>> for CurrentUser {
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &Arc<AppState>,
    ) -> Result<Self, Self::Rejection> {
        // Extract bearer token from Authorization header
        let auth_header = parts
            .headers
            .get("Authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or(AuthError::MissingToken)?;

        let token = auth_header
            .strip_prefix("Bearer ")
            .ok_or(AuthError::InvalidToken)?;

        // Verify and decode JWT
        let token_data = decode::<Claims>(
            token,
            &DecodingKey::from_secret(state.jwt_secret.as_bytes()),
            &Validation::default(),
        )
        .map_err(|e| match e.kind() {
            jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired,
            _ => AuthError::InvalidToken,
        })?;

        Ok(CurrentUser {
            user_id: token_data.claims.sub,
            roles: token_data.claims.roles,
        })
    }
}

#[derive(Debug)]
pub enum AuthError {
    MissingToken,
    InvalidToken,
    TokenExpired,
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AuthError::MissingToken => (StatusCode::UNAUTHORIZED, "Missing authorization token"),
            AuthError::InvalidToken => (StatusCode::UNAUTHORIZED, "Invalid token"),
            AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired"),
        };
        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}

Error Handling with thiserror

Create domain errors that implement IntoResponse so handlers
can use ? without explicit error mapping.
// src/errors.rs
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("Resource not found")]
    NotFound,

    #[error("Insufficient stock for product {product_id}: {available} available, {requested} requested")]
    InsufficientStock {
        product_id: String,
        available: i32,
        requested: i32,
    },

    #[error("Order cannot be cancelled in status: {status}")]
    CancelNotAllowed { status: String },

    #[error("Validation error: {0}")]
    Validation(String),

    #[error("Database error")]
    Database(#[from] sqlx::Error),

    #[error("Unauthorized")]
    Unauthorized,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, body) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
            AppError::InsufficientStock { .. } => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
            AppError::CancelNotAllowed { .. } => (StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() })),
            AppError::Validation(msg) => (StatusCode::BAD_REQUEST, json!({ "error": msg })),
            AppError::Database(e) => {
                tracing::error!("database error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "Internal server error" }))
            }
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, json!({ "error": self.to_string() })),
        };
        (status, Json(body)).into_response()
    }
}

pub type Result<T> = std::result::Result<T, AppError>;

Handlers with sqlx

Write the order handlers. Use sqlx::query_as! for type-safe
database queries. Include the create, get, and list endpoints.
// src/handlers/orders.rs
use axum::{
    extract::{Path, State},
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::sync::Arc;
use uuid::Uuid;

use crate::{auth::extractor::CurrentUser, errors::Result, AppState};

#[derive(Debug, Serialize, FromRow)]
pub struct Order {
    pub id: Uuid,
    pub user_id: Uuid,
    pub status: String,
    pub total_cents: i64,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreateOrderRequest {
    pub items: Vec<OrderItemRequest>,
    pub shipping_address_id: Uuid,
}

#[derive(Debug, Deserialize)]
pub struct OrderItemRequest {
    pub product_id: Uuid,
    pub quantity: i32,
}

pub fn router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/orders", get(list_orders).post(create_order))
        .route("/orders/:id", get(get_order))
}

async fn list_orders(
    State(state): State<Arc<AppState>>,
    user: CurrentUser,
) -> Result<Json<Vec<Order>>> {
    let user_id = user.user_id.parse::<Uuid>().map_err(|_| crate::errors::AppError::Unauthorized)?;

    // sqlx compile-time query verification
    let orders = sqlx::query_as!(
        Order,
        r#"
        SELECT id, user_id, status, total_cents, created_at
        FROM orders
        WHERE user_id = $1
        ORDER BY created_at DESC
        LIMIT 50
        "#,
        user_id
    )
    .fetch_all(&state.db)
    .await?;

    Ok(Json(orders))
}

async fn get_order(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
    user: CurrentUser,
) -> Result<Json<Order>> {
    let user_id = user.user_id.parse::<Uuid>().map_err(|_| crate::errors::AppError::Unauthorized)?;

    let order = sqlx::query_as!(
        Order,
        r#"
        SELECT id, user_id, status, total_cents, created_at
        FROM orders
        WHERE id = $1 AND user_id = $2
        "#,
        id,
        user_id,
    )
    .fetch_optional(&state.db)
    .await?
    .ok_or(crate::errors::AppError::NotFound)?;

    Ok(Json(order))
}

async fn create_order(
    State(state): State<Arc<AppState>>,
    user: CurrentUser,
    Json(req): Json<CreateOrderRequest>,
) -> Result<(axum::http::StatusCode, Json<Order>)> {
    if req.items.is_empty() {
        return Err(crate::errors::AppError::Validation("Order must have at least one item".into()));
    }

    let user_id = user.user_id.parse::<Uuid>().map_err(|_| crate::errors::AppError::Unauthorized)?;

    let mut tx = state.db.begin().await?;

    // Calculate total from current prices
    let total_cents: i64 = 0; // Calculate from products in real impl

    let order = sqlx::query_as!(
        Order,
        r#"
        INSERT INTO orders (user_id, status, total_cents, shipping_address_id)
        VALUES ($1, 'pending', $2, $3)
        RETURNING id, user_id, status, total_cents, created_at
        "#,
        user_id,
        total_cents,
        req.shipping_address_id,
    )
    .fetch_one(&mut *tx)
    .await?;

    tx.commit().await?;

    Ok((axum::http::StatusCode::CREATED, Json(order)))
}

Integration Testing

// tests/integration_test.rs
#[cfg(test)]
mod tests {
    use axum::http::StatusCode;
    use axum_test::TestServer;
    use serde_json::json;

    async fn create_test_app() -> TestServer {
        let state = Arc::new(AppState {
            db: create_test_db().await,
            jwt_secret: "test-secret".to_string(),
        });
        let app = crate::create_app(state);
        TestServer::new(app).unwrap()
    }

    #[tokio::test]
    async fn test_create_order_requires_auth() {
        let server = create_test_app().await;

        let response = server
            .post("/orders")
            .json(&json!({ "items": [], "shipping_address_id": Uuid::new_v4() }))
            .await;

        assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn test_list_orders_returns_only_own_orders() {
        let server = create_test_app().await;
        let token = create_test_jwt("user-1");

        let response = server
            .get("/orders")
            .add_header("Authorization", format!("Bearer {}", token))
            .await;

        assert_eq!(response.status_code(), StatusCode::OK);
        let orders: Vec<serde_json::Value> = response.json();
        // All returned orders should belong to user-1
        assert!(orders.iter().all(|o| o["user_id"] == "user-1"));
    }
}

For Rust WASM patterns and compiling Rust to WebAssembly for browser use, see the WASM guide. For deploying Rust services with Docker and Kubernetes, see the Docker guide and Kubernetes guide. The Claude Skills 360 bundle includes Rust web skill sets covering Axum, async patterns, and production deployment. Start with the free tier to try Rust code generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free