Cómo construir una API REST eficiente en Rust con Actix-web y SQLx
En este tutorial práctico vas a crear desde cero una pequeña API CRUD de tasks usando Actix-web y SQLx sobre PostgreSQL. Verás la estructura de carpetas, el código completo, por qué se diseña así y consejos de rendimiento y seguridad. Enfocado a desarrolladores con conocimientos básicos de Rust.
Requisitos
- Rust (stable) y Cargo
- Docker (para ejecutar PostgreSQL localmente)
- Editor y curl o Postman
Características del ejemplo
- Endpoints: GET /tasks, GET /tasks/{id}, POST /tasks, PUT /tasks/{id}, DELETE /tasks/{id}
- Conexión a PostgreSQL con pool
- Validación básica y manejo de errores
- Ejemplos curl
Estructura del proyecto
task-api/
├─ Cargo.toml
├─ docker-compose.yml
└─ src/
├─ main.rs
├─ db.rs
├─ models.rs
├─ handlers.rs
└─ routes.rs
Cargo.toml
[package]
name = "task-api"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "macros"] }
anyhow = "1.0"
env_logger = "0.10"
log = "0.4"
uuid = { version = "1", features = ["serde", "v4"] }
Docker Compose para Postgres (local)
version: "3.7"
services:
db:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: taskdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Levantar con: docker-compose up -d. La URL de conexión será algo como postgres://postgres:postgres@localhost:5432/taskdb.
Migración SQL (ejemplo simple)
CREATE TABLE tasks (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
src/db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;
pub async fn establish_pool(database_url: &str) -> anyhow::Result {
let pool = PgPoolOptions::new()
.max_connections(5) // ajustar según carga
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await?;
Ok(pool)
}
src/models.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(sqlx::FromRow, Serialize, Debug)]
pub struct Task {
pub id: Uuid,
pub title: String,
pub completed: bool,
pub created_at: DateTime,
}
#[derive(Deserialize)]
pub struct CreateTask {
pub title: String,
}
#[derive(Deserialize)]
pub struct UpdateTask {
pub title: Option,
pub completed: Option,
}
src/handlers.rs
use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{Task, CreateTask, UpdateTask};
pub async fn list_tasks(db: web::Data) -> actix_web::Result {
let tasks = sqlx::query_as::<_, Task>("SELECT * FROM tasks ORDER BY created_at DESC")
.fetch_all(db.get_ref())
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
Ok(HttpResponse::Ok().json(tasks))
}
pub async fn get_task(db: web::Data, path: web::Path) -> actix_web::Result {
let id = path.into_inner();
let task = sqlx::query_as::<_, Task>("SELECT * FROM tasks WHERE id = $1")
.bind(id)
.fetch_optional(db.get_ref())
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
match task {
Some(t) => Ok(HttpResponse::Ok().json(t)),
None => Ok(HttpResponse::NotFound().finish()),
}
}
pub async fn create_task(db: web::Data, payload: web::Json) -> actix_web::Result {
if payload.title.trim().is_empty() {
return Ok(HttpResponse::BadRequest().body("title is required"));
}
let id = Uuid::new_v4();
let rec = sqlx::query_as::<_, Task>("INSERT INTO tasks (id, title, completed) VALUES ($1, $2, $3) RETURNING *")
.bind(id)
.bind(&payload.title)
.bind(false)
.fetch_one(db.get_ref())
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
Ok(HttpResponse::Created().json(rec))
}
pub async fn update_task(db: web::Data, path: web::Path, payload: web::Json) -> actix_web::Result {
let id = path.into_inner();
let mut tx = db.begin().await.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
// Obtener actual
let current = sqlx::query_as::<_, Task>("SELECT * FROM tasks WHERE id = $1")
.bind(id)
.fetch_optional(&mut tx)
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
let current = match current {
Some(c) => c,
None => return Ok(HttpResponse::NotFound().finish()),
};
let new_title = payload.title.clone().unwrap_or(current.title);
let new_completed = payload.completed.unwrap_or(current.completed);
let updated = sqlx::query_as::<_, Task>("UPDATE tasks SET title = $1, completed = $2 WHERE id = $3 RETURNING *")
.bind(new_title)
.bind(new_completed)
.bind(id)
.fetch_one(&mut tx)
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
tx.commit().await.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
Ok(HttpResponse::Ok().json(updated))
}
pub async fn delete_task(db: web::Data, path: web::Path) -> actix_web::Result {
let id = path.into_inner();
let result = sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(id)
.execute(db.get_ref())
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
if result.rows_affected() == 0 {
Ok(HttpResponse::NotFound().finish())
} else {
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("/tasks")
.route("", web::get().to(handlers::list_tasks))
.route("", web::post().to(handlers::create_task))
.route("/{id}", web::get().to(handlers::get_task))
.route("/{id}", web::put().to(handlers::update_task))
.route("/{id}", web::delete().to(handlers::delete_task)),
);
}
src/main.rs
mod db;
mod handlers;
mod models;
mod routes;
use actix_web::{App, HttpServer};
use env_logger::Env;
use std::env;
#[actix_web::main]
async fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/taskdb".into());
let pool = db::establish_pool(&database_url).await?;
HttpServer::new(move || {
App::new()
.app_data(actix_web::web::Data::new(pool.clone()))
.configure(routes::configure)
})
.bind(("0.0.0.0", 8080))?
.run()
.await?;
Ok(())
}
Probar la API (curl)
# Crear
curl -X POST -H "Content-Type: application/json" -d '{"title":"Comprar leche"}' http://localhost:8080/tasks
# Listar
curl http://localhost:8080/tasks
# Obtener
curl http://localhost:8080/tasks/{id}
# Actualizar
curl -X PUT -H "Content-Type: application/json" -d '{"completed":true}' http://localhost:8080/tasks/{id}
# Borrar
curl -X DELETE http://localhost:8080/tasks/{id}
Por qué este diseño
- Separa responsabilidades: db (pool), models, handlers y rutas para mantener el código claro y testeable.
- SQLx con consultas parametrizadas evita inyección SQL y permite mapping directo a structs.
- Uso de pool y límites de conexiones para proteger la DB bajo carga.
- Manejo de transacciones donde es necesario (actualizar con lectura previa).
Mejoras de rendimiento y configuración
- Compilar en release:
cargo build --releasepara optimizaciones. - Ajustar
max_connectionsdel pool según CPU y tamaño de DB. - Habilitar keep-alive y TLS según necesidad (sqlx con rustls en features).
- Utilizar índices en columnas consultadas frecuentemente (ej. created_at si se ordena).
Seguridad y prácticas recomendadas
- Validar payloads y evitar campos inseguros. Aquí se valida título vacío como ejemplo.
- Usar variables de entorno para credenciales y no subirlas al repo.
- Limitar tamaño máximo de body en Actix con configuraciones si esperas payloads grandes.
- Habilitar CORS con configuración estricta sólo para orígenes permitidos en producción.
Depuración y testing
- Usa
RUST_LOG=debugpara obtener trazas durante desarrollo. - Agregar pruebas de integración que arranquen una BD de testing (Docker) y usen endpoints HTTP.
Errores comunes y cómo evitarlos
- No ajustar el pool: poner un pool demasiado pequeño o demasiado grande causa latencia o sobrecarga.
- No usar consultas parametrizadas: sqlx las usa por defecto con
.bind(). - No manejar timeouts de conexión: configurar
connect_timeouty timeouts de request en Actix.
Si quieres llevar esto más lejos: añade autenticación (JWT), paginación, índices y metrics (Prometheus). Un consejo avanzado: habilita sqlx offline mode para compilar con verificación estática de queries en CI, generando un archivo de realidad con DATABASE_URL apuntando a una DB de test durante build. Eso reduce errores en producción y pone tus queries a prueba.
Advertencia: siempre realiza pruebas de carga y revisa límites de conexión antes de desplegar en producción; la configuración por defecto puede no ser segura bajo picos altos.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación