Cómo construir una API REST asíncrona en Rust con Axum, SQLx y Postgres

rust

Cómo construir una API REST asíncrona en Rust con Axum, SQLx y Postgres

En este tutorial vas a crear una API CRUD minimalista pero realista con Axum (router y server), Tokio (runtime) y SQLx (acceso a Postgres) usando patrones de estado compartido, manejo de errores y pruebas de integración. Te doy la estructura, el código completo y el porqué de las decisiones.

Pre-requisitos

  • Rust (stable) y Cargo
  • Postgres disponible (local o en Docker)
  • sqlx-cli para manejar migraciones (opcional pero recomendado)

Estructura del proyecto

my_api/
├─ Cargo.toml
├─ .env
└─ src/
   ├─ main.rs
   ├─ db.rs
   ├─ models.rs
   └─ handlers.rs

Cargo.toml

[package]
name = "my_api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "macros"] }
tracing = "0.1"
tracing-subscriber = "0.3"
dotenvy = "0.15"

Archivo .env (ejemplo)

DATABASE_URL=postgres://postgres:password@localhost:5432/my_api_db
RUST_LOG=info

src/db.rs

use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;

pub async fn init_pool(database_url: &str) -> anyhow::Result {
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect_timeout(Duration::from_secs(5))
        .connect(database_url)
        .await?;
    Ok(pool)
}

src/models.rs

use serde::{Deserialize, Serialize};
use sqlx::FromRow;

#[derive(Serialize, Deserialize, FromRow, Debug)]
pub struct Item {
    pub id: i32,
    pub name: String,
    pub completed: bool,
}

#[derive(Serialize, Deserialize)]
pub struct CreateItem {
    pub name: String,
}

src/handlers.rs

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use sqlx::PgPool;
use crate::models::{Item, CreateItem};

pub type AppState = PgPool;

pub async fn list_items(State(pool): State) -> Result>, StatusCode> {
    let items = sqlx::query_as!(Item, "SELECT id, name, completed FROM items ORDER BY id")
        .fetch_all(&pool)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(items))
}

pub async fn get_item(Path(id): Path, State(pool): State) -> Result, StatusCode> {
    let item = sqlx::query_as!(Item, "SELECT id, name, completed FROM items WHERE id = $1", id)
        .fetch_one(&pool)
        .await
        .map_err(|_| StatusCode::NOT_FOUND)?;
    Ok(Json(item))
}

pub async fn create_item(Json(payload): Json, State(pool): State) -> Result<(StatusCode, Json), StatusCode> {
    let rec = sqlx::query_as!(
        Item,
        "INSERT INTO items (name, completed) VALUES ($1, false) RETURNING id, name, completed",
        payload.name
    )
    .fetch_one(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

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

pub async fn delete_item(Path(id): Path, State(pool): State) -> Result {
    let res = sqlx::query!("DELETE FROM items WHERE id = $1", id)
        .execute(&pool)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if res.rows_affected() == 0 {
        return Err(StatusCode::NOT_FOUND);
    }
    Ok(StatusCode::NO_CONTENT)
}

src/main.rs

mod db;
mod handlers;
mod models;

use axum::{routing::get, routing::post, routing::delete, Router};
use dotenvy::dotenv;
use std::net::SocketAddr;
use tracing_subscriber::EnvFilter;

use handlers::{list_items, get_item, create_item, delete_item};
use db::init_pool;

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

    let database_url = std::env::var("DATABASE_URL")?;
    let pool = init_pool(&database_url).await?;

    let app = Router::new()
        .route("/items", get(list_items).post(create_item))
        .route("/items/:id", get(get_item).delete(delete_item))
        .with_state(pool);

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

Migración SQL

-- 2023xxxx_create_items.sql
CREATE TABLE items (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  completed BOOLEAN NOT NULL DEFAULT false
);

Si usas sqlx-cli puedes correr las migraciones con sqlx migrate run. Alternativamente crea la tabla manualmente en psql.

Cómo ejecutar

  1. Exporta DATABASE_URL y RUST_LOG (o crea .env).
  2. Corre migraciones: sqlx migrate run o ejecuta el SQL.
  3. Compila y ejecuta: cargo run --release.

Pruebas de integración (simplificadas)

Un patrón práctico: arrancar el server en background en un puerto aleatorio y usar reqwest para probar endpoints. Aquí un ejemplo mínimo en tests/integration.rs:

// tests/integration.rs
#[tokio::test]
async fn smoke() {
    // asumiendo que la app tiene una forma de arrancar con una DB en memoria o test DB
    // Este fragmento es conceptual: arranca el server y realiza una petición HTTP
    // let addr = spawn_app().await;
    // let client = reqwest::Client::new();
    // let res = client.get(&format!("http://{}/items", addr)).send().await.unwrap();
    // assert!(res.status().is_success());
}

Las pruebas en Rust para servicios implican gestionar un entorno reproducible (DB de prueba, variables de entorno y puerto dinámico). Usa migraciones y create/drop DB por test si necesitas aislamiento.

Por qué estas elecciones

  • Axum: router minimal, ergonomía con extractors y State; fácil de componer.
  • SQLx: consultas compiladas con el feature "macros" y el DB URL disponible en tiempo de compilación si usas cargo sqlx prepare, ayuda a detectar errores SQL temprano.
  • Tokio: runtime maduro, compatibilidad con Axum y SQLx.
  • Pool: usar PgPool evita sobrecargar la DB y es async-safe.

Buenas prácticas y consideraciones

  • Manejo de errores: no devolver errores internos al cliente; mapéalos a códigos HTTP apropiados.
  • Validación: valida entradas (largo, formato) antes de ejecutar SQL.
  • Transacciones: agrupa operaciones relacionadas en transacciones SQL para consistencia.
  • Observabilidad: usa tracing y metrics (Prometheus) para monitorear latencias y errores.
  • Configuración segura: no embebas credenciales en el repo; usa secretos o variables de entorno.
  • Migraciones: versiona y aplica migraciones en despliegues automáticos.

Si quieres soporte para autenticación, paginación eficiente, o caché, los siguientes pasos naturales son:

  • Agregar middleware de autenticación (JWT o sesiones).
  • Implementar paginación y filtros en las consultas SQL.
  • Introducir Redis para caché de lecturas intensas.

Consejo avanzado: usa sqlx prepare en CI para validar todas tus consultas SQL contra el esquema de migraciones, y configura tests de integración que creen una base de datos efímera por corrida para evitar efectos colaterales.

Advertencia: evita aumentar excesivamente el tamaño del pool sin medir; demasiadas conexiones concurrentes pueden saturar Postgres. Un buen siguiente paso es añadir tracing más fino y métricas de latencia por endpoint para identificar cuellos de botella.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión