9

APIs REST en Rust para Linuxeros

Vistas: 0
APIs REST en Rust para Linuxeros

Te voy a confesar una cosa. Cuando empecé con Rust, una de las cosas que más me costó fue pasar de scripts de Bash que modificaban un fichero de texto a construir una API REST de verdad. Porque en Bash todo es secuencial, todo es haz esto, luego esto otro. Pero una API recibe peticiones de golpe, de varios clientes a la vez, y tiene que mantener el tipo sin petar.

Durante años mi to-do list era un fichero de texto plano. Literalmente:

echo "- Comprar leche" >> ~/tareas.txt
cat ~/tareas.txt

Y vale, funcionaba. Pero no tenía autenticación, no tenía logging, no tenía errores coherentes. Cualquiera que llegara a mi máquina podía ver mis tareas. Menudo desastre de seguridad 😅

La cuestión es que en el capítulo anterior levantaste tu primer servidor HTTP con Axum. Expusiste información del sistema a través de una API. Fue un buen primer contacto, pero los endpoints eran de solo lectura: un GET aquí, otro GET allá. Las aplicaciones reales necesitan crear, modificar y eliminar datos. Necesitan autenticación, logging de cada petición, control de acceso y errores que tengan sentido para quien consume la API.

En este capítulo construyes crustaceo-tasks, una API de tareas (sí, un clásico to-do list) con CRUD completo, middlewares personalizados y estado compartido. Todo lo que aprendes aquí lo aplicas directamente a cualquier API REST que necesites montar en tu infraestructura self-hosted. Porque al final, da igual que sea un gestor de tareas, un panel de monitorización o un proxy de descargas: el patrón es el mismo.

¿Qué construyes?

crustaceo-tasks es una API REST con estos endpoints:

MétodoRutaDescripciónCódigo respuesta
POST/tasksCrear una tarea201 Created
GET/tasksListar todas las tareas200 OK
GET/tasks/{id}Obtener una tarea por ID200 OK
PUT/tasks/{id}Actualizar una tarea200 OK
DELETE/tasks/{id}Eliminar una tarea204 No Content
GET/healthHealth check200 OK

Además de los endpoints, incorpora:

  • Estado compartido con Arc<Mutex<Vec<Task>>> e inyección vía State
  • Middleware de logging que registra método, ruta, código HTTP y duración
  • Middleware de autenticación que valida una API Key en el header X-API-Key
  • Errores tipados con un enum AppError que devuelve código y mensaje
  • Validación de entrada con serde

Fíjate en que no uso base de datos. Nada de SQLite, PostgreSQL ni ficheros. Todo el almacenamiento es en memoria, con un simple Vec<Task> compartido entre peticiones. ¿Es esto producción? No exactamente. Pero te sorprendería lo lejos que puedes llegar con esta arquitectura para servicios internos con poco tráfico. Y en el próximo capítulo ya lo conectaremos a una base de datos de verdad.

Las crates del capítulo

Tu Cargo.toml necesita estas dependencias:

[package]
name = "crustaceo-tasks"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }

Desglosando:

CrateVersiónPara qué
axum0.8Framework web: rutas, handlers, estado, middlewares
tokio1 (features = [«full»])Runtime asíncrono. Imprescindible
serde1 (features = [«derive»])Serializar/deserializar JSON
serde_json1Trabajar con JSON cuando lo necesitas explícitamente
tower-http0.6 (features = [«cors»])Middleware CORS para desarrollo
tracing0.1Trazas estructuradas
tracing-subscriber0.3Mostrar trazas en terminal
chrono0.4 (features = [«serde»])Fechas y tiempos con serialización JSON
uuid1 (features = [«v4»])Generar identificadores únicos
anyhow1Errores contextualizados (lo usas en AppError::Internal)

La última dependencia, anyhow, la vamos a usar dentro del AppError::Internal para tener errores con contexto. Más adelante te enseño cómo.

Crea el proyecto:

cd ~
cargo new crustaceo-tasks
cd crustaceo-tasks

Edita Cargo.toml con el contenido de arriba.

Modelo de datos: la tarea

El corazón de la aplicación es la estructura Task. Representa una tarea con todos sus atributos:

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Task {
    id: String,
    title: String,
    description: String,
    completed: bool,
    created_at: DateTime<Utc>,
    updated_at: DateTime<Utc>,
}

