Crear REST API con Axum en Rust

1. Descripción general

Axum es un framework web del equipo de Tokio. Se construye sobre hyper y tower, utilizando el sistema de tipos de Rust para verificar los handlers en tiempo de compilación.

En este artículo, escribirás una REST API para gestionar pedidos (Order) con todas las operaciones CRUD. También cubriremos cómo compartir estado entre handlers, retornar errores estructurados en JSON y escribir pruebas para el router sin necesidad de abrir puertos de red.

El código utiliza Axum 0.8 y Rust edition 2021 o superior.

2. Configuración del proyecto

Crea un nuevo proyecto:

cargo new order-api
cd order-api

Agrega las dependencias a Cargo.toml:

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

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

Función de cada crate:

  • axum: router, extractors y tipos de response.
  • tokio: async runtime. Axum requiere ejecutarse sobre Tokio.
  • serde y serde_json: conversión de structs a JSON y viceversa.
  • tower y http-body-util: solo se usan en pruebas. Ayudan a enviar requests directamente al router y leer el body de la response.

3. Tu primera aplicación con Axum

Comienza con un endpoint que verifique la salud del servicio:

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", get(|| async { "OK" }));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Escuchando en {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

Este código realiza tres cosas:

  • #[tokio::main] transforma main en una función async que se ejecuta en el runtime de Tokio.
  • Router::new().route(...) asocia la ruta /health con el método GET. El handler es un closure async que retorna &'static str. Axum automáticamente convierte esta cadena en una response 200 OK con Content-Type: text/plain.
  • axum::serve recibe un TcpListener e inicia a servir requests.

Pruébalo:

cargo run
curl -i http://localhost:3000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2

OK

4. Modelos y estado compartido

4.1. Definir modelos

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub amount: u64,
}

#[derive(Debug, Deserialize)]
pub struct CreateOrder {
    pub customer: String,
    pub amount: u64,
}

Separar CreateOrder de Order es intencional. El cliente no puede enviar el id por sí mismo, ya que el servidor es quien lo genera. Si usaras el mismo struct, tendrías que manejar el caso donde el cliente envía un id arbitrario.

4.2. Estado compartido

En este artículo, los datos se almacenan en memoria. En la práctica, esto generalmente sería un connection pool como sqlx::PgPool.

use std::collections::HashMap;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, RwLock};

#[derive(Clone, Default)]
pub struct AppState {
    orders: Arc<RwLock<HashMap<u64, Order>>>,
    next_id: Arc<AtomicU64>,
}

Axum clona el estado para cada request. Por eso, AppState debe implementar Clone, y el clone debe ser económico. Envolver los datos en Arc cumple ambos requisitos: clonar solo incrementa el contador de referencias, y todos los handlers apuntan a la misma región de memoria.

RwLock permite que múltiples threads lean en paralelo, pero solo un thread puede escribir a la vez. AtomicU64 se usa para generar id sin necesidad de lock.

Nota: Aquí se usa std::sync::RwLock porque no hay ningún .await entre tomar y liberar el lock. Si necesitas mantener el lock a través de un .await, cambia a tokio::sync::RwLock. Si no lo haces, el future no será Send, y el handler no compilará.

5. Manejo de errores con IntoResponse

Antes de escribir los handlers, define un tipo de error común. Cualquier tipo que implemente IntoResponse puede ser retornado por un handler, incluso dentro de un Result.

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

