Cómo construir un servicio web REST seguro y eficiente en Rust con Actix-web y SQLx
En este tutorial práctico construirás una API REST sencilla (users CRUD) usando Actix-web y SQLx con PostgreSQL. Verás estructura de carpetas, código completo, por qué tomamos cada decisión y prácticas para seguridad y rendimiento.
Requisitos
- Rust (stable): rustup + cargo
- Docker y docker-compose (para levantar Postgres localmente)
- sqlx-cli (opcional para migraciones): cargo install sqlx-cli --no-default-features --features postgres
Decisiones principales y por qué
- Actix-web: rendimiento y ergonomía para APIs
- SQLx: queries parametrizadas con comprobación en tiempo de compilación (si se habilita
offlineo se ejecutan las migraciones) — evita SQL inyectable - serde: serialización/validación básica de JSON
- tracing + env_logger: observabilidad en producción y desarrollo
Estructura de proyecto
rust-actix-sqlx/
├─ Cargo.toml
├─ .env
├─ docker-compose.yml
├─ migrations/ # sqlx migrations (opcional)
├─ src/
│ ├─ main.rs
│ ├─ db.rs
│ ├─ models.rs
│ ├─ handlers.rs
│ └─ routes.rs
└─ README.md
Cargo.toml
[package]
name = "rust_actix_sqlx"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "macros"] }
dotenv = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"
uuid = { version = "1", features = ["serde", "v4"] }
thiserror = "1"
docker-compose.yml (Postgres)
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_USER: example
POSTGRES_PASSWORD: example
POSTGRES_DB: example
ports:
- "5432:5432"
volumes:
- ./pgdata:/var/lib/postgresql/data
.env
DATABASE_URL=postgres://example:example@localhost/example
RUST_LOG=info
src/db.rs — Pool de conexiones y helpers
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;
pub async fn create_pool(database_url: &str) -> Result {
PgPoolOptions::new()
.max_connections(10)
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await
}
src/models.rs — Modelos y DTOs
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub name: String,
pub email: String,
}
#[derive(Deserialize)]
pub struct CreateUser {
pub name: String,
pub email: String,
}
#[derive(Deserialize)]
pub struct UpdateUser {
pub name: Option,
pub email: Option,
}
src/handlers.rs — Handlers con SQLx
use actix_web::{web, HttpResponse, Responder};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{CreateUser, UpdateUser, User};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ServiceError {
#[error("DB error: {0}")]
Db(#[from] sqlx::Error),
#[error("Not found")]
NotFound,
}
impl actix_web::ResponseError for ServiceError {}
pub async fn create_user(pool: web::Data, json: web::Json) -> Result {
let id = Uuid::new_v4();
let rec = sqlx::query_as!(
User,
"INSERT INTO users (id, name, email) VALUES ($1, $2, $3) RETURNING id, name, email",
id,
json.name,
json.email
)
.fetch_one(pool.get_ref())
.await?;
Ok(HttpResponse::Created().json(rec))
}
pub async fn list_users(pool: web::Data) -> Result {
let users = sqlx::query_as!(User, "SELECT id, name, email FROM users ORDER BY name")
.fetch_all(pool.get_ref())
.await?;
Ok(HttpResponse::Ok().json(users))
}
pub async fn get_user(pool: web::Data, path: web::Path) -> Result {
let id = path.into_inner();
let user = sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", id)
.fetch_optional(pool.get_ref())
.await?;
match user {
Some(u) => Ok(HttpResponse::Ok().json(u)),
None => Err(ServiceError::NotFound),
}
}
pub async fn update_user(pool: web::Data, path: web::Path, json: web::Json) -> Result {
let id = path.into_inner();
// Simple pattern: apply fields if present. Could be more efficient in SQL.
let existing = sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", id)
.fetch_optional(pool.get_ref())
.await?;
let mut user = match existing {
Some(u) => u,
None => return Err(ServiceError::NotFound),
};
if let Some(name) = &json.name { user.name = name.clone(); }
if let Some(email) = &json.email { user.email = email.clone(); }
let updated = sqlx::query_as!(
User,
"UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING id, name, email",
user.name,
user.email,
id
)
.fetch_one(pool.get_ref())
.await?;
Ok(HttpResponse::Ok().json(updated))
}
pub async fn delete_user(pool: web::Data, path: web::Path) -> Result {
let id = path.into_inner();
let result = sqlx::query!("DELETE FROM users WHERE id = $1", id)
.execute(pool.get_ref())
.await?;
if result.rows_affected() == 0 {
return Err(ServiceError::NotFound);
}
Ok(HttpResponse::NoContent().finish())
}
src/routes.rs
use actix_web::web;
use crate::handlers;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/users")
.route("", web::post().to(handlers::create_user))
.route("", web::get().to(handlers::list_users))
.route("/{id}", web::get().to(handlers::get_user))
.route("/{id}", web::patch().to(handlers::update_user))
.route("/{id}", web::delete().to(handlers::delete_user)),
);
}
src/main.rs
mod db;
mod handlers;
mod models;
mod routes;
use actix_web::{App, HttpServer};
use dotenv::dotenv;
use std::env;
use tracing_subscriber::{fmt, EnvFilter};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
fmt().with_env_filter(filter).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 host = "127.0.0.1";
let port = 8080;
tracing::info!("Starting server at http://{}:{}", host, port);
HttpServer::new(move || {
App::new()
.app_data(actix_web::web::Data::new(pool.clone()))
.configure(routes::configure)
})
.bind((host, port))?
.run()
.await
}
Migración SQL (migrations/20260101_create_users.sql)
-- Up
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY,
name text NOT NULL,
email text NOT NULL UNIQUE
);
-- Down
DROP TABLE IF EXISTS users;
Cómo ejecutar
- Levanta Postgres:
docker-compose up -d - Pregunta de migraciones (opcional): con
sqlx-cliDATABASE_URL="postgres://example:example@localhost/example" sqlx migrate run - Exporta variables y ejecuta:
export DATABASE_URL=postgres://example:example@localhost/example RUST_LOG=info cargo run - Prueba endpoints (curl):
curl -X POST localhost:8080/users -H "Content-Type: application/json" -d '{"name":"Alice","email":"alice@example.com"}' curl localhost:8080/users
Buenas prácticas de seguridad y rendimiento explicadas
- Usa queries parametrizadas (SQLx hace esto) para prevenir inyección SQL.
- Validación de entrada: aquí usamos serde; para reglas más fuertes añade crates como validator o custom checks (email regex).
- Pool de conexiones limitado: evita abrir demasiadas conexiones a la BD.
- Observabilidad: tracing y logs estructurados ayudan en debugging y performance analysis.
- CORS y rate limiting: añade middleware si la API es pública.
- Timeouts: agrega timeouts en peticiones externas y la DB para evitar recursos colgados.
Pruebas
Para testing de integración considera arrancar una instancia de Postgres por test usando docker-compose o testcontainers-rs. Para unit tests, mockea la capa de base de datos o usa una trait que puedas sustituir.
Errores comunes y cómo evitarlos
- No manejar filas no encontradas: siempre devuelve 404 en ese caso (ejemplo: ServiceError::NotFound).
- Asumir que deserialización siempre funciona: valida campos y devuelve 400 con mensajes claros.
- Exponer errores internos: mapear errores a respuestas seguras sin filtrar stack traces.
Siguientes pasos y consejos avanzados
Habilita las macros de SQLx para comprobación en compilación: ejecuta sqlx prepare -- --lib o usa DATABASE_URL en el entorno de CI para que sqlx valide queries. Considera migrar a TLS para la conexión a la base de datos en producción, añadir autenticación (JWT + refresh tokens), y usar un reverse proxy como Nginx/Envoy para terminar TLS y hacer rate limiting. Para mayor rendimiento, mide GC/latencia y perfila con flamegraphs.
Advertencia: no uses datos reales de producción en discos locales del entorno de desarrollo sin respaldo; automatiza backups y revisa límites de conexiones en tu proveedor de DB.
¿Quieres que te genere los archivos listos para copiar/pegar o un docker-compose que incluya un servicio de migraciones automático? Ese es un buen siguiente paso para tener CI/CD completo.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación