Axum es el framework web oficial del ecosistema Tokio. A diferencia de Actix-web, que tiene su propio runtime de actores, Axum está construido directamente sobre Tower y Hyper, lo que significa que comparte el mismo modelo de composición de middlewares que usa el resto de la comunidad async de Rust.
Para añadirlo a tu proyecto, edita Cargo.toml:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Un servidor mínimo en Axum arranca así:
use axum::{Router, routing::get};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(raiz));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn raiz() -> &'static str {
"Hola desde Axum"
}
El tipo Router es donde se definen las rutas. Cada ruta asocia un path y un método HTTP con un handler. Los handlers son funciones async que pueden devolver cualquier tipo que implemente el trait IntoResponse.
Definir rutas con get(), post(), delete()
Axum tiene métodos específicos para cada verbo HTTP: get(), post(), put(), patch(), delete() y head(). Si necesitas aceptar varios verbos en la misma ruta, usa routing::on() con un MethodFilter.
use axum::{
Router,
routing::{get, post, delete},
};
let app = Router::new()
.route("/usuarios", get(listar_usuarios).post(crear_usuario))
.route("/usuarios/:id", get(obtener_usuario).delete(eliminar_usuario))
.route("/usuarios/:id", put(actualizar_usuario));
Los segmentos dinámicos de la ruta se declaran con :nombre y se extraen en el handler con el extractor Path.
Extractors: leer datos de la petición
Los extractors son la forma en que Axum pasa datos de la petición HTTP al handler. Se declaran como argumentos de la función, y Axum los resuelve automáticamente en el orden en que aparecen.
Path: parámetros de ruta
use axum::extract::Path;
async fn obtener_usuario(Path(id): Path<u64>) -> String {
format!("Usuario con id {}", id)
}
// Para múltiples parámetros:
async fn obtener_post(
Path((user_id, post_id)): Path<(u64, u64)>
) -> String {
format!("Post {} del usuario {}", post_id, user_id)
}
Query: parámetros de query string
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct Paginacion {
pagina: Option<u32>,
por_pagina: Option<u32>,
}
async fn listar_usuarios(Query(params): Query<Paginacion>) -> String {
let pagina = params.pagina.unwrap_or(1);
let por_pagina = params.por_pagina.unwrap_or(20);
format!("Página {}, {} por página", pagina, por_pagina)
}
Json: cuerpo de la petición
use axum::{extract::Json, http::StatusCode};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CrearUsuario {
nombre: String,
email: String,
}
#[derive(Serialize)]
struct Usuario {
id: u64,
nombre: String,
email: String,
}
async fn crear_usuario(
Json(payload): Json<CrearUsuario>
) -> (StatusCode, Json<Usuario>) {
let usuario = Usuario {
id: 42,
nombre: payload.nombre,
email: payload.email,
};
(StatusCode::CREATED, Json(usuario))
}
Si el cuerpo no es JSON válido o no se puede deserializar al tipo esperado, Axum devuelve automáticamente un 422 con el mensaje de error.
Estado compartido con State
La mayoría de APIs necesitan compartir algún estado entre peticiones: una conexión a base de datos, una caché en memoria o una configuración. Axum lo resuelve con el extractor State.
use axum::{
extract::State,
Router,
routing::get,
};
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
type DB = Arc<RwLock<HashMap<u64, String>>>;
async fn listar(State(db): State<DB>) -> Json<Vec<String>> {
let usuarios = db.read().unwrap();
Json(usuarios.values().cloned().collect())
}
async fn crear(
State(db): State<DB>,
Json(nombre): Json<String>,
) -> StatusCode {
let mut usuarios = db.write().unwrap();
usuarios.insert(usuarios.len() as u64 + 1, nombre);
StatusCode::CREATED
}
#[tokio::main]
async fn main() {
let db: DB = Arc::new(RwLock::new(HashMap::new()));
let app = Router::new()
.route("/", get(listar).post(crear))
.with_state(db);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
El estado se envuelve en Arc para que pueda compartirse entre hilos, y en RwLock para permitir lecturas concurrentes con escrituras exclusivas. En producción querrás usar un pool de base de datos (como PgPool de SQLx) en lugar de un HashMap en memoria.
Manejo de errores con Result e IntoResponse
Los handlers de Axum pueden devolver Result<T, E> siempre que tanto T como E implementen IntoResponse. La forma más limpia es definir un tipo de error propio:
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
enum ApiError {
NoEncontrado,
Interno(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::NoEncontrado => {
(StatusCode::NOT_FOUND, "Recurso no encontrado").into_response()
}
ApiError::Interno(msg) => {
(StatusCode::INTERNAL_SERVER_ERROR, msg).into_response()
}
}
}
}
async fn obtener_usuario(
Path(id): Path<u64>,
State(db): State<DB>,
) -> Result<Json<String>, ApiError> {
let db = db.read().unwrap();
db.get(&id)
.cloned()
.map(Json)
.ok_or(ApiError::NoEncontrado)
}
Una API REST completa de ejemplo
Veamos todo junto en una API de usuarios con las cuatro operaciones básicas:
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Json, Response},
routing::{get, post, delete},
Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
type DB = Arc<RwLock<HashMap<u64, Usuario>>>;
#[derive(Clone, Serialize, Deserialize)]
struct Usuario {
id: u64,
nombre: String,
email: String,
}
#[derive(Deserialize)]
struct NuevoUsuario {
nombre: String,
email: String,
}
async fn listar(State(db): State<DB>) -> Json<Vec<Usuario>> {
let db = db.read().unwrap();
Json(db.values().cloned().collect())
}
async fn crear(
State(db): State<DB>,
Json(datos): Json<NuevoUsuario>,
) -> (StatusCode, Json<Usuario>) {
let mut db = db.write().unwrap();
let id = db.len() as u64 + 1;
let usuario = Usuario { id, nombre: datos.nombre, email: datos.email };
db.insert(id, usuario.clone());
(StatusCode::CREATED, Json(usuario))
}
async fn obtener(
Path(id): Path<u64>,
State(db): State<DB>,
) -> Result<Json<Usuario>, StatusCode> {
let db = db.read().unwrap();
db.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}
async fn eliminar(
Path(id): Path<u64>,
State(db): State<DB>,
) -> StatusCode {
let mut db = db.write().unwrap();
if db.remove(&id).is_some() { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND }
}
#[tokio::main]
async fn main() {
let db: DB = Arc::new(RwLock::new(HashMap::new()));
let app = Router::new()
.route("/usuarios", get(listar).post(crear))
.route("/usuarios/:id", get(obtener).delete(eliminar))
.with_state(db);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Escuchando en http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Capas y middleware básico
Antes de terminar, una mención al sistema de capas. Axum usa Tower para los middlewares: cualquier Layer compatible con Tower puede aplicarse a todo el router o a rutas específicas.
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/usuarios", get(listar))
.layer(TraceLayer::new_for_http());
Esto añade logging automático de todas las peticiones y respuestas. Puedes apilar tantas capas como necesites; se aplican de adentro hacia afuera en el orden en que las defines.
El siguiente paso natural es aprender a estructurar aplicaciones Axum con múltiples módulos, gestionar errores tipados con thiserror y compartir estado diferente por router anidado, que es lo que cubre el siguiente artículo de esta serie.
