Cómo construir una API asíncrona en Rust con Tokio y Axum: tutorial paso a paso

rust Cómo construir una API asíncrona en Rust con Tokio y Axum: tutorial paso a paso

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 --release y 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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión