Cómo construir una API asíncrona en Rust con Tokio y Axum: tutorial paso a paso
En este tutorial veremos cómo construir una API REST asíncrona en Rust usando Tokio y Axum. El enfoque es práctico: estructura del proyecto, código completo (con manejo de concurrencia segura), pruebas básicas, despliegue mínimo y buenas prácticas para producción.
Por qué Tok io + Axum
- Tokio: runtime asíncrono maduro y eficiente.
- Axum: framework web ligero, modular y basado en tower, optimizado para Rust async.
- Encaja bien con el modelo de propiedad y seguridad de Rust para escribir servidores fiables.
Requisitos
- Rust toolchain (stable o nightly recomendado por algunas crates): rustup + cargo
- Conexión a Internet para descargar crates
- Opcional: Docker para contenerizar
Estructura del proyecto
/async-axum-api
├─ Cargo.toml
└─ src
├─ main.rs
├─ routes.rs
├─ handlers.rs
└─ model.rs
Se separan responsabilidades: routes.rs define rutas, handlers.rs implementa lógica y model.rs contiene estructuras y errores.
Cargo.toml (dependencias clave)
[package]
name = "async-axum-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
uuid = { version = "1", features = ["v4"] }
Explicación del enfoque
Usaremos un almacén en memoria (HashMap) protegido por tokio::sync::RwLock para demostrar concurrencia asíncrona sin añadir complejidad de una base de datos. Cada recurso tendrá un UUID como id. Este patrón es ideal para empezar y para pruebas; en producción sustituirás el store por un cliente de DB async (sqlx, sea-orm, etc.).
model.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Item {
pub id: Uuid,
pub name: String,
pub value: i64,
}
#[derive(Deserialize)]
pub struct CreateItem {
pub name: String,
pub value: i64,
}
#[derive(Deserialize)]
pub struct UpdateItem {
pub name: Option,
pub value: Option,
}
handlers.rs
use crate::model::{CreateItem, Item, UpdateItem};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
pub type Db = Arc>>;
pub async fn list_items(State(db): State) -> Json> {
let map = db.read().await;
let items = map.values().cloned().collect();
Json(items)
}
pub async fn create_item(State(db): State, Json(payload): Json) -> (StatusCode, Json- ) {
let item = Item {
id: Uuid::new_v4(),
name: payload.name,
value: payload.value,
};
db.write().await.insert(item.id, item.clone());
(StatusCode::CREATED, Json(item))
}
pub async fn get_item(State(db): State
, Path(id): Path) -> Result, StatusCode> {
let map = db.read().await;
map.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}
pub async fn update_item(State(db): State, Path(id): Path, Json(payload): Json) -> Result, StatusCode> {
let mut map = db.write().await;
let item = map.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
if let Some(name) = payload.name { item.name = name };
if let Some(value) = payload.value { item.value = value };
Ok(Json(item.clone()))
}
pub async fn delete_item(State(db): State, Path(id): Path) -> StatusCode {
let mut map = db.write().await;
if map.remove(&id).is_some() { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND }
}
routes.rs
use crate::handlers::*;
use crate::handlers::Db;
use axum::routing::{get, post, put, delete};
use axum::Router;
pub fn app(db: Db) -> Router {
Router::new()
.route("/items", get(list_items).post(create_item))
.route("/items/:id", get(get_item).put(update_item).delete(delete_item))
.with_state(db)
}
main.rs (arranque, logging y shutdown)
use axum::Server;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::signal;
use tokio::sync::RwLock;
use tracing_subscriber;
mod handlers;
mod model;
mod routes;
use handlers::Db;
#[tokio::main]
async fn main() {
// Logging básico configurado por variables de entorno
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let db: Db = Arc::new(RwLock::new(HashMap::new()));
let app = routes::app(db);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
tracing::info!("Starting server on {}", addr);
let server = Server::bind(&addr).serve(app.into_make_service());
// Graceful shutdown: espera SIGINT/SIGTERM
let graceful = server.with_graceful_shutdown(shutdown_signal());
if let Err(e) = graceful.await {
tracing::error!("server error: {}", e);
}
}
async fn shutdown_signal() {
let ctrl_c = async { signal::ctrl_c().await.expect("failed to listen for ctrl_c") };
#[cfg(unix)]
let terminate = async {
use tokio::signal::unix::{signal, SignalKind};
let mut stream = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
stream.recv().await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Signal received, starting graceful shutdown");
}
Probar la API
Ejecuta:
cargo run
Ejemplos con curl:
# Crear
curl -X POST -H "Content-Type: application/json" -d '{"name":"foo","value":42}' http://127.0.0.1:3000/items
# Listar
curl http://127.0.0.1:3000/items
# Obtener (reemplaza ID)
curl http://127.0.0.1:3000/items/
# Actualizar
curl -X PUT -H "Content-Type: application/json" -d '{"value":100}' http://127.0.0.1:3000/items/
# Borrar
curl -X DELETE http://127.0.0.1:3000/items/
Pruebas básicas
Puedes crear tests de integración con reqwest y ejecutar el binario en background o montar la lógica de rutas como servicio y testear las handlers directamente. Ejemplo rápido: crear tests que llamen a las funciones de handlers usando un Db en memoria.
Errores comunes y cómo evitarlos
- No bloquear el runtime: evita operaciones sincrónicas pesadas; si deben existir, muévelas a un spawn_blocking.
- Olvidar clonar/compartir correctamente el estado: usa Arc + RwLock o una abstracción de capa de datos para inyectar dependencias.
- Mala gestión de errores: mapear errores internos a códigos HTTP adecuados y no filtrar información sensible.
Seguridad y rendimiento
- Valida entradas y usa límites de tamaño (body limits) para evitar DoS por payloads grandes.
- Habilita TLS (ej.: a través de un proxy inverso o usando hyper+rustls en front).
- Para carga alta, reemplaza el store en memoria por una base de datos async y usa un pool de conexiones.
- Usa tracing y métricas (prometheus) para observabilidad.
Optimización rápida
- Construye binarios con
cargo build --releasey mide con herramientas (wrk, hey). - Activa LTO y optimizaciones en Cargo.toml para producción si necesitas reducir latencia.
- Evita clones innecesarios: pasa referencias cuando sea posible y usa tipos ligeros (SmallVec donde aplique).
Despliegue mínimo con Docker (opcional)
FROM rust:1.70 as builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:buster-slim
COPY --from=builder /app/target/release/async-axum-api /usr/local/bin/async-axum-api
EXPOSE 3000
CMD ["/usr/local/bin/async-axum-api"]
Próximos pasos recomendados
- Reemplaza el store en memoria por sqlx (Postgres) o sea-orm y aprende a manejar transacciones async.
- Agrega autenticación (JWT) e integración con OpenID Connect para APIs seguras.
- Instrumenta con tracing, exporta traces y métricas, y configura alertas.
Consejo avanzado: para APIs con alta concurrencia, mide contention en RwLock; si ves demasiados bloqueos, cambia a sharded locks o un diseño basado en actors/chanels según el patrón de acceso.
Advertencia: mantener un store en memoria es útil para prototipos y tests, pero no para datos críticos en producción; planifica migración a almacenamiento persistente y backups.
Siguiente paso: integra sqlx con migrations y añade autenticación/authorization para convertir este tutorial en una base sólida de producción.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación