Servidor web con rutas y JSON en Rust con axum

Con axum un servidor web con rutas y JSON cabe en treinta líneas. Es el framework más usado hoy y se apoya en tokio, el entorno asíncrono de Rust, así que este es el primer ejemplo de la serie con async.

Las dependencias

cargo add axum
cargo add tokio --features full
cargo add serde --features derive
[dependencies]
axum = "0.8.9"
tokio = { version = "1.53.1", features = ["full"] }
serde = { version = "1.0.229", features = ["derive"] }

Tres rutas: texto, JSON con parámetro y recepción de JSON

use axum::{extract::Path, routing::{get, post}, Json, Router};
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Articulo { id: u32, nombre: String, unidades: u32 }

#[derive(Deserialize)]
struct NuevoArticulo { nombre: String, unidades: u32 }

async fn raiz() -> &'static str { "inventario en marcha" }

async fn ver(Path(id): Path<u32>) -> Json<Articulo> {
    Json(Articulo { id, nombre: "tornillos".into(), unidades: 120 })
}

async fn crear(Json(nuevo): Json<NuevoArticulo>) -> Json<Articulo> {
    Json(Articulo { id: 99, nombre: nuevo.nombre, unidades: nuevo.unidades })
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Router::new()
        .route("/", get(raiz))
        .route("/articulo/{id}", get(ver))
        .route("/articulo", post(crear));

    let escucha = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    println!("escuchando en http://127.0.0.1:3000");
    axum::serve(escucha, app).await?;
    Ok(())
}

Arrancándolo con cargo run --release y consultándolo con curl desde otra terminal:

curl http://127.0.0.1:3000/inventario en marcha
curl http://127.0.0.1:3000/articulo/7{"id":7,"nombre":"tornillos","unidades":120}
curl -X POST http://127.0.0.1:3000/articulo -H 'Content-Type: application/json' -d '{"nombre":"clavos","unidades":500}'{"id":99,"nombre":"clavos","unidades":500}

Cómo encajan las piezas

Lo que hace legible a axum es que cada manejador declara en su firma lo que necesita y lo que devuelve, y el framework se encarga del resto.

Path(id): Path<u32> saca el trozo de la URL y lo convierte a número; si alguien pide /articulo/abc, axum responde un error sin que tú escribas nada. Json(nuevo): Json<NuevoArticulo> lee el cuerpo y lo convierte a tu estructura con serde, y rechaza la petición si el JSON no encaja. Y devolver Json<Articulo> serializa la respuesta y pone la cabecera correcta.

Las llaves en los parámetros de ruta. En axum 0.8 un parámetro se escribe /articulo/{id}. En las versiones anteriores se escribía /articulo/:id, con dos puntos, y esa forma ya no funciona. Es el cambio que más confunde al seguir tutoriales antiguos: el código compila pero la ruta nunca coincide.
Aquí aparece async por primera vez. Los manejadores son async fn y main lleva #[tokio::main], que monta el entorno de ejecución. Un servidor atiende muchas conexiones a la vez y casi todo su tiempo lo pasa esperando a la red, que es exactamente el caso en que la asincronía gana sobre los hilos de la lección de concurrencia: miles de conexiones en espera no necesitan miles de hilos.

Lo que viene después

Qué quieres Cómo
Parámetros de consulta Query<T> en la firma
Devolver un código concreto (StatusCode::CREATED, Json(x))
Compartir estado, como una base State<T> y .with_state(...)
Registro de peticiones, CORS capas de tower-http
Servir archivos estáticos ServeDir de tower-http
Agrupar rutas Router::nest("/api", otro)

El código de esta página se compiló y ejecutó con rustc 1.96.1, axum 0.8.9 y tokio 1.53.1 antes de publicarla; el servidor se arrancó de verdad y las tres respuestas están copiadas de peticiones reales con curl. Documentación oficial: axum en docs.rs.