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étodo | Ruta | Descripción | Código respuesta |
|---|---|---|---|
| POST | /tasks | Crear una tarea | 201 Created |
| GET | /tasks | Listar todas las tareas | 200 OK |
| GET | /tasks/{id} | Obtener una tarea por ID | 200 OK |
| PUT | /tasks/{id} | Actualizar una tarea | 200 OK |
| DELETE | /tasks/{id} | Eliminar una tarea | 204 No Content |
| GET | /health | Health check | 200 OK |
Además de los endpoints, incorpora:
- Estado compartido con
Arc<Mutex<Vec<Task>>>e inyección víaState - 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
AppErrorque 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:
| Crate | Versión | Para qué |
|---|---|---|
axum | 0.8 | Framework web: rutas, handlers, estado, middlewares |
tokio | 1 (features = [«full»]) | Runtime asíncrono. Imprescindible |
serde | 1 (features = [«derive»]) | Serializar/deserializar JSON |
serde_json | 1 | Trabajar con JSON cuando lo necesitas explícitamente |
tower-http | 0.6 (features = [«cors»]) | Middleware CORS para desarrollo |
tracing | 0.1 | Trazas estructuradas |
tracing-subscriber | 0.3 | Mostrar trazas en terminal |
chrono | 0.4 (features = [«serde»]) | Fechas y tiempos con serialización JSON |
uuid | 1 (features = [«v4»]) | Generar identificadores únicos |
anyhow | 1 | Errores 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.
falseal 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:titlees obligatorio (va sinOption),descriptiones opcional. El servidor valida quetitleno 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 deArcincrementa un contador; cuando todos los clones desaparecen, se libera la memoria.AppState: Una estructura que agrupa todo el estado de la aplicación. ImplementaClonepara que Axum pueda clonarla por cada petición. La clonación deArces 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.
| Tipo | Lecturas concurrentes | Bloquea hilo | Ideal para |
|---|---|---|---|
std::sync::Mutex | No (1 sola operación) | Sí | Secciones críticas muy cortas |
tokio::sync::Mutex | No (1 sola operación) | No (cede) | Secciones críticas lentas en código async |
std::sync::RwLock | Sí (múltiples lecturas) | Sí (solo escritura) | Muchas lecturas, pocas escrituras, críticas cortas |
tokio::sync::RwLock | Sí (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:
NotFound→404 NOT_FOUND. Cuando una tarea no existe.BadRequest→400 BAD_REQUEST. Cuando los datos de entrada son inválidos (título vacío, malformed JSON).Internal→500 INTERNAL_SERVER_ERROR. Cuando falla algo interno (el mutex se envenena, un error inesperado). Aquí es donde usamosanyhow::Error. Fíjate en que logueamos el error real contracing::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:
State(state): State<AppState>: El extractorStatede Axum inyecta el estado compartido. Debes haberlo pasado con.with_state(state)al construir el router.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 un422 Unprocessable Entityautomáticamente.- Validación manual:
payload.title.trim().to_string()elimina espacios al inicio y final. Si el resultado es vacío, devuelves un400 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. state.db.lock(): Bloquea el mutex y devuelve unMutexGuard. Si el mutex está envenenado, devuelve un error que conviertes enAppError::Internalconanyhow!.StatusCode::CREATED: Devuelves201 Created, el código correcto para una creación exitosa. No uses200 OKpara 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í:
Path(id): Path<String>: El extractorPathcaptura parámetros de la ruta. El nombreiddebe 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.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.ok_or_else: Si no encuentra la tarea, convierte elOptionen unResultcon el errorNotFound. Elok_or_elserecibe 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:
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.StatusCode::NO_CONTENT: La respuesta correcta para un DELETE exitoso. No lleva cuerpo. El204 No Contentes 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:
- Capturas información antes de ejecutar el handler (
start,method,uri) - Llamas a
next.run(request).awaitpara pasar el control al siguiente middleware o handler - Procesas la respuesta (
status,duration) - Haces algo con esa información (log, métricas, modificar la respuesta)
- 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 Createdy 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:
tracing_subscribercon filtro por variable de entorno. Si no hayRUST_LOG, usacrustaceo_tasks=info,tower_http=info. Esto significa que puedes cambiar el nivel de log sin recompilar:RUST_LOG=debug cargo run.Router::new().route(...)encadena rutas con métodos HTTP. El path/tasks/{id}captura el parámetroid. La sintaxis con llaves es la de Axum 0.8, que cambió respecto a versiones anteriores (antes era:idcon dos puntos).with_state(state)inyecta el estado en todos los handlers que usenState<AppState>.- 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íaState. 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::MutexyRwLockpara 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,
- Axum — Documentación oficial
- Axum — State extractor
- Axum — Middleware
- Tokio — Tutorial
- Tokio — sync::Mutex vs std::sync::Mutex
- Tower — Service trait y test harness
- Tower-http — CORS
- Anyhow — Errores contextualizados
- Tutorial Rust en atareao.es — guía completa del lenguaje
- Tutorial Self-Hosted en atareao.es — infraestructura para tus servicios
- Tutorial Docker en atareao.es — containeriza tus APIs
- Serde — Serialización en Rust
- UUID crate — Documentación
- Chrono crate — Documentación
- Rust by Example — Testing