← Volver al listado de tecnologías

Capítulo 9: Infrastructure - HTTP Handlers con Axum

Por: SiempreListo
rustwasmaxumleptos

Capítulo 9: Infrastructure - HTTP Handlers con Axum

Introducción

Con la persistencia lista, necesitamos exponer los casos de uso como API REST. Axum nos permite definir handlers como funciones async normales donde los parámetros se inyectan automáticamente.

AppState: estado compartido

use crate::infrastructure::persistence::SqliteCardRepo;

#[derive(Clone)]
pub struct AppState {
    pub repo: SqliteCardRepo,
}

AppState contiene las dependencias que los handlers necesitan. Axum lo clona para cada request (por eso #[derive(Clone)]). Es nuestro contenedor de inyección de dependencias: simple, explícito, sin magia.

Router: definición de rutas

use axum::routing::{delete, get, patch, post};
use axum::Router;
use tower_http::cors::CorsLayer;

use super::app_state::AppState;
use crate::infrastructure::http::card_handler;

pub fn create_router(state: AppState) -> Router {
    Router::new()
        .route("/api/cards", get(card_handler::list_cards))
        .route("/api/cards", post(card_handler::create_card))
        .route("/api/cards/{id}/move", patch(card_handler::move_card))
        .route("/api/cards/{id}", delete(card_handler::delete_card))
        .layer(CorsLayer::permissive())
        .with_state(state)
}

Cada línea .route() mapea un verbo HTTP + path a una función handler:

RutaVerboHandlerAcción
/api/cardsGETlist_cardsListar todas las tarjetas
/api/cardsPOSTcreate_cardCrear tarjeta
/api/cards/{id}/movePATCHmove_cardMover a otra lane
/api/cards/{id}DELETEdelete_cardEliminar tarjeta

DTOs: contratos de entrada

use serde::Deserialize;

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

#[derive(Deserialize)]
pub struct MoveCardDto {
    pub lane: String,
    pub position: i32,
}

Los DTOs (Data Transfer Objects) definen la forma del JSON que recibe el servidor. #[derive(Deserialize)] permite que Serde los construya desde JSON automáticamente. description es Option<String> porque es opcional en la creación.

Handlers

list_cards

pub async fn list_cards(
    State(state): State<AppState>,
) -> Result<Json<Vec<Card>>, DomainError> {
    let cards = use_cases::list_cards::execute(&state.repo).await?;
    Ok(Json(cards))
}

Extractors de Axum en acción:

create_card

pub async fn create_card(
    State(state): State<AppState>,
    Json(dto): Json<CreateCardDto>,
) -> Result<(StatusCode, Json<Card>), DomainError> {
    let desc = dto.description.unwrap_or_default();
    let card = use_cases::create_card::execute(&state.repo, dto.title, desc).await?;
    Ok((StatusCode::CREATED, Json(card)))
}

move_card

pub async fn move_card(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(dto): Json<MoveCardDto>,
) -> Result<StatusCode, DomainError> {
    let card_id = CardId::from_string(id);
    let lane = Lane::try_from(dto.lane.as_str())
        .map_err(DomainError::InvalidLane)?;
    use_cases::move_card::execute(&state.repo, &card_id, lane, dto.position).await?;
    Ok(StatusCode::NO_CONTENT)
}

delete_card

pub async fn delete_card(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<StatusCode, DomainError> {
    let card_id = CardId::from_string(id);
    use_cases::delete_card::execute(&state.repo, &card_id).await?;
    Ok(StatusCode::NO_CONTENT)
}

El handler más simple: extrae ID de la URL, ejecuta el caso de uso, retorna 204.

Error Handler: DomainError a HTTP

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

use crate::domain::errors::DomainError;

impl IntoResponse for DomainError {
    fn into_response(self) -> Response {
        let (status, msg) = match &self {
            DomainError::CardNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
            DomainError::InvalidLane(_) => (StatusCode::BAD_REQUEST, self.to_string()),
            DomainError::Persistence(_) => {
                (StatusCode::INTERNAL_SERVER_ERROR, "Error interno".into())
            }
        };

        (status, Json(json!({ "error": msg }))).into_response()
    }
}

Implementamos IntoResponse para DomainError, lo que permite usarlo como tipo de error en los handlers:

Error de dominioHTTP StatusMensaje
CardNotFound404”Card no encontrada: {id}“
InvalidLane400”Lane inválida: {valor}“
Persistence500”Error interno” (no exponemos detalles)

Para Persistence retornamos un mensaje genérico. Nunca exponer errores internos de base de datos al cliente.

main.rs: punto de entrada

mod application;
mod domain;
mod infrastructure;

use anyhow::Result;
use infrastructure::config::{app_state::AppState, router::create_router};
use infrastructure::persistence::SqliteCardRepo;
use sqlx::SqlitePool;
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();
    dotenvy::dotenv().ok();

    let db_url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "sqlite:apps/app-bbdd/kanban.db?mode=rwc".into());
    let port = std::env::var("PORT").unwrap_or_else(|_| "22000".into());

    let pool = SqlitePool::connect(&db_url).await?;
    sqlx::query(include_str!("../../app-bbdd/migrations/001_create_cards.sql"))
        .execute(&pool)
        .await?;

    let state = AppState {
        repo: SqliteCardRepo::new(pool),
    };

    let app = create_router(state);
    let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
    tracing::info!("Servidor en http://localhost:{port}");
    axum::serve(listener, app).await?;

    Ok(())
}

Paso a paso de la inicialización:

  1. tracing_subscriber::fmt::init(): configura logging estructurado.
  2. dotenvy::dotenv().ok(): carga .env si existe (.ok() ignora si no hay archivo).
  3. Variables de entorno: DATABASE_URL y PORT con valores por defecto.
  4. SqlitePool::connect: crea el pool de conexiones.
  5. include_str! + sqlx::query: ejecuta la migración SQL embebida en el binario en tiempo de compilación.
  6. AppState: construye el estado con el repositorio.
  7. create_router: crea el router con todas las rutas.
  8. TcpListener::bind: escucha en el puerto configurado.
  9. axum::serve: inicia el servidor HTTP.

?mode=rwc en la URL de SQLite significa: read-write-create. Si la DB no existe, la crea.

Flujo completo de un request

flowchart TB
    A["POST /api/cards\n{ title: Mi tarea }"] --> B["CorsLayer\n(permite el request)"]
    B --> C["Router\n(matchea POST /api/cards → create_card)"]
    C --> D["Extractors\nState(state) ← AppState\nJson(dto) ← deserializa body"]
    D --> E["create_card handler"]
    E --> F["use_cases::create_card::execute()"]
    F --> G["repo.save(&card)"]
    G --> H["SQLx INSERT INTO cards"]
    H --> I["Result<StatusCode, Json<Card>, DomainError>"]
    I --> J["HTTP 201 + JSON response"]

Glosario

TérminoDefinición
ExtractorTipo que Axum inyecta automáticamente en handlers desde el request
StateExtractor que provee acceso al estado compartido de la aplicación
PathExtractor que captura parámetros dinámicos de la URL
JsonExtractor que deserializa el body JSON del request
IntoResponseTrait que convierte un tipo en respuesta HTTP
CorsLayerMiddleware que agrega headers CORS a las respuestas
tower-httpColección de middlewares HTTP compatibles con Axum