#[derive(Debug)]
pub enum AppError {
    NotFound(u64),
    Validation(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(id) => (StatusCode::NOT_FOUND, format!("Pedido {id} no encontrado")),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

La tupla (StatusCode, Json<_>) también implementa IntoResponse. Gracias a esto, puedes establecer el código de estado y el body al mismo tiempo.

A partir de ahora, un handler solo necesita retornar Err(AppError::NotFound(id)). El cliente recibirá una response como esta:

HTTP/1.1 404 Not Found
content-type: application/json

{"error":"Pedido 42 no encontrado"}

6. Escribir los handlers CRUD

6.1. Crear un pedido

use axum::extract::State;
use std::sync::atomic::Ordering;

async fn create_order(
    State(state): State<AppState>,
    Json(input): Json<CreateOrder>,
) -> Result<(StatusCode, Json<Order>), AppError> {
    if input.customer.trim().is_empty() {
        return Err(AppError::Validation("el cliente no debe estar vacío".into()));
    }
    let id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1;
    let order = Order { id, customer: input.customer, amount: input.amount };
    state.orders.write().unwrap().insert(id, order.clone());
    Ok((StatusCode::CREATED, Json(order)))
}

Cada parámetro del handler es un extractor:

  • State(state) obtiene el AppState asociado al router.
  • Json(input) lee el body y lo deserializa en CreateOrder. Si el body tiene formato incorrecto, Axum retorna un error antes de que tu función se ejecute.

fetch_add retorna el valor anterior, así que necesitas sumar 1 para que el id comience desde 1. El handler retorna 201 Created junto con el pedido recién creado.

Nota: Los extractors que leen el body, como Json, String o Bytes, deben ser el último parámetro. El body solo se puede leer una vez. Poner Json antes de State causará un error de compilación tipo “the trait Handler<_, _> is not implemented”, y este mensaje es bastante confuso si no conoces la causa.

6.2. Obtener un pedido por id

use axum::extract::Path;

async fn get_order(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Order>, AppError> {
    state
        .orders
        .read()
        .unwrap()
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(AppError::NotFound(id))
}

Path<u64> parsea el segmento {id} de la URL a u64. Si el cliente llama a /orders/abc, Axum retorna 400 Bad Request sin necesidad de código adicional. Cuando no se encuentra el pedido, ok_or convierte None en un error NotFound.

6.3. Listar con paginación

use axum::extract::Query;

#[derive(Debug, Deserialize)]
pub struct Pagination {
    #[serde(default)]
    offset: usize,
    #[serde(default = "default_limit")]
    limit: usize,
}

fn default_limit() -> usize {
    20
}

async fn list_orders(
    State(state): State<AppState>,
    Query(page): Query<Pagination>,
) -> Json<Vec<Order>> {
    let orders = state.orders.read().unwrap();
    let mut result: Vec<Order> = orders.values().cloned().collect();
    result.sort_by_key(|o| o.id);
    let result = result.into_iter().skip(page.offset).take(page.limit).collect();
    Json(result)
}

Query<Pagination> lee el query string como ?offset=10&limit=5. Gracias a #[serde(default)], el cliente puede omitir ambos parámetros y aún recibirá un resultado válido.

El código debe ordenar por id porque HashMap no garantiza ningún orden. Si omites este paso, los resultados de la paginación cambiarán entre llamadas.

6.4. Eliminar un pedido

async fn delete_order(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<StatusCode, AppError> {
    match state.orders.write().unwrap().remove(&id) {
        Some(_) => Ok(StatusCode::NO_CONTENT),
        None => Err(AppError::NotFound(id)),
    }
}

StatusCode por sí solo también implementa IntoResponse. Retornar 204 No Content es la práctica estándar para una operación de eliminación exitosa.

7. Ensamblar el router

pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/health", get(|| async { "OK" }))
        .route("/orders", get(list_orders).post(create_order))
        .route("/orders/{id}", get(get_order).delete(delete_order))
        .with_state(state)
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app(AppState::default())).await.unwrap();
}

Este router se organiza de la siguiente manera:

  • Una ruta puede asociarse con múltiples métodos encadenando: get(...).post(...).
  • with_state asocia el estado al router. Después de este paso, el router tiene tipo Router<()> y está listo para servir requests.
  • La función app se separa para que las pruebas puedan construir el router sin necesidad de abrir un puerto.

Intenta crear y leer un pedido:

curl -i -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer":"Juan García","amount":150000}'

curl http://localhost:3000/orders/1
HTTP/1.1 201 Created
content-type: application/json

{"id":1,"customer":"Juan García","amount":150000}

{"id":1,"customer":"Juan García","amount":150000}

Nota: A partir de Axum 0.8, los parámetros de ruta usan la sintaxis {id} en lugar de /:id de versiones anteriores. Si copias código antiguo que use /:id, la aplicación entrará en pánico al construir el router. La sintaxis de comodín también cambió de *rest a {*rest}.

8. Probar el router

Router implementa el trait Service de tower. Por eso, puedes enviar requests directamente al router usando oneshot, sin necesidad de ejecutar un servidor real. Agrega un módulo de pruebas al final de main.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    fn post_order(json: &str) -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri("/orders")
            .header("content-type", "application/json")
            .body(Body::from(json.to_owned()))
            .unwrap()
    }

    #[tokio::test]
    async fn create_then_get_order() {
        let app = app(AppState::default());

        let response = app
            .clone()
            .oneshot(post_order(r#"{"customer":"Juan","amount":150000}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::CREATED);

        let response = app
            .oneshot(Request::get("/orders/1").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);

        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let order: Order = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(order, Order { id: 1, customer: "Juan".into(), amount: 150000 });
    }

    #[tokio::test]
    async fn empty_customer_is_rejected() {
        let response = app(AppState::default())
            .oneshot(post_order(r#"{"customer":"  ","amount":1}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
    }

    #[tokio::test]
    async fn unknown_order_returns_404() {
        let response = app(AppState::default())
            .oneshot(Request::get("/orders/42").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }
}

Tres puntos importantes en el código de pruebas:

  • oneshot consume el router. Para enviar múltiples requests a un router con el mismo estado, necesitas clone() el router antes de cada llamada. El estado sigue siendo compartido porque está dentro de Arc.
  • BodyExt::collect reúne todo el body en Bytes, luego serde_json lo parsea nuevamente en Order.
  • Cada prueba crea un nuevo AppState, así que las pruebas son independientes entre sí y pueden ejecutarse en paralelo.

Ejecuta las pruebas:

cargo test
running 3 tests
test tests::unknown_order_returns_404 ... ok
test tests::empty_customer_is_rejected ... ok
test tests::create_then_get_order ... ok

test result: ok. 3 passed; 0 failed

9. Errores comunes

Falta el header Content-Type. El extractor Json requiere el header application/json. Sin este header, el cliente recibe 415 Unsupported Media Type aunque el body sea completamente válido. Este es el error más común al probar con curl.

Distinguir códigos de error de Json. JSON con sintaxis incorrecta retorna 400. JSON con sintaxis correcta pero campos faltantes o tipos incorrectos retorna 422. Por defecto, estas responses tienen body en texto plano, no JSON. Si tu API necesita un formato de error uniforme, envuelve Json en un extractor propio y convierte JsonRejection en AppError.

Usar unwrap() en locks en producción. Si un thread entra en pánico mientras sostiene un lock, el lock se envenenará, y todos los unwrap() subsecuentes también entrarán en pánico. En código real, maneja este caso o usa una base de datos verdadera.

Meter todo en un AppState grande. Cuando la aplicación crece, puedes usar FromRef para que los handlers solo obtengan la parte del estado que necesitan, por ejemplo State<PgPool>, en lugar de todo AppState.

¿Cuándo no usar Axum? Si necesitas un framework “todo incluido” con ORM, templates, admin y migraciones integradas, Axum te obligará a ensamblar muchas cosas por tu cuenta. Si tu equipo no está familiarizado con async Rust, los mensajes de error de trait bound de los handlers también serán una barrera significativa en las primeras etapas.

10. Conclusión

Acabas de construir una REST API CRUD completa con Axum 0.8. Los conceptos principales incluyen:

  • Router: asociar rutas y métodos con handlers.
  • Extractor: State, Path, Query y Json obtienen datos de la request de forma type-safe.
  • Estado compartido: los datos compartidos se envuelven en Arc.
  • Manejo de errores: implementar IntoResponse para retornar errores JSON estructurados.
  • Pruebas: enviar requests directamente al router usando tower::ServiceExt::oneshot.

El siguiente paso lógico es reemplazar HashMap con sqlx conectado a PostgreSQL. También deberías agregar middleware de tower-http, como TraceLayer para registrar requests y CorsLayer para configurar CORS. La mayoría de los handlers no necesitarán cambios, ya que la lógica HTTP se ha separado de la capa de almacenamiento.

rustaxumrest-apitokiobackend