Cada campo tiene un propósito:

  • id: Identificador único generado con UUID v4. No confíes en IDs autoincrementales en memoria; los UUIDs son únicos incluso si escalas a múltiples instancias. Si alguna vez tienes que migrar a PostgreSQL, los UUIDs que ya tienes siguen siendo válidos. Es una decisión de diseño que agradecerás más adelante.
  • title: El título de la tarea. Es el único campo obligatorio.
  • description: Descripción opcional. Por defecto, cadena vacía.
  • completed: Estado de la tarea. false al crearse.
  • created_at: Marca temporal de creación. No se modifica nunca, como las huellas dactilares.
  • updated_at: Marca temporal de la última modificación. Se actualiza en cada PUT.

Para las peticiones de entrada necesitas dos estructuras adicionales:

#[derive(Debug, Deserialize)]
struct CreateTaskRequest {
    title: String,
    description: Option<String>,
}

#[derive(Debug, Deserialize)]
struct UpdateTaskRequest {
    title: Option<String>,
    description: Option<String>,
    completed: Option<bool>,
}

Fíjate en las diferencias:

  • CreateTaskRequest: title es obligatorio (va sin Option), description es opcional. El servidor valida que title no esté vacío.
  • UpdateTaskRequest: todos los campos son opcionales. El cliente puede enviar solo los campos que quiere modificar. Esto se llama PATCH semantics aplicadas a PUT: no obligas al cliente a enviar el objeto completo.

La distinción es importante: cuando creas, necesitas un título; cuando actualizas, puedes cambiar solo el estado completed sin retocar el título. Y oye, si tuviéramos que enviar el objeto entero cada vez que queremos marcar una tarea como completada, la experiencia de usuario sería horrible.

Estado compartido con Arc<Mutex>

Las tareas se almacenan en memoria. Sin base de datos, sin ficheros. Un simple Vec<Task> envuelto en Arc<Mutex> para que sea seguro compartirlo entre peticiones concurrentes.

use std::sync::{Arc, Mutex};

type TaskDb = Arc<Mutex<Vec<Task>>>;

#[derive(Clone)]
struct AppState {
    db: TaskDb,
}

Desglosando:

  • Vec<Task>: El almacenamiento real. Un vector de tareas.
  • Mutex<Vec<Task>>: El mutex (mutual exclusion) garantiza que solo un hilo accede al vector a la vez. Sin mutex, dos peticiones simultáneas podrían corromper los datos. Y no me preguntes cómo lo sé. Bueno sí, te lo cuento: una vez se me ocurrió hacer un contador de visitas sin mutex y acabé con números negativos. Qué torpeza la mía 😅
  • Arc<Mutex<Vec<Task>>>: Arc (Atomic Reference Counting, contador atómico de referencias) permite que múltiples propietarios compartan el mismo mutex. Cada clon de Arc incrementa un contador; cuando todos los clones desaparecen, se libera la memoria.
  • AppState: Una estructura que agrupa todo el estado de la aplicación. Implementa Clone para que Axum pueda clonarla por cada petición. La clonación de Arc es barata: solo incrementa un contador.

En el main creas el estado y lo pasas al router:

let state = AppState {
    db: Arc::new(Mutex::new(Vec::new())),
};

Mutex: lo bueno, lo malo y las alternativas

Mutex de std::sync es el mecanismo más básico de Rust para exclusión mutua. Es simple, directo y suficiente para este proyecto. Pero tiene limitaciones:

  • Bloquea el hilo actual: si una petición tarda en completarse, todas las demás esperan. En un servidor web, eso significa que un cliente lento puede bloquear a todos los demás.
  • Puede causar panics: si un hilo bloquea el mutex y entra en panic, el mutex se envenena. Cualquier intento posterior de bloquearlo devuelve un error. Es como si un camarero dejara caer la bandeja y nadie más pudiera recoger los platos.
  • No es asíncrono: lock() bloquea el hilo. En un servidor asíncrono como Axum, esto puede afectar al rendimiento bajo carga alta por el blocking del threadpool.

Para este tutorial es perfecto. Pero te tengo que hablar de las alternativas, porque en producción probablemente quieras otra cosa.

tokio::sync::Mutex

tokio::sync::Mutex es la versión asíncrona del mutex. En lugar de lock(), usas .lock().await, que cede el control al runtime de Tokio mientras espera. Esto significa que si una tarea está esperando el mutex, el hilo puede atender otras peticiones mientras tanto.

use tokio::sync::Mutex;

type TaskDb = Arc<Mutex<Vec<Task>>>;

async fn list_tasks(state: State<AppState>) -> Result<Json<Vec<Task>>, AppError> {
    let db = state.db.lock().await;
    Ok(Json(db.clone()))
}

La diferencia clave: con std::sync::Mutex, el hilo se queda parado haciendo spinning hasta que consigue el lock. Con tokio::sync::Mutex, el hilo se pone a hacer otras cosas mientras espera.

¿Cuándo usar cada uno? std::sync::Mutex es más rápido si la sección crítica es muy corta (microsegundos). tokio::sync::Mutex es mejor si la sección crítica puede tardar (milisegundos o más). En nuestro caso, clonar un vector de tareas es rapidísimo, así que std::sync::Mutex va perfecto.

RwLock: cuando lees mucho y escribes poco

RwLock (Reader-Writer Lock, bloqueo de lector-escritor) va un paso más allá. Permite múltiples lecturas simultáneas, pero solo una escritura a la vez. Piensa en una biblioteca: mucha gente puede leer a la vez, pero si alguien está escribiendo en la pizarra, los demás esperan.

use std::sync::RwLock;

type TaskDb = Arc<RwLock<Vec<Task>>>;

async fn list_tasks(state: State<AppState>) -> Result<Json<Vec<Task>>, AppError> {
    let db = state.db.read().map_err(|e| {
        AppError::Internal(format!("Error de concurrencia: {}", e))
    })?;
    Ok(Json(db.clone()))
}

async fn create_task(
    State(state): State<AppState>,
    Json(payload): Json<CreateTaskRequest>,
) -> Result<(StatusCode, Json<Task>), AppError> {
    // ...validación...
    let mut db = state.db.write().map_err(|e| {
        AppError::Internal(format!("Error de concurrencia: {}", e))
    })?;
    // ...insertar tarea...
}

Fíjate en que list_tasks usa .read() (múltiples lecturas simultáneas) y create_task usa .write() (solo una escritura, espera a que terminen las lecturas). En una API REST típica, las lecturas son muchísimo más frecuentes que las escrituras. La gente lista tareas constantemente pero solo de vez en cuando crea una. RwLock optimiza precisamente ese caso de uso.

¿Y tokio::sync::RwLock? Existe también, con la misma filosofía: versión asíncrona que cede el hilo mientras espera.

TipoLecturas concurrentesBloquea hiloIdeal para
std::sync::MutexNo (1 sola operación)Secciones críticas muy cortas
tokio::sync::MutexNo (1 sola operación)No (cede)Secciones críticas lentas en código async
std::sync::RwLockSí (múltiples lecturas)Sí (solo escritura)Muchas lecturas, pocas escrituras, críticas cortas
tokio::sync::RwLockSí (múltiples lecturas)No (cede)Muchas lecturas, pocas escrituras, críticas lentas

Para nuestro proyecto nos quedamos con std::sync::Mutex. Es simple, directo, y para un Vec<Task> con unas decenas de elementos va sobrado. Cuando tu API tenga miles de peticiones por segundo, ya mirarás alternativas.

Manejo de errores tipado

Uno de los puntos débiles del capítulo anterior era que todos los errores se devolvían como 500 Internal Server Error. Vamos, que daba igual lo que pasara: siempre 500. Aquí vas a construir un sistema de errores tipado que devuelve el código HTTP adecuado y un mensaje descriptivo.

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response, Json},
};
use anyhow::anyhow;

#[derive(Debug)]
enum AppError {
    NotFound(String),
    BadRequest(String),
    Internal(anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
            AppError::Internal(err) => {
                // Log del error real para depuración
                tracing::error!("Error interno: {:?}", err);
                (StatusCode::INTERNAL_SERVER_ERROR, format!("Error interno del servidor"))
            }
        };

        let body = Json(serde_json::json!({
            "error": message,
            "code": status.as_u16(),
        }));

        (status, body).into_response()
    }
}

Cada variante del enum se mapea a un código HTTP diferente:

  • NotFound404 NOT_FOUND. Cuando una tarea no existe.
  • BadRequest400 BAD_REQUEST. Cuando los datos de entrada son inválidos (título vacío, malformed JSON).
  • Internal500 INTERNAL_SERVER_ERROR. Cuando falla algo interno (el mutex se envenena, un error inesperado). Aquí es donde usamos anyhow::Error. Fíjate en que logueamos el error real con tracing::error! antes de devolver un mensaje genérico al cliente. No queremos filtrar detalles internos en la respuesta, pero sí queremos poder depurarlos.

