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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- rust-axum Installs to ~/.agents/skills/rust-axum/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install rust-axum The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project rust-axum Installs to .agents/skills/rust-axum/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/rust-axum.zipunzip -o rust-axum.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) rust-axum PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.mdandCargo.toml: axum version (0.8 changed path syntax and extractor traits), tokio features, tower-http features, error crates (thiserror,anyhow). - Find the router assembly (
rg -n "Router::new|axum::serve" --type rust). - Baseline:
cargo build,cargo test,cargo clippy --all-targets. New crates need network to download; in an offline sandbox use what is in~/.cargo/registryor 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/*restsyntax panics at startup. - Wrap non-
Cloneor large members inArc. Pools from sqlx, reqwest clients andtokio::synchandles are already cheap to clone. - To extract a sub-part of state (
State<PgPool>), derive or implementFromRef:#[derive(Clone, FromRef)] struct AppState { db: PgPool, ... }with themacrosfeature.
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)
}
| Extractor | Implements | Position |
|---|---|---|
State, Path, Query, HeaderMap, custom auth | FromRequestParts | Any, before the body |
Json, Form, String, Bytes, Multipart, Request | FromRequest (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:
ServiceBuilderapplies layers top to bottom (first listed sees the request first). ChainedRouter::layercalls wrap the other way: the last.layeris outermost. - Older tower-http releases spell the timeout constructor
TimeoutLayer::new(duration); match the version inCargo.lock. Router::layeronly wraps routes added before it. Useroute_layerfor auth so unmatched paths still return 404 instead of 401.- Write ad-hoc middleware with
axum::middleware::from_fnorfrom_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
AppErrorwithIntoResponse; 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 warningsandcargo testpass
Gotchas
- Holding a
std::sync::Mutexguard across.awaitmakes the future!Send; scope the guard in a block or usetokio::sync::Mutex. Path<(A, B)>binds by position;Path<Params>with a struct binds by name.Json<T>requiresContent-Type: application/json, or it rejects with 415.- Nesting a router that has its own state requires calling
with_stateon 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.yaml
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.