Axum en Rust: servidor HTTP con handlers, extractors, middleware y Router

Axum es el framework web del ecosistema Tokio. Construido sobre Tower y Hyper, comparte el mismo modelo de middleware que el resto de la comunidad async de Rust y ofrece verificación de tipos en compilación para los handlers. Es la opción recomendada cuando ya se usa Tokio como runtime.

Router y handlers async básicos

Un servidor Axum se define con un Router que asocia rutas con handlers. Los handlers son funciones async que pueden devolver cualquier tipo que implemente IntoResponse. Axum infiere automáticamente qué extractors necesita cada handler a partir de los tipos de los argumentos.

// [dependencies]
// axum = "0.7"
// tokio = { version = "1", features = ["full"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"

use axum::{Router, routing::{get, post}, response::Json, http::StatusCode};
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Respuesta {
    mensaje: String,
    ok: bool,
}

async fn raiz() -> &'static str {
    "Hola desde Axum"
}

async fn estado() -> Json<Respuesta> {
    Json(Respuesta { mensaje: "servidor activo".into(), ok: true })
}

async fn no_encontrado() -> (StatusCode, &'static str) {
    (StatusCode::NOT_FOUND, "ruta no encontrada")
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(raiz))
        .route("/estado", get(estado))
        .fallback(no_encontrado);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Escuchando en http://0.0.0.0:3000");
    axum::serve(listener, app).await.unwrap();
}

Extractors: Path, Query y Json

Los extractors son los argumentos de un handler. Axum los resuelve automáticamente a partir de la petición HTTP. Los más usados son Path para segmentos de ruta dinámicos, Query para parámetros de query string y Json para el cuerpo de la petición.

use axum::{extract::{Path, Query}, response::Json};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct FiltroUsuarios {
    activos: Option<bool>,
    pagina: Option<u32>,
}

#[derive(Deserialize)]
struct NuevoUsuario {
    nombre: String,
    email: String,
}

#[derive(Serialize)]
struct Usuario {
    id: u64,
    nombre: String,
    email: String,
}

// GET /usuarios/:id
async fn obtener_usuario(Path(id): Path<u64>) -> Json<Usuario> {
    Json(Usuario { id, nombre: "Alice".into(), email: "[email protected]".into() })
}

// GET /usuarios?activos=true&pagina=2
async fn listar_usuarios(Query(filtro): Query<FiltroUsuarios>) -> Json<Vec<Usuario>> {
    println!("Página: {:?}, activos: {:?}", filtro.pagina, filtro.activos);
    Json(vec![
        Usuario { id: 1, nombre: "Alice".into(), email: "[email protected]".into() },
        Usuario { id: 2, nombre: "Bob".into(),   email: "[email protected]".into() },
    ])
}

// POST /usuarios
async fn crear_usuario(Json(body): Json<NuevoUsuario>) -> (axum::http::StatusCode, Json<Usuario>) {
    let usuario = Usuario { id: 42, nombre: body.nombre, email: body.email };
    (axum::http::StatusCode::CREATED, Json(usuario))
}

State: compartir estado entre handlers

Para compartir estado mutable entre handlers (conexiones de base de datos, caché, configuración) se usa el extractor State. El estado debe implementar Clone; para estado mutable se combina con Arc<Mutex<T>>.

use axum::{Router, routing::get, extract::State, response::Json};
use std::sync::{Arc, Mutex};
use serde::Serialize;

#[derive(Clone)]
struct AppState {
    contador: Arc<Mutex<u64>>,
    nombre_app: String,
}

#[derive(Serialize)]
struct EstadoRespuesta {
    visitas: u64,
    app: String,
}

async fn visitas(State(state): State<AppState>) -> Json<EstadoRespuesta> {
    let mut contador = state.contador.lock().unwrap();
    *contador += 1;
    Json(EstadoRespuesta {
        visitas: *contador,
        app: state.nombre_app.clone(),
    })
}

#[tokio::main]
async fn main() {
    let estado = AppState {
        contador: Arc::new(Mutex::new(0)),
        nombre_app: "Mi App Axum".into(),
    };

    let app = Router::new()
        .route("/visitas", get(visitas))
        .with_state(estado);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Middleware con Tower y un CRUD completo

Axum usa Tower como sistema de middleware. El middleware más habitual es el de logging, que se aplica con tower_http::trace::TraceLayer, y la gestión de CORS con tower_http::cors::CorsLayer.

use axum::{Router, routing::{get, post, put, delete}, middleware, response::IntoResponse, http::Request};
use tower_http::trace::TraceLayer;
use tower_http::cors::CorsLayer;

// Middleware personalizado: añadir header a todas las respuestas
async fn agregar_version<B>(
    req: Request<B>,
    next: middleware::Next<B>,
) -> impl IntoResponse {
    let mut resp = next.run(req).await;
    resp.headers_mut().insert(
        "X-App-Version",
        "1.0.0".parse().unwrap(),
    );
    resp
}

fn crear_router() -> Router {
    Router::new()
        .route("/usuarios",     get(listar_usuarios).post(crear_usuario))
        .route("/usuarios/:id", get(obtener_usuario).put(actualizar_usuario).delete(eliminar_usuario))
        .layer(middleware::from_fn(agregar_version))
        .layer(TraceLayer::new_for_http())
        .layer(CorsLayer::permissive())
}

async fn actualizar_usuario(Path(id): Path<u64>, Json(body): Json<NuevoUsuario>) -> Json<Usuario> {
    Json(Usuario { id, nombre: body.nombre, email: body.email })
}

async fn eliminar_usuario(Path(id): Path<u64>) -> axum::http::StatusCode {
    println!("Eliminando usuario {id}");
    axum::http::StatusCode::NO_CONTENT
}

La ventaja de Axum frente a otros frameworks es que el compilador verifica en tiempo de compilación que todos los extractors de un handler son correctos y que no hay conflictos de rutas. Los errores de configuración que en otros frameworks aparecen en runtime aquí se detectan antes de desplegar.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP