Capítulo 9: Infrastructure - HTTP Handlers con Axum
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:
| Ruta | Verbo | Handler | Acción |
|---|---|---|---|
/api/cards | GET | list_cards | Listar todas las tarjetas |
/api/cards | POST | create_card | Crear tarjeta |
/api/cards/{id}/move | PATCH | move_card | Mover a otra lane |
/api/cards/{id} | DELETE | delete_card | Eliminar tarjeta |
CorsLayer::permissive(): permite requests desde cualquier origen. Necesario porque el frontend WASM corre en un puerto distinto..with_state(state): inyectaAppStateen todos los handlers.{id}en la ruta: parámetro dinámico que Axum extrae automáticamente.
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:
State(state): extraeAppStatedel router. Axum lo inyecta automáticamente.- El retorno
Result<Json<Vec<Card>>, DomainError>se convierte a HTTP:Okretorna 200 con JSON,Errusa nuestro error handler.
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)))
}
Json(dto): deserializa el body JSON enCreateCardDto. Si falla, Axum retorna 400 automáticamente.- Retornamos una tupla
(StatusCode, Json<Card>): Axum sabe convertir tuplas en responses. StatusCode::CREATEDgenera un HTTP 201.
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)
}
Path(id): extrae{id}de la URL/api/cards/{id}/move.- Tres extractors en un handler:
State,Path,Json. Axum los resuelve por tipo, sin importar el orden. StatusCode::NO_CONTENT(204): operación exitosa sin body de respuesta.
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 dominio | HTTP Status | Mensaje |
|---|---|---|
CardNotFound | 404 | ”Card no encontrada: {id}“ |
InvalidLane | 400 | ”Lane inválida: {valor}“ |
Persistence | 500 | ”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:
tracing_subscriber::fmt::init(): configura logging estructurado.dotenvy::dotenv().ok(): carga.envsi existe (.ok()ignora si no hay archivo).- Variables de entorno:
DATABASE_URLyPORTcon valores por defecto. SqlitePool::connect: crea el pool de conexiones.include_str!+sqlx::query: ejecuta la migración SQL embebida en el binario en tiempo de compilación.AppState: construye el estado con el repositorio.create_router: crea el router con todas las rutas.TcpListener::bind: escucha en el puerto configurado.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érmino | Definición |
|---|---|
| Extractor | Tipo que Axum inyecta automáticamente en handlers desde el request |
| State | Extractor que provee acceso al estado compartido de la aplicación |
| Path | Extractor que captura parámetros dinámicos de la URL |
| Json | Extractor que deserializa el body JSON del request |
| IntoResponse | Trait que convierte un tipo en respuesta HTTP |
| CorsLayer | Middleware que agrega headers CORS a las respuestas |
| tower-http | Colección de middlewares HTTP compatibles con Axum |