El método into_response construye una tupla (StatusCode, Json<Value>) que Axum sabe convertir en una respuesta HTTP con el cuerpo JSON y el código correcto.

La macro anyhow! te permite crear errores con contexto fácilmente:

use anyhow::anyhow;

let err = AppError::Internal(anyhow!("No se pudo conectar a la base de datos: {}", detalle));

Esto permite que los handlers devuelvan Result<T, AppError> y el framework se encargue de convertir el error en una respuesta HTTP adecuada. Sin unwrap(), sin pánicos, sin errores 500 genéricos.

Handlers: el CRUD completo

Crear tarea (POST /tasks)

async fn create_task(
    State(state): State<AppState>,
    Json(payload): Json<CreateTaskRequest>,
) -> Result<(StatusCode, Json<Task>), AppError> {
    let title = payload.title.trim().to_string();
    if title.is_empty() {
        return Err(AppError::BadRequest(
            "El campo 'title' es obligatorio y no puede estar vacío".into(),
        ));
    }

    let task = Task {
        id: Uuid::new_v4().to_string(),
        title,
        description: payload.description.unwrap_or_default(),
        completed: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    };

    let mut db = state.db.lock().map_err(|e| {
        AppError::Internal(anyhow!("Error de concurrencia: {}", e))
    })?;

    let response = task.clone();
    db.push(task);

    Ok((StatusCode::CREATED, Json(response)))
}

Puntos importantes:

  1. State(state): State<AppState>: El extractor State de Axum inyecta el estado compartido. Debes haberlo pasado con .with_state(state) al construir el router.
  2. Json(payload): Json<CreateTaskRequest>: Axum deserializa automáticamente el cuerpo de la petición JSON en la estructura. Si el JSON es inválido, devuelve un 422 Unprocessable Entity automáticamente.
  3. Validación manual: payload.title.trim().to_string() elimina espacios al inicio y final. Si el resultado es vacío, devuelves un 400 BadRequest. Axum no valida contenido, solo estructura. Y créeme, no hay nada peor que una tarea con título invisible que no puedes borrar.
  4. state.db.lock(): Bloquea el mutex y devuelve un MutexGuard. Si el mutex está envenenado, devuelve un error que conviertes en AppError::Internal con anyhow!.
  5. StatusCode::CREATED: Devuelves 201 Created, el código correcto para una creación exitosa. No uses 200 OK para creaciones, que luego viene un purista del HTTP y te lo discute.

Listar tareas (GET /tasks)

async fn list_tasks(
    State(state): State<AppState>,
) -> Result<Json<Vec<Task>>, AppError> {
    let db = state.db.lock().map_err(|e| {
        AppError::Internal(anyhow!("Error de concurrencia: {}", e))
    })?;

    Ok(Json(db.clone()))
}

El handler más simple. Bloquea el mutex, clona el vector completo y lo devuelve como JSON.

Para una API real esto no escala: si tienes 100.000 tareas, clonar el vector en cada petición es caro. Pero para almacenamiento en memoria con pocos elementos, es perfecto. Mi to-do list personal nunca ha pasado de 50 tareas, y la tuya tampoco, seamos sinceros 😊

Obtener tarea (GET /tasks/{id})

async fn get_task(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Json<Task>, AppError> {
    let db = state.db.lock().map_err(|e| {
        AppError::Internal(anyhow!("Error de concurrencia: {}", e))
    })?;

    let task = db
        .iter()
        .find(|t| t.id == id)
        .cloned()
        .ok_or_else(|| AppError::NotFound(format!("Tarea {} no encontrada", id)))?;

    Ok(Json(task))
}

Tres novedades aquí:

  1. Path(id): Path<String>: El extractor Path captura parámetros de la ruta. El nombre id debe coincidir con el parámetro {id} en la ruta. Si no coincide, Axum se queja en compilación. Me encanta cuando el compilador me salva de mí mismo.
  2. db.iter().find(|t| t.id == id): Busca la tarea por ID. Como es una búsqueda lineal O(n), para vectores pequeños es aceptable. Con 50 tareas, la diferencia es imperceptible.
  3. ok_or_else: Si no encuentra la tarea, convierte el Option en un Result con el error NotFound. El ok_or_else recibe un closure que solo se ejecuta si hay error, evitando la creación innecesaria del mensaje.

Actualizar tarea (PUT /tasks/{id})

async fn update_task(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(payload): Json<UpdateTaskRequest>,
) -> Result<Json<Task>, AppError> {
    let mut db = state.db.lock().map_err(|e| {
        AppError::Internal(anyhow!("Error de concurrencia: {}", e))
    })?;

    let task = db
        .iter_mut()
        .find(|t| t.id == id)
        .ok_or_else(|| AppError::NotFound(format!("Tarea {} no encontrada", id)))?;

    if let Some(title) = payload.title {
        let trimmed = title.trim().to_string();
        if trimmed.is_empty() {
            return Err(AppError::BadRequest(
                "El campo 'title' no puede estar vacío".into(),
            ));
        }
        task.title = trimmed;
    }

    if let Some(description) = payload.description {
        task.description = description;
    }

    if let Some(completed) = payload.completed {
        task.completed = completed;
    }

    task.updated_at = Utc::now();

    Ok(Json(task.clone()))
}

La actualización es el handler más complejo. Usas iter_mut() en lugar de iter() porque necesitas modificar la tarea in situ. Con if let Some(valor) actualizas solo los campos que el cliente envió. Es un patrón muy común en APIs REST con PUT parcial.

Fíjate en que también validamos el título vacío en la actualización, no solo en la creación. Porque si alguien intenta poner un título vacío a una tarea existente, no debería poder hacerlo.

Eliminar tarea (DELETE /tasks/{id})

async fn delete_task(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
    let mut db = state.db.lock().map_err(|e| {
        AppError::Internal(anyhow!("Error de concurrencia: {}", e))
    })?;

    let len_before = db.len();
    db.retain(|t| t.id != id);

    if db.len() == len_before {
        return Err(AppError::NotFound(format!("Tarea {} no encontrada", id)));
    }

    Ok(StatusCode::NO_CONTENT)
}

Dos cosas clave:

  1. Vec::retain: Elimina todos los elementos que no cumplan el predicado. Es más eficiente que buscar y eliminar por índice. Es como pasar una aspiradora: te quedas solo con lo que quieres conservar.
  2. StatusCode::NO_CONTENT: La respuesta correcta para un DELETE exitoso. No lleva cuerpo. El 204 No Content es el código HTTP que indica se ha hecho, pero no te devuelvo nada.

Middleware personalizado

Axum permite añadir capas de middleware que se ejecutan antes o después de los handlers. Cada middleware recibe la petición, hace algo, y pasa el control al siguiente middleware o al handler final.

Middleware de logging

Quieres registrar cada petición con su método, ruta, código de respuesta y duración. Necesitas medir el tiempo antes de pasar la petición y calcularlo después de recibir la respuesta.

use std::time::Instant;

async fn logging_middleware(
    request: Request,
    next: Next,
) -> Response {
    let start = Instant::now();
    let method = request.method().clone();
    let uri = request.uri().clone();

    let response = next.run(request).await;

    let status = response.status();
    let duration = start.elapsed();

    tracing::info!(
        method = %method,
        path = %uri,
        status = %status.as_u16(),
        duration_ms = duration.as_millis(),
        "{} {} -> {} ({}ms)",
        method, uri, status, duration.as_millis(),
    );

    response
}

El patrón es siempre el mismo:

  1. Capturas información antes de ejecutar el handler (start, method, uri)
  2. Llamas a next.run(request).await para pasar el control al siguiente middleware o handler
  3. Procesas la respuesta (status, duration)
  4. Haces algo con esa información (log, métricas, modificar la respuesta)
  5. Devuelves la respuesta

Las funciones de middleware tienen la firma async fn(Request, Next) -> Response. Next representa el siguiente middleware o el handler final.

Y oye, esto es oro puro para depurar. Cuando tu API empiece a ir lenta y no sepas por qué, estos logs te dicen exactamente qué ruta está tardando. Te lo digo por experiencia: he pasado horas depurando porque no tenía logging.

Middleware de autenticación

El middleware de autenticación valida que la petición incluya una API Key en el header X-API-Key. Si la clave es correcta, pasa la petición. Si no, devuelve 401 Unauthorized.

const API_KEY: &str = "crustaceo-secreto-2026";

async fn auth_middleware(
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let api_key = request
        .headers()
        .get("X-API-Key")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    if api_key == API_KEY {
        Ok(next.run(request).await)
    } else {
        Err(StatusCode::UNAUTHORIZED)
    }
}

Aquí la firma devuelve Result<Response, StatusCode>. Si el resultado es un error, Axum lo convierte automáticamente en una respuesta HTTP con ese código. No necesitas construir el JSON manualmente.

La cadena de métodos headers().get().and_then().unwrap_or() es un patrón muy común en Rust: intentas obtener el header, lo conviertes a &str si es posible, y si fallas devuelves cadena vacía. Luego comparas con la clave esperada.

StatusCode::UNAUTHORIZED es el 401. Axum implementa IntoResponse para StatusCode, así que puedes usarlo directamente como error.

Cómo se aplican los middlewares

En Axum, los middlewares se aplican con .layer(). El orden importa: el último layer añadido es el primero en ejecutarse.

let app = Router::new()
    .route("/health", get(health))
    .route("/tasks", get(list_tasks).post(create_task))
    .route("/tasks/{id}", get(get_task).put(update_task).delete(delete_task))
    .with_state(state)
    .layer(middleware::from_fn(auth_middleware))
    .layer(middleware::from_fn(logging_middleware))
    .layer(tower_http::cors::CorsLayer::permissive());

El flujo de una petición es:

Cliente → CORS → Logging → Auth → Handler → Auth → Logging → CORS → Cliente

La petición atraviesa los layers de fuera a dentro: CORS primero, luego logging, luego auth, y finalmente el handler. La respuesta viaja en sentido inverso.

¿Por qué logging antes que auth? Porque quieres registrar también las peticiones rechazadas por auth. Si pones auth primero, las peticiones sin API Key no llegarían al logger. Y cuando estás depurando por qué alguien no consigue autenticarse, esos logs son tu único testigo.

CORS

El layer CorsLayer::permissive() permite peticiones desde cualquier origen. Es útil en desarrollo, cuando el frontend corre en otro puerto. En producción lo ajustas para permitir solo tus dominios:

use tower_http::cors::CorsLayer;

let cors = CorsLayer::new()
    .allow_origin("https://mifrontend.com".parse::<HeaderValue>().unwrap());

Tests con el test harness de Axum

Y ahora llega una de las partes que más me gustan de Axum: el test harness. El framework proporciona un oneshot que te permite enviar peticiones directamente al router sin necesidad de levantar un servidor HTTP. No más scripts de curl para pruebas manuales.

Para poder testear, necesitas añadir tower a tus dependencias de desarrollo:

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }

La idea es crear una función que construya el router de pruebas, y luego enviar peticiones HTTP directamente. Así:

use tower::ServiceExt;

fn test_app() -> Router {
    let state = AppState {
        db: Arc::new(Mutex::new(Vec::new())),
    };

    Router::new()
        .route("/tasks", get(list_tasks).post(create_task))
        .route("/tasks/{id}", get(get_task).put(update_task).delete(delete_task))
        .with_state(state)
}

async fn send_request(
    app: Router,
    method: Method,
    path: &str,
    body: Option<&str>,
) -> Response {
    let mut builder = HttpRequest::builder()
        .method(method)
        .uri(path)
        .header("Content-Type", "application/json");

    let req = if let Some(b) = body {
        builder.body(Body::from(b.to_string())).unwrap()
    } else {
        builder.body(Body::empty()).unwrap()
    };

    app.oneshot(req).await.unwrap()
}

El método oneshot de tower::ServiceExt convierte el router en un servicio que acepta una petición y devuelve una respuesta. No hay puertos, no hay bindings, no hay localhost. Directo al grano.

Y ahora los tests:

#[tokio::test]
async fn test_create_task() {
    let app = test_app();

    let response = send_request(
        app,
        Method::POST,
        "/tasks",
        Some(r#"{"title": "Test task", "description": "A test"}"#),
    ).await;

    assert_eq!(response.status(), StatusCode::CREATED);

    let body: Task = serde_json::from_slice(
        &axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap()
    ).unwrap();

    assert!(!body.id.is_empty());
    assert_eq!(body.title, "Test task");
    assert_eq!(body.description, "A test");
    assert!(!body.completed);
}

#[tokio::test]
async fn test_create_task_empty_title() {
    let app = test_app();

    let response = send_request(
        app,
        Method::POST,
        "/tasks",
        Some(r#"{"title": "   "}"#),
    ).await;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn test_get_task_not_found() {
    let app = test_app();

    let response = send_request(
        app, Method::GET, "/tasks/nonexistent-id", None
    ).await;

    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

Estos tests cubren:

  • Creación exitosa: verifica que devuelve 201 Created y que los campos son correctos.
  • Validación de título vacío: verifica que devuelve 400 Bad Request.
  • Tarea no encontrada: verifica que devuelve 404 Not Found.
  • Ciclo CRUD completo: crea, obtiene, actualiza y elimina una tarea, verificando cada paso.

Puedes ver el código completo de los tests en el gist que he preparado. Tiene más de 200 líneas con tests para cada endpoint y el ciclo completo.

Para ejecutar los tests:

cargo test

Y verás algo como:

running 6 tests
test test_create_task ... ok
test test_create_task_empty_title ... ok
test test_get_task_not_found ... ok
test test_list_tasks_empty ... ok
test test_list_tasks_with_data ... ok
test test_crud_cycle ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Seis tests, seis verdes. Y lo mejor es que estos tests son rápidísimos porque no levantan ningún servidor TCP. El test harness de Axum es una maravilla.

El main completo

Aquí está el main que conecta todo en un solo archivo src/main.rs. Como el código completo es extenso, lo he dejado en un gist para que puedas acceder a él cómodamente:

Pero vamos a repasar las partes clave del main:

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "crustaceo_tasks=info,tower_http=info".into()),
        )
        .init();

    let state = AppState {
        db: Arc::new(Mutex::new(Vec::new())),
    };

    let app = Router::new()
        .route("/health", get(health))
        .route("/tasks", get(list_tasks).post(create_task))
        .route("/tasks/{id}", get(get_task).put(update_task).delete(delete_task))
        .with_state(state)
        .layer(middleware::from_fn(auth_middleware))
        .layer(middleware::from_fn(logging_middleware))
        .layer(tower_http::cors::CorsLayer::permissive());

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    println!("🦀 crustaceo-tasks escuchando en http://0.0.0.0:3000");
    println!("🔑 API Key: {}", API_KEY);

    axum::serve(listener, app).await.unwrap();
}

Puntos a destacar:

  1. tracing_subscriber con filtro por variable de entorno. Si no hay RUST_LOG, usa crustaceo_tasks=info,tower_http=info. Esto significa que puedes cambiar el nivel de log sin recompilar: RUST_LOG=debug cargo run.
  2. Router::new().route(...) encadena rutas con métodos HTTP. El path /tasks/{id} captura el parámetro id. La sintaxis con llaves es la de Axum 0.8, que cambió respecto a versiones anteriores (antes era :id con dos puntos).
  3. with_state(state) inyecta el estado en todos los handlers que usen State<AppState>.
  4. Los layers se aplican de abajo arriba: CORS (más externo), logging, auth (más interno, más cerca del handler).

Verificación

Compilar

cd ~/crustaceo-tasks
cargo build

La primera compilación descarga todas las dependencias y compila. Las siguientes son casi instantáneas porque las dependencias ya están en cache.

Ejecutar

cargo run

Verás las trazas de tracing-subscriber y el mensaje de bienvenida:

🦀 crustaceo-tasks escuchando en http://0.0.0.0:3000
🔑 API Key: crustaceo-secreto-2026

Probar con curl

Health check (sin autenticación: la ruta /health está fuera del middleware de auth)

curl -s http://localhost:3000/health | python3 -m json.tool

Respuesta:

{
    "service": "crustaceo-tasks",
    "status": "ok"
}

Crear tarea (POST /tasks)

curl -s -X POST http://localhost:3000/tasks \
  -H "X-API-Key: crustaceo-secreto-2026" \
  -H "Content-Type: application/json" \
  -d '{"title": "Aprender Rust con Axum", "description": "Completar el tutorial de APIs REST"}' | python3 -m json.tool

Respuesta:

{
    "id": "5044031b-a444-43e8-8143-dd96520c4b69",
    "title": "Aprender Rust con Axum",
    "description": "Completar el tutorial de APIs REST",
    "completed": false,
    "created_at": "2026-06-24T07:15:18.847572926Z",
    "updated_at": "2026-06-24T07:15:18.847574882Z"
}

Copia el id de la respuesta para las siguientes pruebas.

Listar tareas (GET /tasks)

curl -s http://localhost:3000/tasks \
  -H "X-API-Key: crustaceo-secreto-2026" | python3 -m json.tool

Obtener tarea por ID (GET /tasks/{id})

curl -s http://localhost:3000/tasks/5044031b-a444-43e8-8143-dd96520c4b69 \
  -H "X-API-Key: crustaceo-secreto-2026" | python3 -m json.tool

Actualizar tarea (PUT /tasks/{id})

curl -s -X PUT http://localhost:3000/tasks/5044031b-a444-43e8-8143-dd96520c4b69 \
  -H "X-API-Key: crustaceo-secreto-2026" \
  -H "Content-Type: application/json" \
  -d '{"title": "Axum dominado", "completed": true}' | python3 -m json.tool

Respuesta:

{
    "id": "5044031b-a444-43e8-8143-dd96520c4b69",
    "title": "Axum dominado",
    "description": "Completar el tutorial de APIs REST",
    "completed": true,
    "created_at": "2026-06-24T07:15:18.847572926Z",
    "updated_at": "2026-06-24T07:15:31.953153905Z"
}

Fíjate en que updated_at cambió pero created_at se mantiene. Así debe ser.

Eliminar tarea (DELETE /tasks/{id})

curl -s -o /dev/null -w "HTTP %{http_code}\n" -X DELETE \
  http://localhost:3000/tasks/5044031b-a444-43e8-8143-dd96520c4b69 \
  -H "X-API-Key: crustaceo-secreto-2026"

Respuesta: HTTP 204

Verificar errores

Tarea no encontrada (404):

curl -s http://localhost:3000/tasks/no-existe \
  -H "X-API-Key: crustaceo-secreto-2026" | python3 -m json.tool
{
    "code": 404,
    "error": "Tarea no-existe no encontrada"
}

Sin autenticación (401):

curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:3000/tasks

Respuesta: HTTP 401

Validación de título vacío (400):

curl -s -X POST http://localhost:3000/tasks \
  -H "X-API-Key: crustaceo-secreto-2026" \
  -H "Content-Type: application/json" \
  -d '{"title": "   ", "description": "test"}' | python3 -m json.tool
{
    "code": 400,
    "error": "El campo 'title' es obligatorio y no puede estar vacío"
}

Lo que hemos visto

En este capítulo has dado un salto enorme respecto al anterior:

  • CRUD completo con POST, GET, PUT, DELETE y los códigos HTTP correctos para cada operación.
  • Estado compartido con Arc<Mutex<T>> e inyección vía State. Ahora tus handlers pueden compartir datos sin variables globales.
  • Middlewares personalizados con middleware::from_fn: logging que registra cada petición, autenticación por API Key, y CORS para desarrollo.
  • Errores tipados con un enum que se mapea a códigos HTTP diferentes. Ya no devuelves 500 para todo.
  • Validación de entrada con serde y trim, combinando la deserialización automática con validaciones manuales.
  • UUIDs para identificar recursos de forma única y distribuida.
  • Tests de integración con el test harness de Axum, sin levantar servidores.
  • Comparativa entre std::sync::Mutex, tokio::sync::Mutex y RwLock para elegir el mecanismo de concurrencia adecuado.

Has construido crustaceo-tasks, una API REST funcional con almacenamiento en memoria, middlewares y manejo de errores consistente. Es el mismo patrón que usan APIs en producción, solo que aquí el almacenamiento es un Vec<Task> en lugar de PostgreSQL. Y ahora, cuando alguien te pregunte ¿sabes hacer APIs REST en Rust? puedes decir que sí.

Vale, no es producción todavía. Pero el andamio está montado. Los endpoints funcionan, los errores son coherentes, las peticiones se loguean, y todo está autenticado. Lo único que falta es la persistencia. Y eso llega en el próximo capítulo.

Lo que viene

En el próximo capítulo dejarás atrás la memoria volátil y conectarás tu API a una base de datos real. Aprenderás a usar SQLite con rusqlite, migraciones, consultas parametrizadas y cómo estructurar una aplicación Rust con capas bien separadas. Tu crustaceo-tasks pasará de ser un juguete en memoria a una API con persistencia real. Y lo mejor: tus tests de integración te asegurarán que todo sigue funcionando después del cambio.


Más información,

Deja una respuesta