Meta
- Meta title sugerido: Tutorial Rust Actix Web: Crea una API REST segura con SQLx, JWT y Docker
- Meta description sugerida: Guía paso a paso para construir una API REST en Rust con Actix Web. Incluye SQLx PostgreSQL, autenticación JWT, validación, logging, rate limiting, OpenAPI, pruebas y Docker Compose.
- URL slug sugerido: tutorial-rust-actix-web-api-rest-segura-sqlx-jwt-docker
Tutorial Rust Actix Web: Crea una API REST segura con SQLx, JWT y Docker
En esta guía práctica construirás una API REST en Rust con Actix Web, conectada a PostgreSQL mediante SQLx, con autenticación JWT, validación, logging con tracing, rate limiting, CORS y documentación OpenAPI. Está diseñada para backend developers principiantes e intermedios con nociones básicas de HTTP y SQL. Al final podrás ejecutar todo con Docker Compose y tendrás bases para desplegar en producción.
Indice general (secciones numeradas):
- Introducción y objetivos
- Requisitos previos
- Crear el proyecto y organizar la estructura
- Configuración por entornos (config/dotenv)
- PostgreSQL con SQLx: pool, timeouts, retries, migraciones y seeding
- Modelo de dominio y DTOs con validación
- Manejo global de errores
- Logging y trazas con tracing
- Seguridad: CORS, cabeceras y rate limiting
- Autenticación y autorización con JWT y Argon2
- Rutas y CRUD REST (tareas) con paginación/filtrado/ordenación
- Documentación OpenAPI (utoipa + swagger-ui)
- Pruebas unitarias e integración
- Rendimiento y concurrencia
- Contenedorización con Docker y orquestación local con Docker Compose
- Preparación para despliegue y observabilidad
- Resolución de problemas comunes (FAQ técnica)
- Conclusión y próximos pasos
1. Introducción y objetivos
Construiremos una API REST Rust con Actix Web centrada en buenas prácticas de seguridad y rendimiento. Expondremos un recurso de ejemplo: tareas (Task). Implementaremos CRUD, autenticación JWT, validación y documentación. Usaremos SQLx para consultas tipadas en PostgreSQL y Docker para ejecutar todo localmente.
Checklist
- Entenderás la arquitectura del proyecto (src/, routes/, handlers/, models/, db/, auth/)
- Aprenderás a configurar, integrar y probar SQLx con migraciones
- Añadirás logging estructurado, CORS y rate limiting
- Implementarás autenticación JWT segura
- Documentarás la API con OpenAPI y swagger-ui
2. Requisitos previos
- Rust y herramientas
- rustup, cargo, rustc
- Instala: curl —proto ‘=https’ —tlsv1.2 -sSf https://sh.rustup.rs | sh
- PostgreSQL (local o en Docker)
- SQLx CLI
- Docker y Docker Compose
- Cliente HTTP: Postman, Insomnia, HTTPie o curl
Comandos útiles:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# Ver versiones rustc --version cargo --version # Instalar SQLx CLI cargo install sqlx-cli --features rustls,postgres # Verificar Docker docker --version docker compose version # Instalar HTTPie (opcional) pipx install httpie # o via pip install --user httpie |
Explicación: Necesitamos Rust y cargo para compilar. SQLx CLI gestiona migraciones. Docker/Compose nos permite levantar API + PostgreSQL localmente.
Checklist
- Rust y Cargo instalados
- SQLx CLI operativo
- Docker y Compose disponibles
- Cliente HTTP listo
3. Crear el proyecto y organizar la estructura
1 2 3 |
cargo new actix-tasks-api --bin cd actix-tasks-api |
Explicación: Creamos un binario llamado actix-tasks-api.
Estructura propuesta:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
actix-tasks-api/ ├─ Cargo.toml ├─ .env ├─ sqlx-data.json # opcional (offline) ├─ migrations/ # SQLx migrations ├─ src/ │ ├─ main.rs │ ├─ config.rs │ ├─ db.rs │ ├─ errors.rs │ ├─ models.rs │ ├─ auth.rs │ ├─ routes.rs │ ├─ handlers/ │ │ ├─ tasks.rs │ │ └─ auth.rs │ └─ docs.rs └─ config/ ├─ default.toml ├─ development.toml └─ production.toml |
Explicación: Mantenemos módulos claros: configuración, DB, modelos, autenticación, rutas/handlers y documentación.
Añade dependencias en Cargo.toml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
[package] name = "actix-tasks-api" version = "0.1.0" edition = "2021" [dependencies] actix-web = "4" actix-cors = "0.6" actix-governor = "0.5" serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" config = "0.13" sqlx = { version = "0.7", features = ["runtime-tokio-rustls","postgres","uuid","chrono","migrate","macros","json"] } validator = { version = "0.18", features = ["derive"] } thiserror = "1" jsonwebtoken = { version = "9", default-features = false, features = ["serde"] } argon2 = "0.5" password-hash = "0.5" rand = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter","fmt","json"] } tracing-actix-web = "0.7" utoipa = { version = "4", features = ["chrono","uuid","json"] } utoipa-swagger-ui = { version = "7", features = ["actix-web"] } actix-web-prom = "0.6" once_cell = "1" reqwest = { version = "0.12", features = ["json","rustls-tls"] } tokio = { version = "1", features = ["rt-multi-thread","macros"] } actix-rt = "2" |
Explicación: Estas crates cubren el stack: Actix Web, CORS, rate limiting, SQLx PostgreSQL, validación, JWT/Argon2, logging/tracing, OpenAPI y métricas.
Checklist
- Proyecto creado
- Estructura y Cargo.toml listos
4. Configuración por entornos (config/dotenv)
Crea config/default.toml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[server] host = "0.0.0.0" port = 8080 [database] max_connections = 10 connect_timeout_secs = 5 acquire_timeout_secs = 10 idle_timeout_secs = 300 [jwt] issuer = "actix-tasks-api" expiration_minutes = 60 [cors] allowed_origins = ["*"] [rate_limit] per_second = 5 burst_size = 10 |
Crea .env (no lo subas a git):
1 2 3 4 5 |
RUST_LOG=info,actix_web=info DATABASE_URL=postgres://postgres:postgres@localhost:5432/tasks_db JWT_SECRET=super-secreto-cambia-esto APP_ENV=development |
Carga de configuración en src/config.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
use serde::Deserialize; #[derive(Debug, Deserialize, Clone)] pub struct Server { pub host: String, pub port: u16 } #[derive(Debug, Deserialize, Clone)] pub struct Database { pub max_connections: u32, pub connect_timeout_secs: u64, pub acquire_timeout_secs: u64, pub idle_timeout_secs: u64 } #[derive(Debug, Deserialize, Clone)] pub struct Jwt { pub issuer: String, pub expiration_minutes: i64 } #[derive(Debug, Deserialize, Clone)] pub struct Cors { pub allowed_origins: Vec<String> } #[derive(Debug, Deserialize, Clone)] pub struct RateLimit { pub per_second: u64, pub burst_size: u32 } #[derive(Debug, Deserialize, Clone)] pub struct Settings { pub server: Server, pub database: Database, pub jwt: Jwt, pub cors: Cors, pub rate_limit: RateLimit, } pub fn load() -> anyhow::Result<(Settings, String)> { dotenvy::dotenv().ok(); let env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); let mut cfg = config::Config::builder() .add_source(config::File::with_name("config/default").required(true)) .add_source(config::File::with_name(&format!("config/{}", env)).required(false)) .add_source(config::Environment::default().separator("_")) .build()?; let settings: Settings = cfg.try_deserialize()?; let database_url = std::env::var("DATABASE_URL")?; Ok((settings, database_url)) } |
Explicación: Combinamos archivos TOML y variables de entorno. DATABASEURL y JWTSECRET van en .env.
Checklist
- Variables de entorno definidas
- Config loader implementado
5. PostgreSQL con SQLx: pool, timeouts, retries, migraciones y seeding
Conexión y pool en src/db.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; use std::time::Duration; pub async fn connect_pool( database_url: &str, max_connections: u32, connect_timeout_secs: u64, acquire_timeout_secs: u64, idle_timeout_secs: u64, ) -> anyhow::Result<Pool<Postgres>> { let mut retries = 0u32; loop { match PgPoolOptions::new() .max_connections(max_connections) .acquire_timeout(Duration::from_secs(acquire_timeout_secs)) .connect_timeout(Duration::from_secs(connect_timeout_secs)) .idle_timeout(Duration::from_secs(idle_timeout_secs)) .connect(database_url) .await { Ok(pool) => return Ok(pool), Err(e) if retries < 5 => { retries += 1; eprintln!("DB connect failed (attempt {}): {}", retries, e); tokio::time::sleep(Duration::from_secs(2u64.pow(retries))).await; } Err(e) => return Err(e.into()), } } } |
Explicación: Creamos un pool con límites y reintentos exponenciales ante fallos temporales.
Migraciones:
1 2 3 |
# Inicializa directorio de migraciones sqlx migrate add -r 0001_create_tables |
Archivo migrations/0001createtables.sql:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
-- Add migration CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'pending', owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_id); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); |
Explicación: Creamos tablas users y tasks con índices.
Ejecutar migraciones:
1 2 3 |
DATABASE_URL=postgres://postgres:postgres@localhost:5432/tasks_db sqlx database create sqlx migrate run |
Seeding opcional: migrations/0002_seed.sql
1 2 3 |
INSERT INTO users (email, password_hash) VALUES ('demo@example.com', '$argon2id$v=19$m=65536,t=3,p=2$...'); -- reemplaza con hash real |
Explicación: Puedes sembrar un usuario de prueba. Más abajo veremos cómo generar el hash.
Checklist
- Pool configurado con timeouts y retries
- Migraciones creadas y aplicadas
- Datos de prueba opcionales insertados
6. Modelo de dominio y DTOs con validación
src/models.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
use serde::{Deserialize, Serialize}; use uuid::Uuid; use chrono::{DateTime, Utc}; use validator::Validate; use utoipa::ToSchema; #[derive(Debug, Serialize, sqlx::FromRow, ToSchema)] pub struct Task { pub id: Uuid, pub title: String, pub description: Option<String>, pub status: String, pub owner_id: Uuid, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateTaskDto { #[validate(length(min = 3, max = 120))] pub title: String, #[validate(length(max = 500))] pub description: Option<String>, } #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateTaskDto { #[validate(length(min = 3, max = 120))] pub title: Option<String>, #[validate(length(max = 500))] pub description: Option<String>, pub status: Option<String>, } #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct RegisterDto { #[validate(email)] pub email: String, #[validate(length(min = 8))] pub password: String, } #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct LoginDto { #[validate(email)] pub email: String, #[validate(length(min = 8))] pub password: String, } |
Explicación: Definimos el modelo Task y DTOs con validator para asegurar entradas correctas. ToSchema expone esquemas para OpenAPI.
Checklist
- DTOs con validación listos
- Modelo de dominio mapeado a SQLx
7. Manejo global de errores
src/errors.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
use actix_web::{http::StatusCode, HttpResponse, ResponseError}; use serde::Serialize; use thiserror::Error; #[derive(Debug, Serialize)] pub struct ErrorResponse { pub error: String } #[derive(Debug, Error)] pub enum AppError { #[error("Recurso no encontrado")] NotFound, #[error("Entrada inválida: {0}")] Validation(String), #[error("No autorizado")] Unauthorized, #[error("Operación prohibida")] Forbidden, #[error(transparent)] Sqlx(#[from] sqlx::Error), #[error(transparent)] Other(#[from] anyhow::Error), } impl ResponseError for AppError { fn status_code(&self) -> StatusCode { match self { AppError::NotFound => StatusCode::NOT_FOUND, AppError::Validation(_) => StatusCode::BAD_REQUEST, AppError::Unauthorized => StatusCode::UNAUTHORIZED, AppError::Forbidden => StatusCode::FORBIDDEN, AppError::Sqlx(_) | AppError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR, } } fn error_response(&self) -> HttpResponse { let body = ErrorResponse { error: self.to_string() }; HttpResponse::build(self.status_code()).json(body) } } |
Explicación: Unificamos errores en AppError y los convertimos en respuestas HTTP con ResponseError.
Checklist
- Tipos de error centralizados
- Respuestas JSON coherentes
8. Logging y trazas con tracing
Inicializa tracing en main:
1 2 3 4 5 6 7 8 9 10 11 |
use tracing_subscriber::{fmt, EnvFilter}; pub fn init_tracing() { let env_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("info,actix_web=info,sqlx=warn")); tracing_subscriber::registry() .with(env_filter) .with(fmt::layer().json().flatten_event(true)) .init(); } |
Explicación: Configuramos logging estructurado en JSON, útil en producción.
Checklist
- Logging estructurado activo
9. Seguridad: CORS, cabeceras y rate limiting
Middleware en main (veremos main completo en la sección 11):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
use actix_cors::Cors; use actix_web::{middleware, http::header}; use actix_governor::{GovernorConfigBuilder, Governor}; fn build_cors(allowed: &[String]) -> Cors { let mut cors = Cors::default().allow_any_method().allow_any_header(); if allowed.iter().any(|o| o == "*") { cors = cors.allow_any_origin(); } else { for o in allowed { cors = cors.allowed_origin(o); } } cors } fn rate_limit_mw(per_second: u64, burst: u32) -> Governor { let cfg = GovernorConfigBuilder::default() .per_second(per_second) .burst_size(burst) .finish() .unwrap(); Governor::new(&cfg) } |
Explicación: CORS configurable y rate limiting básico por IP. También añadiremos cabeceras de seguridad con DefaultHeaders.
Checklist
- CORS ajustado
- Rate limiting configurado
- Cabeceras seguras planificadas
10. Autenticación y autorización con JWT y Argon2
src/auth.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
use crate::errors::AppError; use argon2::{Argon2, PasswordHasher, PasswordVerifier}; use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey, Algorithm}; use password_hash::{SaltString, PasswordHash}; use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; use chrono::{Utc, Duration}; use uuid::Uuid; use actix_web::{FromRequest, HttpRequest}; use futures::future::{ready, Ready}; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub sub: Uuid, pub exp: usize, pub iss: String, } pub fn hash_password(plain: &str) -> Result<String, AppError> { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); let hash = argon2.hash_password(plain.as_bytes(), &salt)?.to_string(); Ok(hash) } pub fn verify_password(hash: &str, plain: &str) -> Result<bool, AppError> { let parsed = PasswordHash::new(hash)?; Ok(Argon2::default().verify_password(plain.as_bytes(), &parsed).is_ok()) } pub fn sign_token(user_id: Uuid, issuer: &str, minutes: i64, secret: &str) -> Result<String, AppError> { let exp = (Utc::now() + Duration::minutes(minutes)).timestamp() as usize; let claims = Claims { sub: user_id, exp, iss: issuer.to_string() }; Ok(encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(secret.as_bytes()))?) } pub fn decode_token(token: &str, secret: &str) -> Result<Claims, AppError> { let data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::new(Algorithm::HS256))?; Ok(data.claims) } // Extractor de usuario autenticado pub struct AuthUser(pub Uuid); impl FromRequest for AuthUser { type Error = actix_web::Error; type Future = Ready<Result<Self, actix_web::Error>>; fn from_request(req: &HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future { let secret = req.app_data::<String>().cloned(); // guardamos JWT_SECRET como String app_data let auth = req.headers().get("Authorization").and_then(|h| h.to_str().ok()).map(|s| s.to_string()); ready(match (secret, auth) { (Some(secret), Some(header)) if header.starts_with("Bearer ") => { let token = header.trim_start_matches("Bearer "); match decode_token(token, &secret) { Ok(claims) => Ok(AuthUser(claims.sub)), Err(_) => Err(actix_web::error::ErrorUnauthorized("Token inválido")), } } _ => Err(actix_web::error::ErrorUnauthorized("Falta token")), }) } } |
Explicación: Hasheamos contraseñas con Argon2 y firmamos/validamos JWT HS256. AuthUser extrae el id de usuario desde Authorization: Bearer.
Checklist
- Hash y verificación implementados
- JWT firmado/validado
- Extractor AuthUser listo
11. Rutas y CRUD REST (tareas) con paginación/filtrado/ordenación
src/handlers/auth.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
use actix_web::{post, web, HttpResponse}; use sqlx::PgPool; use validator::Validate; use uuid::Uuid; use crate::{auth::{hash_password, verify_password, sign_token}, errors::AppError, models::{RegisterDto, LoginDto}}; #[post("/register")] pub async fn register(pool: web::Data<PgPool>, form: web::Json<RegisterDto>) -> Result<HttpResponse, AppError> { form.validate().map_err(|e| AppError::Validation(e.to_string()))?; let hash = hash_password(&form.password)?; let rec = sqlx::query!( "INSERT INTO users (email, password_hash) VALUES ($1,$2) RETURNING id", form.email, hash ).fetch_one(pool.get_ref()).await?; Ok(HttpResponse::Created().json(serde_json::json!({"id": rec.id}))) } #[post("/login")] pub async fn login(pool: web::Data<PgPool>, secret: web::Data<String>, form: web::Json<LoginDto>) -> Result<HttpResponse, AppError> { form.validate().map_err(|e| AppError::Validation(e.to_string()))?; let user = sqlx::query!("SELECT id, password_hash FROM users WHERE email = $1", form.email) .fetch_optional(pool.get_ref()).await?; if let Some(u) = user { if verify_password(&u.password_hash, &form.password)? { let token = sign_token(u.id, "actix-tasks-api", 60, secret.get_ref())?; return Ok(HttpResponse::Ok().json(serde_json::json!({"token": token}))) } } Err(AppError::Unauthorized) } |
Explicación: Registro y login. Devolvemos id en register y token JWT en login.
src/handlers/tasks.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
use actix_web::{get, post, put, delete, web, HttpResponse}; use validator::Validate; use sqlx::{PgPool, Row}; use uuid::Uuid; use crate::{auth::AuthUser, errors::AppError, models::{CreateTaskDto, UpdateTaskDto}}; #[post("/tasks")] pub async fn create_task(pool: web::Data<PgPool>, user: AuthUser, body: web::Json<CreateTaskDto>) -> Result<HttpResponse, AppError> { body.validate().map_err(|e| AppError::Validation(e.to_string()))?; let rec = sqlx::query!( "INSERT INTO tasks (title, description, owner_id) VALUES ($1,$2,$3) RETURNING id", body.title, body.description, user.0 ).fetch_one(pool.get_ref()).await?; Ok(HttpResponse::Created().json(serde_json::json!({"id": rec.id}))) } #[get("/tasks")] pub async fn list_tasks( pool: web::Data<PgPool>, user: AuthUser, q: web::Query<std::collections::HashMap<String, String>>, ) -> Result<HttpResponse, AppError> { let page: i64 = q.get("page").and_then(|v| v.parse().ok()).unwrap_or(1); let per_page: i64 = q.get("per_page").and_then(|v| v.parse().ok()).unwrap_or(10); let status = q.get("status"); let sort = q.get("sort").map(String::as_str).unwrap_or("-created_at"); let order_clause = if sort.starts_with('-') { format!("{} DESC", &sort[1..]) } else { format!("{} ASC", sort) }; let base = "SELECT id, title, description, status, owner_id, created_at, updated_at FROM tasks WHERE owner_id = $1"; let (sql, args) = if let Some(s) = status { (format!("{} AND status = $2 ORDER BY {} LIMIT $3 OFFSET $4", base, order_clause), vec![user.0.to_string(), s.clone(), per_page.to_string(), ((page-1)*per_page).to_string()]) } else { (format!("{} ORDER BY {} LIMIT $2 OFFSET $3", base, order_clause), vec![user.0.to_string(), per_page.to_string(), ((page-1)*per_page).to_string()]) }; let rows = sqlx::query(&sql).bind(user.0).bind_opt(status).bind(per_page).bind((page-1)*per_page).fetch_all(pool.get_ref()).await?; let tasks: Vec<serde_json::Value> = rows.into_iter().map(|r| { serde_json::json!({ "id": r.get::<Uuid,_>("id"), "title": r.get::<String,_>("title"), "description": r.try_get::<String,_>("description").ok(), "status": r.get::<String,_>("status"), "owner_id": r.get::<Uuid,_>("owner_id"), "created_at": r.get::<chrono::DateTime<chrono::Utc>,_>("created_at"), "updated_at": r.get::<chrono::DateTime<chrono::Utc>,_>("updated_at"), }) }).collect(); Ok(HttpResponse::Ok().json(serde_json::json!({ "data": tasks, "page": page, "per_page": per_page }))) } #[get("/tasks/{id}")] pub async fn get_task(pool: web::Data<PgPool>, user: AuthUser, path: web::Path<Uuid>) -> Result<HttpResponse, AppError> { let id = path.into_inner(); let rec = sqlx::query!( "SELECT id, title, description, status, owner_id, created_at, updated_at FROM tasks WHERE id=$1 AND owner_id=$2", id, user.0 ).fetch_optional(pool.get_ref()).await?; match rec { Some(r) => Ok(HttpResponse::Ok().json(r)), None => Err(AppError::NotFound) } } #[put("/tasks/{id}")] pub async fn update_task(pool: web::Data<PgPool>, user: AuthUser, path: web::Path<Uuid>, body: web::Json<UpdateTaskDto>) -> Result<HttpResponse, AppError> { body.validate().map_err(|e| AppError::Validation(e.to_string()))?; let id = path.into_inner(); let rec = sqlx::query!( "UPDATE tasks SET title = COALESCE($1, title), description = COALESCE($2, description), status = COALESCE($3, status), updated_at = now() WHERE id=$4 AND owner_id=$5 RETURNING id", body.title, body.description, body.status, id, user.0 ).fetch_optional(pool.get_ref()).await?; match rec { Some(r) => Ok(HttpResponse::Ok().json(serde_json::json!({"id": r.id}))), None => Err(AppError::NotFound) } } #[delete("/tasks/{id}")] pub async fn delete_task(pool: web::Data<PgPool>, user: AuthUser, path: web::Path<Uuid>) -> Result<HttpResponse, AppError> { let id = path.into_inner(); let res = sqlx::query!("DELETE FROM tasks WHERE id=$1 AND owner_id=$2", id, user.0) .execute(pool.get_ref()).await?; if res.rows_affected() == 1 { Ok(HttpResponse::NoContent().finish()) } else { Err(AppError::NotFound) } } |
Explicación: Implementamos CRUD con paginación, filtros por status y sort por campo con – para descendente. Restringimos por owner_id del token.
src/routes.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
use actix_web::web; use crate::handlers::{tasks, auth}; pub fn api(cfg: &mut web::ServiceConfig) { cfg.service(auth::register) .service(auth::login) .service(tasks::create_task) .service(tasks::list_tasks) .service(tasks::get_task) .service(tasks::update_task) .service(tasks::delete_task); } |
Explicación: Registramos endpoints.
src/main.rs (ensamblado):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
mod config; mod db; mod errors; mod models; mod auth; mod routes; mod docs; use actix_web::{App, HttpServer, middleware, web}; use tracing_actix_web::TracingLogger; use utoipa_swagger_ui::SwaggerUi; use utoipa::OpenApi; #[actix_web::main] async fn main() -> std::io::Result<()> { tutorial_init().await } async fn tutorial_init() -> std::io::Result<()> { tutorial_greet(); init_tracing(); let (settings, database_url) = config::load().expect("config"); let pool = db::connect_pool(&database_url, settings.database.max_connections, settings.database.connect_timeout_secs, settings.database.acquire_timeout_secs, settings.database.idle_timeout_secs, ).await.expect("db"); // Ejecutar migraciones al inicio sqlx::migrate!("migrations").run(&pool).await.expect("migrations"); let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET"); let addr = format!("{}:{}", settings.server.host, settings.server.port); println!("Listening on http://{}", addr); HttpServer::new(move || { App::new() .app_data(web::Data::new(pool.clone())) .app_data(web::Data::new(jwt_secret.clone())) .wrap(TracingLogger::default()) .wrap(rate_limit_mw(settings.rate_limit.per_second, settings.rate_limit.burst_size)) .wrap(build_cors(&settings.cors.allowed_origins)) .wrap(middleware::DefaultHeaders::new() .add(("X-Content-Type-Options", "nosniff")) .add(("X-Frame-Options", "DENY")) .add(("Referrer-Policy", "no-referrer"))) .wrap(actix_web_prom::PrometheusMetricsBuilder::new("api").endpoint("/metrics").build().unwrap()) .configure(routes::api) .service(SwaggerUi::new("/docs").url("/api-doc/openapi.json", docs::ApiDoc::openapi())) .route("/health", web::get().to(|| async { "OK" })) }) .workers(num_cpus::get()) .bind(addr)? .run() .await } // Stubs reusados use tracing_subscriber::{fmt, EnvFilter, prelude::*}; fn init_tracing() { let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,actix_web=info")); tracing_subscriber::registry().with(env_filter).with(fmt::layer()).init(); } fn build_cors(allowed: &[String]) -> actix_cors::Cors { let mut c = actix_cors::Cors::default().allow_any_header().allow_any_method(); if allowed.iter().any(|o| o=="*") { c = c.allow_any_origin(); } else { for o in allowed { c = c.allowed_origin(o); } } c } fn rate_limit_mw(per_second: u64, burst: u32) -> actix_governor::Governor { let cfg = actix_governor::GovernorConfigBuilder::default().per_second(per_second).burst_size(burst).finish().unwrap(); actix_governor::Governor::new(&cfg) } fn tutorial_greet() { println!("Starting API REST Rust con Actix Web"); } |
Explicación: Ensamblamos el servidor: migraciones al inicio, middlewares de trazas, CORS, cabeceras y rate limiting; exponemos /health y /metrics; servimos Swagger en /docs.
Probar endpoints con HTTPie:
1 2 3 4 5 6 7 8 9 10 |
# Registro http POST :8080/register email=me@example.com password=secret1234 # Login -> copia el token http POST :8080/login email=me@example.com password=secret1234 # Crear tarea (usa el token) http POST :8080/tasks "Authorization:Bearer $TOKEN" title="Aprender Actix" description="Leer docs" # Listado con paginación, filtro y ordenación (-created_at) http :8080/tasks "Authorization:Bearer $TOKEN" page==1 per_page==10 status==pending sort==-created_at |
Checklist
- Endpoints CRUD operativos
- Seguridad por usuario aplicada
- Healthcheck y métricas expuestos
12. Documentación OpenAPI (utoipa + swagger-ui)
src/docs.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
use utoipa::{OpenApi, Modify, openapi::security::{SecurityScheme, HttpAuthScheme}}; use utoipa::openapi::security::HttpBuilder; use crate::models::{CreateTaskDto, UpdateTaskDto, LoginDto, RegisterDto, Task}; #[derive(OpenApi)] #[openapi( paths( crate::handlers::auth::register, crate::handlers::auth::login, crate::handlers::tasks::create_task, crate::handlers::tasks::list_tasks, crate::handlers::tasks::get_task, crate::handlers::tasks::update_task, crate::handlers::tasks::delete_task, ), components(schemas(Task, CreateTaskDto, UpdateTaskDto, RegisterDto, LoginDto)), modifiers(&SecurityAddon), tags((name = "tasks", description = "Gestión de tareas")) )] pub struct ApiDoc; pub struct SecurityAddon; impl Modify for SecurityAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { let add = utoipa::openapi::security::SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()); openapi.components = openapi.components.clone().map(|mut c| { c.add_security_scheme("bearer_auth", add); c }); } } |
Explicación: Generamos OpenAPI y añadimos esquema de seguridad Bearer JWT. Swagger UI servirá en /docs.
Checklist
- OpenAPI generado
- Swagger UI accesible en /docs
13. Pruebas unitarias e integración
Unit test simple de validación:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// src/models.rs (al final) #[cfg(test)] mod tests { use super::*; #[test] fn create_task_validation() { let dto = CreateTaskDto { title: "ok".into(), description: None }; assert!(dto.validate().is_ok()); let bad = CreateTaskDto { title: "no".into(), description: None }; assert!(bad.validate().is_err()); } } |
Explicación: Probamos reglas de validator.
Integración con servidor en memoria:
1 2 3 4 5 6 7 8 9 10 11 12 |
// tests/tasks_it.rs use actix_web::{test, App}; use actix_tasks_api::{routes, auth, config, db}; // ajusta el path según crate name #[actix_rt::test] async fn health_ok() { let app = test::init_service(App::new().route("/health", actix_web::web::get().to(|| async { "OK" }))).await; let req = test::TestRequest::get().uri("/health").to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); } |
Explicación: Prueba básica. Para pruebas de DB, usa Testcontainers o un Postgres en Docker Compose en CI antes de cargo test
.
Testcontainers (opcional, solo referencia):
1 2 |
// Pseudocódigo: usa crate testcontainers para levantar Postgres en tests |
Checklist
- Unit tests agregados
- Integración mínima validada
14. Rendimiento y concurrencia
Buenas prácticas:
- Configura tamaño del pool SQLx acorde a CPUs y carga (p.ej., 10-20)
- Usa timeouts estrictos para evitar bloqueos
- Actix Web: ajusta workers (por defecto = núcleos) y backlog
- Respuestas JSON ligeras y paginación obligatoria
- Evita N+1 consultas; usa índices adecuados (ya añadimos en status/owner)
- Aplica backpressure: no hagas bloqueantes en request handlers
Checklist
- Pool y workers ajustados
- Consultas e índices revisados
15. Contenedorización con Docker y Docker Compose
Dockerfile multi-stage:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# Etapa de build FROM rust:1.80 as builder WORKDIR /app RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* COPY Cargo.toml Cargo.lock ./ COPY src ./src COPY config ./config COPY migrations ./migrations RUN cargo build --release # Etapa runtime FROM debian:stable-slim RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /app/target/release/actix-tasks-api /usr/local/bin/actix-tasks-api ENV RUST_LOG=info EXPOSE 8080 CMD ["/usr/local/bin/actix-tasks-api"] |
Explicación: Build en imagen Rust y runtime mínima. Incluye ca-certificates para TLS.
docker-compose.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
version: "3.9" services: db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: tasks_db ports: ["5432:5432"] healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 10 api: build: . environment: DATABASE_URL: postgres://postgres:postgres@db:5432/tasks_db JWT_SECRET: super-secreto-cambia-esto APP_ENV: production RUST_LOG: info,actix_web=info depends_on: db: condition: service_healthy ports: ["8080:8080"] |
Explicación: Orquestamos API + Postgres. La API espera a que la DB esté saludable.
Checklist
- Dockerfile compilando
- Compose levantando stack local
16. Preparación para despliegue y observabilidad
Recomendaciones:
- Variables de entorno seguras (JWT_SECRET desde el entorno/secret manager)
- Healthcheck /health y métricas /metrics para Prometheus
- Migraciones en startup (ya implementadas)
- Logs JSON y niveles adecuados
- CORS restrictivo en producción (dominios específicos)
- Instrumentar endpoints críticos con tracing spans
Checklist
- Salud y métricas habilitadas
- Secrets gestionados correctamente
- CORS endurecido
17. Resolución de problemas comunes (FAQ técnica)
- Error: sqlx::Error(Database) connection refused
- Verifica DATABASE_URL, que Postgres esté activo y puertos expuestos. En Docker, usa el nombre de servicio (db) en lugar de localhost.
- Migraciones fallan por falta de extensión genrandomuuid
- Habilita la extensión: CREATE EXTENSION IF NOT EXISTS pgcrypto; añádelo en la primera migración.
- Token JWT inválido/expirado
- Confirma que usas el mismo JWT_SECRET para firmar y validar; revisa exp/iss.
- CORS bloquea peticiones desde el navegador
- Ajusta allowed_origins en config para incluir el dominio frontend.
- Rate limiting demasiado agresivo
- Incrementa per_second/burst en configuración o excluye rutas internas.
- Hash de contraseña incompatible
- Usa Argon2id con parámetros por defecto; no guardes contraseñas en claro.
- Errores 500 opacos
- Activa RUST_LOG=debug y revisa logs/tracing; implementa AppError con más contexto.
- Pruebas con DB inestables en CI
- Levanta Postgres con docker compose en job antes de cargo test; aplica migraciones.
18. Conclusión y próximos pasos
Has construido una API REST Rust segura con Actix Web, SQLx PostgreSQL, autenticación JWT, validación, logging, rate limiting y documentación OpenAPI. Contenedorizaste con Docker y orquestaste con Docker Compose, y preparaste salud, métricas y migraciones para despliegue.
Próximos pasos y llamada a la acción:
- Extiende el modelo (etiquetas de tareas, comentarios) y añade tests de integración completos
- Implementa refresh tokens y roles/autorización granular
- Añade caché (Redis) para listas y rate limiting distribuido
- Despliega en tu cloud favorito con una base de datos gestionada y Prometheus + Grafana para observabilidad
Si este tutorial te ayudó, comparte la guía y lánzate a crear tu próxima API REST Rust con Actix Web en producción.