Rust Axum skill for Codex

Extractors, state and tower layers that compile. Codex writes Axum handlers that compile the first time: body extractors last, state shared through one Clone struct, and a single AppError that turns into JSON responses without leaking internals. It orders tower-http layers deliberately, uses route_layer for auth, shuts down cleanly on SIGTERM, and tests routes in-process with oneshot. Upgrades from 0.7 fix path syntax and drop async_trait.

  • Backend & APIs
  • $rust-axum
  • 219 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- rust-axum

Installs to ~/.agents/skills/rust-axum/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $rust-axum, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds Axum 0.8 web services in Rust - Router with {param} paths, typed extractors and extractor ordering, shared AppState with FromRef, an AppError type implementing IntoResponse, tower and tower-http layers for tracing, timeouts, body limits and CORS, middleware::from_fn, graceful shutdown with axum::serve, and oneshot tests. Use when adding routes or middleware to an Axum app, decoding a confusing Handler trait compile error, upgrading from axum 0.7, sharing a sqlx pool or config, or mapping errors to HTTP responses.

Use it when

  • Building or extending a Rust web service on Axum
  • Handler trait errors, extractor ordering or state type mismatches block compilation
  • Adding tracing, timeouts, CORS, body limits or auth as tower layers
  • Migrating an Axum app across a breaking minor version

Use something else when

  • The problem is general async Rust, task spawning or channels: use rust-tokio-async
  • You need error type design across a whole crate rather than HTTP mapping: use rust-error-handling

How to check the result

cargo clippy with -D warnings and cargo test pass, including oneshot tests for success, 404, a validation failure and malformed JSON returning the agreed error shape.

The mistake it prevents

Putting Json or another body extractor before other arguments, or holding a std Mutex guard across an await, which surfaces as an opaque Handler trait error.

~/.agents/skills/rust-axum/SKILL.md

--- name: rust-axum description: Builds Axum 0.8 web services in Rust - Router with {param} paths, typed extractors and extractor ordering, shared AppState with FromRef, an AppError… license: MIT ---

Done well means: handlers are plain async functions with typed extractors and a Result<_, AppError> return, shared state is one cheap-to-clone struct, cross-cutting concerns are tower layers in a deliberate order, the server shuts down gracefully, and routes are tested in-process without binding a port.

Before changing anything

  1. Read AGENTS.md and Cargo.toml: axum version (0.8 changed path syntax and extractor traits), tokio features, tower-http features, error crates (thiserror, anyhow).
  2. Find the router assembly (rg -n "Router::new|axum::serve" --type rust).
  3. Baseline: cargo build, cargo test, cargo clippy --all-targets. New crates need network to download; in an offline sandbox use what is in ~/.cargo/registry or ask.

Router and state

#[derive(Clone)]
pub struct AppState {
    pub db: sqlx::PgPool,          // already an Arc internally
    pub config: Arc<Config>,
}

pub fn app(state: AppState) -> Router {
    let api = Router::new()
        .route("/orders", get(list_orders).post(create_order))
        .route("/orders/{id}", get(get_order).delete(delete_order))
        .route("/files/{*path}", get(serve_file));

    Router::new()
        .nest("/v1", api)
        .route("/healthz", get(|| async { StatusCode::NO_CONTENT }))
        .layer(middleware_stack)   // defined in app(), see Middleware below
        .with_state(state)
}
  • Axum 0.8 paths use {id} and {*rest}. The 0.7 :id / *rest syntax panics at startup.
  • Wrap non-Clone or large members in Arc. Pools from sqlx, reqwest clients and tokio::sync handles are already cheap to clone.
  • To extract a sub-part of state (State<PgPool>), derive or implement FromRef: #[derive(Clone, FromRef)] struct AppState { db: PgPool, ... } with the macros feature.

Handlers and extractor order

#[derive(Deserialize)]
pub struct CreateOrder { sku: String, quantity: u32 }

pub async fn create_order(
    State(state): State<AppState>,
    auth: AuthUser,                   // custom FromRequestParts extractor
    Json(body): Json<CreateOrder>,    // body extractor must be LAST
) -> Result<(StatusCode, Json<Order>), AppError> {
    if body.quantity == 0 {
        return Err(AppError::Validation("quantity must be at least 1".into()));
    }
    let order = orders::create(&state.db, auth.user_id, body).await?;
    Ok((StatusCode::CREATED, Json(order)))
}

pub async fn get_order(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<Order>, AppError> {
    orders::find(&state.db, id).await?.map(Json).ok_or(AppError::NotFound)
}
ExtractorImplementsPosition
State, Path, Query, HeaderMap, custom authFromRequestPartsAny, before the body
Json, Form, String, Bytes, Multipart, RequestFromRequest (consumes body)Exactly one, last

When the compiler says “the trait Handler<_, _> is not implemented”, add #[axum::debug_handler] (macros feature) to the handler for a readable error. Usual causes: body extractor not last, an argument that is not an extractor, a non-Send value (a MutexGuard from std::sync, an Rc) held across .await.

In 0.8, custom extractors implement FromRequestParts / FromRequest with native async fn in the trait; remove #[async_trait] when upgrading.

Errors

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("not found")]
    NotFound,
    #[error("{0}")]
    Validation(String),
    #[error(transparent)]
    Db(#[from] sqlx::Error),
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, msg) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::Validation(m) => (StatusCode::UNPROCESSABLE_ENTITY, m.clone()),
            AppError::Db(_) | AppError::Other(_) => {
                tracing::error!(error = ?self, "request failed");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
            }
        };
        (status, Json(serde_json::json!({ "error": msg }))).into_response()
    }
}

Never return internal error text to clients. Rejections from built-in extractors (bad JSON, bad path) produce plain-text 4xx responses; to make them match your JSON shape, wrap the extractor (#[derive(FromRequest)] #[from_request(via(Json), rejection(AppError))]) or convert JsonRejection in From<JsonRejection> for AppError.

Middleware with tower

// Inside app(), before building the router. A let binding infers the layer type;
// naming it in a helper's return type runs into Router::layer's Clone/Send bounds.
let middleware_stack = ServiceBuilder::new()
    .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
    .layer(TraceLayer::new_for_http())
    .layer(PropagateRequestIdLayer::x_request_id())
    .layer(RequestBodyLimitLayer::new(2 * 1024 * 1024))
    .layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(15)))
    .layer(CorsLayer::new().allow_origin(["https://app.example.com".parse::<HeaderValue>().unwrap()]));
  • Order: ServiceBuilder applies layers top to bottom (first listed sees the request first). Chained Router::layer calls wrap the other way: the last .layer is outermost.
  • Older tower-http releases spell the timeout constructor TimeoutLayer::new(duration); match the version in Cargo.lock.
  • Router::layer only wraps routes added before it. Use route_layer for auth so unmatched paths still return 404 instead of 401.
  • Write ad-hoc middleware with axum::middleware::from_fn or from_fn_with_state:
async fn require_api_key(State(s): State<AppState>, req: Request, next: Next) -> Result<Response, StatusCode> {
    match req.headers().get("x-api-key") {
        Some(k) if s.config.api_keys.contains(k.as_bytes()) => Ok(next.run(req).await),
        _ => Err(StatusCode::UNAUTHORIZED),
    }
}
// .route_layer(middleware::from_fn_with_state(state.clone(), require_api_key))

Serving and shutdown

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt().json().init();
    let state = AppState::from_env().await?;
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app(state)).with_graceful_shutdown(shutdown_signal()).await?;
    Ok(())
}

async fn shutdown_signal() {
    let ctrl_c = async { tokio::signal::ctrl_c().await.expect("ctrl-c handler") };
    #[cfg(unix)]
    let term = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("SIGTERM handler").recv().await;
    };
    #[cfg(not(unix))]
    let term = std::future::pending::<()>();
    tokio::select! { _ = ctrl_c => {}, _ = term => {} }
}

Blocking work (password hashing, image processing, sync file I/O on big files) goes in tokio::task::spawn_blocking, never directly in a handler.

Testing

#[tokio::test]
async fn get_missing_order_is_404() {
    let app = app(test_state().await);
    let res = app
        .oneshot(Request::get("/v1/orders/00000000-0000-0000-0000-000000000000").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::NOT_FOUND);
    let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
    assert!(String::from_utf8_lossy(&body).contains("not found"));
}

oneshot needs tower::ServiceExt in scope. For database-backed tests, #[sqlx::test] provides a fresh migrated database per test when DATABASE_URL points at a server.

Checklist

  • Paths use {param}; body extractor last in every handler
  • One AppError with IntoResponse; extractor rejections mapped to the same shape
  • Tracing, request ID, body limit, timeout, CORS layers in a known order
  • Auth via route_layer; graceful shutdown on SIGTERM
  • cargo clippy --all-targets -- -D warnings and cargo test pass

Gotchas

  • Holding a std::sync::Mutex guard across .await makes the future !Send; scope the guard in a block or use tokio::sync::Mutex.
  • Path<(A, B)> binds by position; Path<Params> with a struct binds by name.
  • Json<T> requires Content-Type: application/json, or it rejects with 415.
  • Nesting a router that has its own state requires calling with_state on it first or sharing the same state type.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Rust Axum"
  short_description: "Extractors, state and tower layers that compile"
  default_prompt: "$rust-axum Add CRUD endpoints for orders to our Axum service with a sqlx pool in state"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem