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
- Exporta
DATABASE_URLyRUST_LOG(o crea.env). - Corre migraciones:
sqlx migrate runo ejecuta el SQL. - 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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación