Cómo construir una API REST en Rust con Actix-web y SQLx (Postgres)
En este tutorial práctico construirás una API REST mínima pero completa en Rust utilizando Actix-web (servidor HTTP asíncrono) y SQLx (conexión segura a Postgres). Verás estructura de carpetas, código completo, por qué tomamos cada decisión, pruebas básicas, despliegue con Docker y recomendaciones de rendimiento y seguridad.
Qué construiremos
- CRUD para recursos "users" (id: UUID, name, email, created_at)
- Conexión a Postgres mediante SQLx y pool
- Manejo de errores centralizado y respuestas JSON
- Docker Compose para Postgres + app
Estructura del proyecto
rust_api/
├─ Cargo.toml
├─ .env
├─ src/
│ ├─ main.rs
│ ├─ db.rs
│ ├─ models.rs
│ ├─ handlers.rs
│ └─ errors.rs
├─ migrations/
│ └─ 20260801_create_users.sql
└─ docker-compose.yml
Cargo.toml
Dependencias clave: actix-web, sqlx (Postgres), serde, dotenvy, log.
[package]
name = "rust_api"
edition = "2021"
[dependencies]
actix-web = "4"
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] }
dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
env_logger = "0.9"
uuid = { version = "1", features = ["serde", "v4"] }
anyhow = "1.0"
.env (ejemplo)
DATABASE_URL=postgres://postgres:password@db:5432/rust_api
RUST_LOG=info
Migration SQL (migrations/20260801_create_users.sql)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
src/db.rs
Función para crear pool reutilizable.
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;
pub async fn create_pool(database_url: &str) -> anyhow::Result {
let pool = PgPoolOptions::new()
.max_connections(10) // ajustar según CPU / carga
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await?;
Ok(pool)
}
src/models.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct User {
pub id: Uuid,
pub name: String,
pub email: String,
pub created_at: DateTime,
}
#[derive(Deserialize)]
pub struct CreateUser {
pub name: String,
pub email: String,
}
#[derive(Deserialize)]
pub struct UpdateUser {
pub name: Option,
pub email: Option,
}
src/errors.rs
Manejo de errores centralizado que convierte errores en respuestas HTTP JSON.
use actix_web::{HttpResponse, ResponseError};
use derive_more::Display;
use sqlx::Error as SqlxError;
#[derive(Debug, Display)]
pub enum AppError {
#[display(fmt = "Not Found")]
NotFound,
#[display(fmt = "Bad Request: {}", _0)]
BadRequest(String),
#[display(fmt = "Internal Error")]
InternalError,
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
match self {
AppError::NotFound => HttpResponse::NotFound().json("Not Found"),
AppError::BadRequest(msg) => HttpResponse::BadRequest().json(msg),
AppError::InternalError => HttpResponse::InternalServerError().json("Internal Server Error"),
}
}
}
impl From for AppError {
fn from(err: SqlxError) -> Self {
match err {
SqlxError::RowNotFound => AppError::NotFound,
_ => AppError::InternalError,
}
}
}
src/handlers.rs
use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{CreateUser, UpdateUser, User};
use crate::errors::AppError;
use chrono::Utc;
pub async fn list_users(pool: web::Data) -> Result {
let rows = sqlx::query!(
"SELECT id, name, email, created_at FROM users ORDER BY created_at DESC LIMIT 100"
)
.fetch_all(pool.get_ref())
.await?;
let users: Vec = rows
.into_iter()
.map(|r| User { id: r.id, name: r.name, email: r.email, created_at: r.created_at })
.collect();
Ok(HttpResponse::Ok().json(users))
}
pub async fn create_user(
pool: web::Data,
payload: web::Json,
) -> Result {
// Validación simple
if payload.name.trim().is_empty() || payload.email.trim().is_empty() {
return Err(AppError::BadRequest("name and email are required".into()));
}
let id = Uuid::new_v4();
let created_at = Utc::now();
sqlx::query!(
"INSERT INTO users (id, name, email, created_at) VALUES ($1, $2, $3, $4)",
id,
payload.name,
payload.email,
created_at
)
.execute(pool.get_ref())
.await
.map_err(|e| e.into())?;
let user = User { id, name: payload.name.clone(), email: payload.email.clone(), created_at };
Ok(HttpResponse::Created().json(user))
}
pub async fn get_user(pool: web::Data, path: web::Path) -> Result {
let id = path.into_inner();
let r = sqlx::query!("SELECT id, name, email, created_at FROM users WHERE id = $1", id)
.fetch_one(pool.get_ref())
.await?;
let user = User { id: r.id, name: r.name, email: r.email, created_at: r.created_at };
Ok(HttpResponse::Ok().json(user))
}
pub async fn update_user(
pool: web::Data,
path: web::Path,
payload: web::Json,
) -> Result {
let id = path.into_inner();
// Fetch existing
let existing = sqlx::query!("SELECT id, name, email, created_at FROM users WHERE id = $1", id)
.fetch_one(pool.get_ref())
.await?;
let new_name = payload.name.clone().unwrap_or(existing.name);
let new_email = payload.email.clone().unwrap_or(existing.email);
sqlx::query!(
"UPDATE users SET name = $1, email = $2 WHERE id = $3",
new_name,
new_email,
id
)
.execute(pool.get_ref())
.await?;
let user = User { id, name: new_name, email: new_email, created_at: existing.created_at };
Ok(HttpResponse::Ok().json(user))
}
pub async fn delete_user(pool: web::Data, path: web::Path) -> Result {
let id = path.into_inner();
sqlx::query!("DELETE FROM users WHERE id = $1", id)
.execute(pool.get_ref())
.await?;
Ok(HttpResponse::NoContent().finish())
}
src/main.rs
mod db;
mod models;
mod handlers;
mod errors;
use actix_web::{web, App, HttpServer};
use dotenvy::dotenv;
use std::env;
use env_logger::Env;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = db::create_pool(&database_url)
.await
.expect("Failed to create DB pool");
let pool_data = web::Data::new(pool);
HttpServer::new(move || {
App::new()
.app_data(pool_data.clone())
.route("/users", web::get().to(handlers::list_users))
.route("/users", web::post().to(handlers::create_user))
.route("/users/{id}", web::get().to(handlers::get_user))
.route("/users/{id}", web::put().to(handlers::update_user))
.route("/users/{id}", web::delete().to(handlers::delete_user))
})
.bind(("0.0.0.0", 8080))?
.run()
.await
}
Docker Compose para desarrollo
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: rust_api
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
app:
build: .
env_file: .env
depends_on:
- db
ports:
- "8080:8080"
volumes:
db_data:
Dockerfile (opcional)
FROM rust:1.71 as builder
WORKDIR /usr/src/app
COPY . .
RUN cargo build --release
FROM debian:buster-slim
COPY --from=builder /usr/src/app/target/release/rust_api /usr/local/bin/rust_api
ENV RUST_LOG=info
CMD ["/usr/local/bin/rust_api"]
Pruebas básicas (curl)
# Crear
curl -X POST -H "Content-Type: application/json" -d '{"name":"Alice","email":"alice@example.com"}' http://localhost:8080/users
# Listar
curl http://localhost:8080/users
# Obtener
curl http://localhost:8080/users/
Razones detrás de las decisiones
- Actix-web: rendimiento y ecosistema maduro para APIs en Rust.
- SQLx: proveedores asíncronos y binding seguro de parámetros; evita inyección SQL y ofrece macros para compile-time checks (si configuras DATABASE_URL al compilar).
- UUID + created_at: identificadores seguros y trazabilidad temporal.
- Manejo de errores centralizado con ResponseError para respuestas consistentes.
Mejores prácticas y optimización
- Pool de conexiones: ajusta max_connections según CPU y concurrencia esperada.
- Compilar con las características de SQLx y usar query! para validación en tiempo de compilación (requiere conexión a la DB en build).
- Evita serializar objetos grandes; selecciona solo columnas necesarias en consultas.
- Considera PgBouncer en modo transaction para alta concurrencia y conexiones cortas.
- Habilita TLS (runtime-tokio-rustls) para conexiones seguras si te conectas a DB remota.
Seguridad
- Valida y sanitiza entrada: no confíes solo en la tipificación del JSON.
- Usa consultas parametrizadas (como en SQLx) para eliminar riesgo de SQL injection.
- Protege secretos usando gestores (Vault, AWS Secrets) o variables de entorno en sistemas orquestados.
- Limita tamaño de payload y aplica rate limiting si expones públicamente.
Testing y CI
Incluye pruebas de integración que arranquen una BD de prueba (docker-compose -f docker-compose.test.yml up) y ejecuten migraciones. Con GitHub Actions puedes levantar un servicio Postgres y ejecutar cargo test.
Debugging
Usa RUST_LOG=debug y logs estructurados. Para latencias, mide con tracing o middleware que capture timings de request/response.
Extensiones sugeridas
- Autenticación JWT y scopes por endpoint
- Paginación eficiente con índices y cursores
- Cache con Redis para lecturas frecuentes
- Health checks y métricas (Prometheus)
Consejo avanzado: si esperas latencias muy bajas y alto RPS, evita operaciones síncronas en handlers — usa operaciones asíncronas, prepared statements reutilizables y considera un pool de conexiones más pequeño combinado con PgBouncer. Advertencia: compilar y usar las macros de SQLx (query!) requiere que la URL de la base de datos esté disponible en build time o usar el modo offline; esto mejora seguridad y detecta errores de esquema temprano.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación