L’interface est localisée ; les présentations et documents des Skills sont traduits automatiquement par IA dans votre langue. Le code, les commandes, chemins et termes techniques restent en version originale, et le document de l’auteur fait foi.
Explorer les Skills
Développement · SKILL.md

axum

axum

Builds HTTP APIs and web services in Rust with Axum, the Tokio team's web framework on top of Tokio, Hyper and Tower. Use when a user asks to create a Rust REST API, add routes, extractors, JSON handlers, shared state, middleware, error handling, WebSockets, graceful shutdown or tests with Axum, or to migrate older Axum code (0.6/0.7 path syntax) to 0.8.

Independent GitHub snapshot · Automated classification · Original documentation governs

Tterminalskills / skillsApache-2.0

Documentation de la Skill

GitHub

name: axum description: >- Builds HTTP APIs and web services in Rust with Axum, the Tokio team's web framework on top of Tokio, Hyper and Tower. Use when a user asks to create a Rust REST API, add routes, extractors, JSON handlers, shared state, middleware, error handling, WebSockets, graceful shutdown or tests with Axum, or to migrate older Axum code (0.6/0.7 path syntax) to 0.8. license: Apache-2.0 compatibility: "Axum 0.8 (current stable 0.8.9), Rust 1.80 or newer, Tokio 1.x; examples also use sqlx 0.9 and tower-http 0.7" metadata: author: terminal-skills version: "1.1.0" category: development tags: ["rust", "web-framework", "async", "tokio", "tower"] repository: https://github.com/tokio-rs/axum

Axum — Ergonomic Rust Web Framework

Overview

Axum is a web framework from the Tokio project. Handlers are plain async fns whose arguments are extractors (Path, Query, Json, State, headers) and whose return value implements IntoResponse. Routing is a Router; cross-cutting concerns (CORS, tracing, timeouts, compression, auth) are Tower layers, so everything in tower and tower-http works unchanged. Axum has no macro DSL and no runtime of its own: axum::serve runs a Router on a Tokio TcpListener.

Wrong extractor combinations are compile errors, but route paths are checked when the router is built: an invalid or overlapping path panics at startup, not at compile time.

Instructions

Project setup

cargo new shop-api && cd shop-api
cargo add axum@0.8 --features ws,macros
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json tracing tracing-subscriber
cargo add tower-http@0.7 --features cors,trace
cargo add sqlx@0.9 --features runtime-tokio,postgres,chrono
cargo add chrono --features serde
cargo add --dev tower --features util
cargo add --dev http-body-util

Default Axum features already include json, query, form, http1, tokio and tracing. ws (WebSockets), multipart, http2 and macros (#[debug_handler]) are opt-in. Migrations and offline query checking come from sqlx-cli (cargo install sqlx-cli).

Application, routes and state

Path captures use braces: /users/{id} and /files/{*rest}. The pre-0.8 forms /:id and /*rest panic at startup ("Path segments must not start with :"). Shared state must be Clone; a PgPool is already a cheap handle.

// src/main.rs (verified to compile and pass its tests with axum 0.8.9, sqlx 0.9.0, tower-http 0.7.1)
use axum::{
    Json, Router,
    extract::{Path, Query, Request, State, ws::{Message, WebSocket, WebSocketUpgrade}},
    http::{StatusCode, header},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::get,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use tower_http::{cors::CorsLayer, trace::TraceLayer};

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

fn app(state: AppState) -> Router {
    let protected = Router::new()
        .route("/users", get(list_users).post(create_user))
        .route("/users/{id}", get(get_user))
        .route_layer(middleware::from_fn(require_token));
    Router::new()
        .route("/health", get(|| async { "OK" }))
        .route("/ws", get(ws_handler))
        .merge(protected)
        .layer(CorsLayer::new())          // add allowed origins explicitly, see Guidelines
        .layer(TraceLayer::new_for_http())
        .with_state(state)
}

async fn shutdown_signal() {
    tokio::signal::ctrl_c().await.expect("failed to listen for ctrl-c");
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();
    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let db = PgPool::connect(&database_url).await.expect("cannot connect to Postgres");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app(AppState { db }))
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
}

tracing_subscriber::fmt::init() is the initialiser (there is no tracing_subscriber::init()). Without with_graceful_shutdown the server never drains connections on SIGINT/SIGTERM.

Handlers and extractors

Extractors run in argument order; the one that consumes the request body (Json, Form, String, Bytes) must be last.

#[derive(Serialize, sqlx::FromRow)]
struct User { id: i64, name: String, email: String, created_at: chrono::NaiveDateTime }

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

#[derive(Deserialize)]
struct ListParams { page: Option<u32>, per_page: Option<u32> }

async fn create_user(
    State(state): State<AppState>,
    Json(payload): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), AppError> {
    let user = sqlx::query_as::<_, User>(
        "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email, created_at",
    )
    .bind(payload.name)
    .bind(payload.email)
    .fetch_one(&state.db)
    .await?;
    Ok((StatusCode::CREATED, Json(user)))
}

async fn get_user(State(state): State<AppState>, Path(id): Path<i64>) -> Result<Json<User>, AppError> {
    let user = sqlx::query_as::<_, User>("SELECT id, name, email, created_at FROM users WHERE id = $1")
        .bind(id)
        .fetch_optional(&state.db)
        .await?
        .ok_or(AppError::NotFound)?;
    Ok(Json(user))
}

async fn list_users(
    State(state): State<AppState>,
    Query(params): Query<ListParams>,
) -> Result<Json<Vec<User>>, AppError> {
    let per_page = params.per_page.unwrap_or(20).min(100) as i64;
    let offset = (params.page.unwrap_or(1).max(1) as i64 - 1) * per_page;
    let users = sqlx::query_as::<_, User>(
        "SELECT id, name, email, created_at FROM users ORDER BY id LIMIT $1 OFFSET $2",
    )
    .bind(per_page)
    .bind(offset)
    .fetch_all(&state.db)
    .await?;
    Ok(Json(users))
}

sqlx::query_as::<_, T> with FromRow needs no database at compile time. The query!/query_as! macros check SQL against a live DATABASE_URL (or cached .sqlx/ data from cargo sqlx prepare) and fail the build without one. When a handler fails to compile with a baffling trait error, add #[axum::debug_handler] (feature macros) to get a readable message.

Error handling

enum AppError { NotFound, Unauthorized, Database(sqlx::Error) }

impl From<sqlx::Error> for AppError {
    fn from(e: sqlx::Error) -> Self { AppError::Database(e) }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "resource not found"),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
            AppError::Database(e) => {
                tracing::error!("database error: {e}");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal server error")
            }
        };
        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}

The ? operator converts through From, so handlers stay short. Log the real error, return a generic message.

Middleware

middleware::from_fn turns an async function into a layer; it receives the Request and Next. Use from_fn_with_state when it needs State, and route_layer so unmatched routes still return 404 instead of 401.

async fn require_token(req: Request, next: Next) -> Result<Response, AppError> {
    let token = req.headers().get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "));
    match token {
        Some(t) if !t.is_empty() && t == std::env::var("API_TOKEN").unwrap_or_default() => Ok(next.run(req).await),
        _ => Err(AppError::Unauthorized),
    }
}

Layer order: the layer added last runs first on the request. For several layers use tower::ServiceBuilder, which reads top to bottom.

WebSockets

async fn ws_handler(ws: WebSocketUpgrade) -> Response { ws.on_upgrade(echo) }

async fn echo(mut socket: WebSocket) {
    while let Some(Ok(Message::Text(text))) = socket.recv().await {
        if socket.send(Message::Text(text)).await.is_err() { break; }
    }
}

Needs the ws feature. In 0.8 Message::Text holds a Utf8Bytes value, not a String; convert with .as_str() or .into().

Testing without a network

Call the router as a Tower service with oneshot:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{body::Body, http::Request};
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    #[tokio::test]
    async fn health_returns_ok() {
        let db = PgPool::connect_lazy("postgres://localhost/unused").unwrap();
        let response = app(AppState { db })
            .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
            .await.unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let body = response.into_body().collect().await.unwrap().to_bytes();
        assert_eq!(&body[..], b"OK");
    }
}

Upgrading from 0.6 / 0.7

  • Paths: /:id becomes /{id}, /*rest becomes /{*rest} (0.8).
  • Custom extractors no longer use #[async_trait]; implement FromRequestParts with a plain async fn.
  • Option<T> as an extractor needs T: OptionalFromRequestParts; use Result<T, T::Rejection> to see why extraction failed.
  • Serve with axum::serve(listener, app); axum::Server (Hyper 0.14) is gone since 0.7.

Examples

Example 1: Create and call the API

User request: "Give me a small users API in Rust with Postgres and a bearer token."

export DATABASE_URL=postgres://shop:shop@127.0.0.1:5432/shop API_TOKEN=local-dev-token
cargo run &
curl -s -X POST localhost:3000/users -H 'Authorization: Bearer local-dev-token' \
  -H 'Content-Type: application/json' -d '{"name":"Maria Silva","email":"maria@shopfront.dev"}'
curl -s localhost:3000/users/1 -H 'Authorization: Bearer local-dev-token'
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/users

Result: the first call returns 201 with {"id":1,"name":"Maria Silva","email":"maria@shopfront.dev","created_at":"..."}, the second returns the same user, and the call without a token prints 401. A missing id returns 404 with {"error":"resource not found"}.

Example 2: Run the tests

User request: "Test the health route and the auth check without starting Postgres."

cargo test
# running 2 tests
# test tests::users_require_token ... ok
# test tests::health_returns_ok ... ok

PgPool::connect_lazy never opens a connection until a query runs, so route and middleware tests need no database.

Guidelines

  • Pin axum = "0.8" and use the matching axum-extra (0.12) and tower-http; mixing Axum with http/hyper major versions from other crates causes confusing trait errors.
  • Never use CorsLayer::permissive() outside local development; list origins with CorsLayer::new().allow_origin(...).
  • Keep blocking work (password hashing, large file IO, CPU loops) out of handlers or wrap it in tokio::task::spawn_blocking; it stalls the runtime thread otherwise.
  • Never hold a std::sync::Mutex guard across an .await; use tokio::sync::Mutex or restructure.
  • Limit request bodies: Axum caps Bytes/Json bodies at 2 MB by default; change it with DefaultBodyLimit.
  • Do not log secrets: TraceLayer logs URIs, so keep tokens in headers, not in query strings.
  • Do not hand-roll auth: use a vetted JWT/session crate and constant-time token comparison for anything beyond a local demo.
  • Pick Axum for new async Rust services; for a large existing Actix codebase, rewriting is rarely worth it.
Skills enregistrées