Guía práctica: Construye una API REST de producción en Rust con Actix y Diesel
En esta guía paso a paso crearás una API REST lista para producción con Rust, Actix Web y Diesel sobre PostgreSQL. El enfoque es práctico, con comandos reproducibles, código completo, autenticación JWT, observabilidad con Prometheus y contenedorización con Docker.
Diagrama general del flujo de una petición:
1 2 3 4 |
Cliente -> HTTP -> [Middleware: Logging/Tracing] -> [Middleware: CORS/RateLimit/JWT] -> Handler -> Repositorio -> DB (PostgreSQL) -> [Middleware: Compression] -> Respuesta JSON |
1) Requisitos previos e instalación
Resumen: instalarás Rust estable, herramientas opcionales (diesel_cli, cargo-make), Docker/Compose y utilidades como psql y httpie/curl.
Pasos:
- Instala Rust y cargo con rustup.
- Instala diesel_cli si usarás migraciones Diesel.
- Verifica Docker y Docker Compose.
- Asegúrate de tener psql y curl/httpie.
Comandos:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
# Rust estable curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source "$HOME/.cargo/env" # Verificación de versiones rustc --version cargo --version # (Opcional) Headers de libpq para diesel_cli (Debian/Ubuntu) sudo apt-get update && sudo apt-get install -y libpq-dev pkg-config build-essential # Instalar diesel_cli para PostgreSQL cargo install diesel_cli --no-default-features --features postgres diesel --version # Docker y Docker Compose docker --version docker compose version # psql y httpie/curl psql --version || echo "Instala psql con tu gestor de paquetes" http --version || curl --version |
Explicación: rustup instala Rust y cargo. diesel_cli requiere libpq para enlazar con PostgreSQL. Docker/Compose permiten orquestar la base de datos y la API localmente.
Checklist:
- [ ] rustc y cargo instalados
- [ ] diesel_cli funciona (opcional)
- [ ] Docker y Compose operativos
- [ ] psql y httpie/curl disponibles
Resolución de problemas:
- Si diesel_cli falla, verifica libpq-dev y pkg-config. En macOS: brew install libpq pkg-config y exporta PATH si es necesario.
2) Estructura del proyecto y configuración (API Rust con Actix y Diesel)
Resumen: crea el proyecto con cargo y define un layout mantenible con módulos claros.
Pasos:
1 2 3 4 5 |
cargo new --bin rust_api_demo cd rust_api_demo mkdir -p src/{routes,handlers,models,db,config,auth,middleware,errors,services} mkdir -p migrations tests docker .github/workflows |
Sugerencia de layout:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
src/ main.rs config/mod.rs db/mod.rs db/pool.rs handlers/auth.rs middleware/jwt_middleware.rs models/{mod.rs,user.rs,product.rs,order.rs} schema.rs migrations/ tests/integration_tests.rs docker/{Dockerfile} docker-compose.yml .env.example README.md |
Explicación: separar configuración, DB, modelos, handlers y middleware mejora mantenibilidad y testeo.
Checklist:
- [ ] Proyecto inicializado
- [ ] Carpetas creadas
- [ ] .env y .env.example planificados por entorno
Resolución de problemas:
- Usa .env.example para documentar variables y evitar subir secretos reales al repositorio.
3) Esquema y migraciones (Migraciones Diesel PostgreSQL)
Resumen: definirás tablas users, products, orders y order_items con índices y soft-delete opcional.
Pasos con Diesel:
1 2 3 4 5 6 7 8 9 10 |
# Configura tu DATABASE_URL para desarrollo export DATABASE_URL=postgres://postgres:postgres@localhost:5432/rust_api_demo # Si no tienes Postgres local, arráncalo con Docker (ver sección 11) # Inicializar Diesel diesel setup # Crear migración inicial diesel migration generate init |
Edita las migraciones generadas (ver sección 15 para SQL completo) y ejecútalas:
1 2 |
diesel migration run |
Alternativa sin diesel_cli: usa Refinery en build-time o en un binario de migraciones.
Explicación: Diesel genera y aplica migraciones en orden. Los índices y constraints garantizan integridad y rendimiento.
Checklist:
- [ ] DATABASE_URL configurada
- [ ] Migraciones creadas y ejecutadas
- [ ] Tablas visibles con psql
Resolución de problemas:
- Si diesel setup falla, revisa conexión a Postgres y credenciales.
4) Modelos, acceso a datos y pool (r2d2)
Resumen: mapea tablas a structs, configura el pool de conexiones y ejecuta queries sin bloquear Actix usando web::block.
Pasos:
- Genera schema.rs con diesel print-schema.
- Crea structs con #[derive(Identifiable, Queryable, Insertable)].
- Configura r2d2 y helpers para offload de consultas.
Código breve (detalles completos en sección 15):
1 2 3 4 5 6 7 8 |
// src/db/pool.rs use diesel::{r2d2::{ConnectionManager, Pool}, PgConnection}; pub type DbPool = Pool<ConnectionManager<PgConnection>>; pub fn init_pool(database_url: &str) -> DbPool { let manager = ConnectionManager::<PgConnection>::new(database_url); Pool::builder().max_size(15).build(manager).expect("pool") } |
Explicación: se crea un pool r2d2 con conexiones PostgreSQL; max_size debe calibrarse.
Checklist:
- [ ] schema.rs generado
- [ ] Modelos creados
- [ ] Pool inicializado
Resolución de problemas:
- Error de bloqueo: siempre usa web::block para Diesel (sincrónico) o migra a SQLx async en escenarios de alto throughput.
5) Servidor HTTP con Actix Web: rutas y middlewares
Resumen: inicializa Actix, registra middlewares de logging/tracing, CORS, compresión, rate limit y rutas básicas.
Pasos:
1 2 |
cargo run |
Código breve (ver completo en sección 15):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// src/main.rs (fragmento) HttpServer::new(move || { App::new() .app_data(Data::new(pool.clone())) .wrap(Logger::default()) .wrap(NormalizePath::trim()) .wrap(Compress::default()) .wrap(cors) .wrap(rate_limiter()) .wrap(default_security_headers()) .service(health) .service(metrics) .service(web::scope("/auth").service(register).service(login)) .service(web::scope("/api") .wrap(JwtMiddleware::new(cfg.jwt_secret.clone(), cfg.jwt_issuer.clone())) .service(me)) }) .bind((cfg.host.as_str(), cfg.port))? .workers(num_cpus::get()) .run() .await |
Explicación: se compone el pipeline HTTP con middlewares y scopes, aislando rutas protegidas con el middleware JWT.
Checklist:
- [ ] Servidor arranca en puerto configurado
- [ ] CORS, compresión y logging activos
Resolución de problemas:
- Si CORS bloquea peticiones, ajusta los orígenes permitidos.
6) Autenticación y autorización (Autenticación JWT en Rust)
Resumen: implementa registro y login con contraseñas Argon2 y emisión/validación de JWT (HS256). Añade roles básicos.
Pasos:
- Hash con Argon2 al registrar
- Verifica con Argon2 al login
- Emite JWT con claims {sub, role, exp, iss}
- Middleware valida y adjunta claims
Prueba rápida:
1 2 3 4 5 6 7 |
# Registro http POST :8080/auth/register email=test@example.com password=Secret123 name="Test User" # Login http POST :8080/auth/login email=test@example.com password=Secret123 # Acceso protegido http GET :8080/api/me Authorization:"Bearer <TOKEN>" |
Explicación: el middleware extrae Authorization: Bearer, valida el token y añade Claims a la request.
Checklist:
- [ ] Hash seguro Argon2
- [ ] JWT firmado y verificado
- [ ] Rutas protegidas funcionan
Resolución de problemas:
- “Invalid token”: revisa clocks (exp), issuer y secret correcto.
7) Manejo de errores y respuestas JSON estandarizadas
Resumen: thiserror para errores de dominio y ResponseError para mapear a HTTP con una forma JSON uniforme.
Código breve:
1 2 3 4 5 6 7 8 9 |
#[derive(Debug, thiserror::Error)] pub enum ApiError { #[error("No autorizado")] Unauthorized, #[error("Recurso no encontrado")] NotFound, #[error("Entrada inválida: {0}")] Validation(String), #[error("Error interno")] Internal, } impl ResponseError for ApiError { /* mapear a HttpResponse con JSON {error, code} */ } |
Explicación: centralizar errores facilita logs, métricas y clientes predecibles.
Checklist:
- [ ] Respuestas con códigos correctos
- [ ] Mensajes no filtran detalles sensibles
Resolución de problemas:
- Envía IDs de correlación en errores para rastrear en logs.
8) Serialización y validación (serde + validator)
Resumen: valida payloads con validator y serializa con serde.
Código breve:
1 2 3 4 5 6 7 |
#[derive(Deserialize, Validate)] pub struct RegisterDto { #[validate(email)] pub email: String, #[validate(length(min = 8))] pub password: String, #[validate(length(min = 2, max = 64))] pub name: String, } |
Explicación: valida al inicio del handler y retorna 422 si falla.
Checklist:
- [ ] DTOs con derives serde/validator
- [ ] 422 para errores de validación
Resolución de problemas:
- Añade mensajes claros por campo para DX de clientes.
9) Tests unitarios e integración
Resumen: cubre lógica de dominio y endpoints. Para integración, usa una BD de prueba y aplica migraciones en el setup.
Comandos:
1 2 3 4 5 6 7 |
# Sube una BD de test docker compose up -d db # Exporta DATABASE_URL apuntando a DB de test export DATABASE_URL=postgres://postgres:postgres@localhost:5433/rust_api_demo_test # Ejecuta tests cargo test |
Explicación: los tests pueden invocar “diesel migration run” durante el setup y revertir al final; ver sección 15.
Checklist:
- [ ] Tests unitarios de hashing/JWT
- [ ] Tests de integración para /auth y /healthz
Resolución de problemas:
- Aísla la BD de test en otro puerto y esquema para evitar colisiones.
10) Observabilidad y métricas (Observabilidad Rust Prometheus)
Resumen: integra tracing y un endpoint /metrics para Prometheus. Opcional: exportador OTLP para OpenTelemetry.
Pasos:
- Inicializa tracing_subscriber con EnvFilter
- Crea un Registry de Prometheus y expón /metrics
- (Opcional) Inicia un tracer OTLP via OTELEXPORTEROTLP_ENDPOINT
Explicación: logs estructurados + métricas + trazas facilitan debugar y operar en producción.
Checklist:
- [ ] Logs JSON o texto con niveles adecuados
- [ ] /metrics expone métricas
- [ ] Healthz y readiness disponibles
Resolución de problemas:
- Si Prometheus no scrapea, revisa red y path /metrics configurado.
11) Contenedorización y orquestación local (Docker Actix Rust)
Resumen: usa un Dockerfile multi-stage y docker-compose con servicios api, postgres y opcional prometheus.
Comandos:
1 2 3 4 5 6 7 |
# Construir imagen docker build -t rust-api-demo:latest -f docker/Dockerfile . # Orquestar local docker compose up -d --build # Ver logs docker compose logs -f api |
Explicación: multi-stage reduce tamaño y runtime incluye libpq. Healthchecks automatizan dependencias.
Checklist:
- [ ] Imagen pequeña y no-root user
- [ ] Variables de entorno sin secretos en git
Resolución de problemas:
- Si el binario falla por libpq, añade libpq5 en la imagen final.
12) Seguridad en producción
Resumen: endurece tu API con TLS en el edge, headers seguros, límites de payload, CORS restringido y rotación de JWT keys.
Pasos:
- Termina TLS en LB/ingress (Nginx, Envoy, ALB)
- Headers: HSTS, X-Frame-Options, X-Content-Type-Options
- Limita tamaño de payload y tiempo de request
- CORS sólo a orígenes confiables
- Rota secretos JWT y usa RS256 si gestionas llaves públicas/privadas
Checklist:
- [ ] TLS sólido y red segura
- [ ] CORS restringido
- [ ] Rotación de secretos documentada
Resolución de problemas:
- Automatiza la renovación de claves y configúralo como rolling deploy.
13) Rendimiento y buenas prácticas
Resumen: evita bloquear el executor, calibra pools, usa compresión y benchmarkea con hey/wrk.
Pasos:
- Offload Diesel con web::block o usa SQLx async
- Ajusta pool DB (max_size) y timeouts
- Añade índices a columnas de filtrado
- Habilita gzip y keep-alive
- Benchmarks: wrk -t4 -c64 -d30s http://localhost:8080/healthz
Checklist:
- [ ] Sin bloqueos en el hilo de Actix
- [ ] Índices verificados
- [ ] Límites de payload y timeouts configurados
Resolución de problemas:
- Usa pgbouncer si necesitas más conexiones concurrentes.
14) CI/CD y despliegue (GitHub Actions)
Resumen: pipeline con fmt/clippy/test, build/push Docker y despliegue con migraciones.
Comandos locales:
1 2 3 4 |
cargo fmt -- --check cargo clippy -- -D warnings cargo test |
Explicación: automatiza calidad y despliegue seguro; migra antes de levantar la nueva versión o en job de pre-deploy.
Checklist:
- [ ] Cache de dependencias
- [ ] Secretos del registry seguros
- [ ] Paso de migraciones atómico/seguro
Resolución de problemas:
- Usa estrategias blue/green o rolling para evitar downtime durante migraciones.
15) Archivos y ejemplos clave (copiar/pegar)
A continuación, el código esencial completo. Después de cada bloque hay una explicación breve.
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 = "rust_api_demo" version = "0.1.0" edition = "2021" [dependencies] actix-web = "4" actix-cors = "0.6" actix-web-lab = { version = "0.20", features=["headers"] } num_cpus = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" validator = { version = "0.16", features=["derive"] } thiserror = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features=["env-filter", "fmt", "json"] } jsonwebtoken = "9" argon2 = "0.5" rand = "0.8" diesel = { version = "2.1", features = ["postgres", "r2d2", "chrono", "serde_json"] } r2d2 = "0.8" chrono = { version = "0.4", features=["serde"] } config = "0.14" dotenvy = "0.15" prometheus = "0.13" opentelemetry = { version = "0.21", features=["rt-tokio"] } tracing-opentelemetry = "0.22" once_cell = "1" governor = "0.6" [dev-dependencies] actix-rt = "2" |
Explicación: dependencias para HTTP, seguridad, DB, observabilidad y validación.
src/main.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 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
use actix_cors::Cors; use actix_web::{dev::Service, middleware::{Compress, Logger, NormalizePath}, web, App, HttpResponse, HttpServer, Responder}; use actix_web_lab::middleware::from_fn as wrap_fn; use prometheus::{Encoder, TextEncoder, Registry, IntCounterVec}; use std::sync::Arc; use tracing_subscriber::{fmt, EnvFilter}; mod config; mod db; mod handlers; mod middleware; mod models; mod schema; use crate::{config::Settings, db::pool::init_pool, handlers::auth::{login, register, me}, middleware::jwt_middleware::JwtMiddleware}; #[actix_web::get("/healthz")] async fn health() -> impl Responder { HttpResponse::Ok().json(serde_json::json!({"status":"ok"})) } async fn metrics(registry: web::Data<Arc<Registry>>) -> impl Responder { let encoder = TextEncoder::new(); let metric_families = registry.gather(); let mut buffer = Vec::new(); encoder.encode(&metric_families, &mut buffer).unwrap(); HttpResponse::Ok().content_type(encoder.format_type()).body(buffer) } fn init_tracing() { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,actix_web=info")); fmt().with_env_filter(filter).init(); } fn default_security_headers() -> impl actix_web::middleware::Transform<actix_web::dev::ServiceRequest, Service = impl Service, InitError = (), Response = actix_web::dev::ServiceResponse, Error = actix_web::Error> { actix_web_lab::middleware::rebind::DefaultHeaders::new() .add(("X-Frame-Options", "DENY")) .add(("X-Content-Type-Options", "nosniff")) .add(("Referrer-Policy", "no-referrer")) } fn rate_limiter() -> impl actix_web::middleware::Transform<actix_web::dev::ServiceRequest, Service = impl Service, InitError = (), Response = actix_web::dev::ServiceResponse, Error = actix_web::Error> { use governor::{Quota, RateLimiter}; use nonzero_ext::nonzero; use std::num::NonZeroU32; let limiter = Arc::new(RateLimiter::direct(Quota::per_second(nonzero!(50u32)))); wrap_fn(move |req, srv| { let limiter = limiter.clone(); async move { if limiter.check().is_err() { return Ok(req.into_response(HttpResponse::TooManyRequests().finish())); } srv.call(req).await } }) } #[actix_web::main] async fn main() -> std::io::Result<()> { dotenvy::dotenv().ok(); init_tracing(); let settings = Settings::from_env().expect("config"); let pool = init_pool(&settings.database_url); let registry = Arc::new(Registry::new()); let http_counter = IntCounterVec::new( prometheus::Opts::new("http_requests_total", "Total de peticiones HTTP"), &["method", "path", "status"] ).unwrap(); registry.register(Box::new(http_counter.clone())).unwrap(); let cors = Cors::default().allow_any_method().allow_any_header().allowed_origin(&settings.cors_origin); HttpServer::new(move || { let http_counter = http_counter.clone(); App::new() .app_data(web::Data::new(pool.clone())) .app_data(web::Data::new(registry.clone())) .wrap(Logger::default()) .wrap(NormalizePath::trim()) .wrap(Compress::default()) .wrap(cors.clone()) .wrap(rate_limiter()) .wrap(default_security_headers()) .wrap(wrap_fn(move |req, srv| { // métricas por request let http_counter = http_counter.clone(); let method = req.method().to_string(); let path = req.path().to_string(); async move { let res = srv.call(req).await?; let status = res.status().as_u16().to_string(); http_counter.with_label_values(&[&method, &path, &status]).inc(); Ok(res) } })) .service(health) .route("/metrics", web::get().to(metrics)) .service(web::scope("/auth").service(register).service(login)) .service(web::scope("/api") .wrap(JwtMiddleware::new(settings.jwt_secret.clone(), settings.jwt_issuer.clone())) .service(me)) }) .bind((settings.host.as_str(), settings.port))? .workers(num_cpus::get()) .run() .await } |
Explicación: servidor Actix con middlewares, métricas Prometheus, rutas de auth y ruta protegida /api/me.
src/config/mod.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
use serde::Deserialize; use config::{Config, File}; #[derive(Debug, Deserialize, Clone)] pub struct Settings { pub host: String, pub port: u16, pub database_url: String, pub jwt_secret: String, pub jwt_issuer: String, pub cors_origin: String, } impl Settings { pub fn from_env() -> Result<Self, config::ConfigError> { let builder = Config::builder() .add_source(File::with_name("config/default").required(false)) .add_source(config::Environment::default().separator("__")); let cfg = builder.build()?; cfg.try_deserialize() } } |
Explicación: lectura de configuración desde archivo y variables de entorno.
src/db/mod.rs y src/db/pool.rs
1 2 3 4 5 6 7 8 9 10 11 |
// src/db/mod.rs pub mod pool; // src/db/pool.rs use diesel::{r2d2::{ConnectionManager, Pool}, PgConnection}; pub type DbPool = Pool<ConnectionManager<PgConnection>>; pub fn init_pool(database_url: &str) -> DbPool { let manager = ConnectionManager::<PgConnection>::new(database_url); Pool::builder().max_size(15).build(manager).expect("crear pool") } |
Explicación: pool de conexiones r2d2 para PostgreSQL.
migrations/ iniciales
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 |
-- migrations/<timestamp>_init/up.sql CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); CREATE TABLE products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, sku TEXT NOT NULL UNIQUE, price_cents BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); CREATE TABLE orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), status TEXT NOT NULL DEFAULT 'pending', total_cents BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE order_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES products(id), qty INT NOT NULL CHECK (qty > 0), price_cents BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_orders_user_id ON orders(user_id); CREATE INDEX idx_order_items_order_id ON order_items(order_id); CREATE INDEX idx_products_sku ON products(sku); |
1 2 3 4 5 6 |
-- migrations/<timestamp>_init/down.sql DROP TABLE IF EXISTS order_items; DROP TABLE IF EXISTS orders; DROP TABLE IF EXISTS products; DROP TABLE IF EXISTS users; |
Explicación: esquema básico con relaciones, índices y soft-delete en users/products.
Nota: activa la extensión pgcrypto o uuid-ossp para genrandomuuid() si tu Postgres no la tiene:
1 2 |
CREATE EXTENSION IF NOT EXISTS pgcrypto; |
src/models y src/schema.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 |
// src/models/mod.rs pub mod user; // src/models/user.rs use chrono::{DateTime, Utc}; use diesel::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Debug, Queryable, Identifiable, Serialize)] #[diesel(table_name = crate::schema::users)] pub struct User { pub id: uuid::Uuid, pub email: String, pub password_hash: String, pub name: String, pub role: String, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, pub deleted_at: Option<DateTime<Utc>>, } #[derive(Insertable, Deserialize)] #[diesel(table_name = crate::schema::users)] pub struct NewUser { pub email: String, pub password_hash: String, pub name: String, pub role: String, } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// src/schema.rs (generado por diesel print-schema - ej. parcial) // @generated automatically by Diesel CLI. diesel::table! { use diesel::sql_types::*; use diesel::sql_types::Timestamptz; users (id) { id -> Uuid, email -> Text, password_hash -> Text, name -> Text, role -> Text, created_at -> Timestamptz, updated_at -> Timestamptz, deleted_at -> Nullable<Timestamptz>, } } |
Explicación: mapeo Diesel para usuarios; extiende con products/orders si lo necesitas.
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 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 |
use actix_web::{post, get, web, HttpResponse}; use diesel::prelude::*; use rand::RngCore; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use serde::{Deserialize, Serialize}; use crate::{db::pool::DbPool, models::user::{User, NewUser}}; use crate::schema::users::dsl::*; use validator::Validate; #[derive(Debug, Deserialize, Validate)] pub struct RegisterDto { #[validate(email)] pub email: String, #[validate(length(min=8))] pub password: String, #[validate(length(min=2))] pub name: String } #[derive(Debug, Deserialize)] pub struct LoginDto { pub email: String, pub password: String } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { pub sub: String, pub role: String, pub iss: String, pub exp: usize } fn hash_password(plain: &str) -> anyhow::Result<String> { let salt: [u8; 16] = rand::random(); let salt = argon2::password_hash::Salt::from_b64(&base64::engine::general_purpose::STANDARD.encode(salt)).unwrap(); let argon2 = Argon2::default(); let pwd_hash = argon2.hash_password(plain.as_bytes(), &salt)?.to_string(); Ok(pwd_hash) } fn verify_password(hash: &str, plain: &str) -> bool { let parsed = PasswordHash::new(hash); if let Ok(ph) = parsed { Argon2::default().verify_password(plain.as_bytes(), &ph).is_ok() } else { false } } fn make_token(secret: &str, issuer: &str, uid: &str, role_: &str) -> String { let exp = (chrono::Utc::now() + chrono::Duration::hours(12)).timestamp() as usize; let claims = Claims { sub: uid.to_string(), role: role_.to_string(), iss: issuer.to_string(), exp }; encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(secret.as_bytes())).unwrap() } #[post("/register")] pub async fn register(pool: web::Data<DbPool>, payload: web::Json<RegisterDto>) -> actix_web::Result<HttpResponse> { use crate::schema::users; payload.validate().map_err(|e| actix_web::error::ErrorUnprocessableEntity(e.to_string()))?; let dto = payload.into_inner(); let phash = hash_password(&dto.password).map_err(|_| actix_web::error::ErrorInternalServerError("hash"))?; let new_user = NewUser { email: dto.email, password_hash: phash, name: dto.name, role: "user".into() }; let pool = pool.clone(); let user: User = actix_web::web::block(move || { let mut conn = pool.get().unwrap(); diesel::insert_into(users::table).values(&new_user).get_result(&mut conn) }).await.map_err(|e| actix_web::error::ErrorInternalServerError(e))?; Ok(HttpResponse::Created().json(serde_json::json!({"id": user.id, "email": user.email}))) } #[post("/login")] pub async fn login(pool: web::Data<DbPool>, payload: web::Json<LoginDto>, cfg: web::Data<crate::config::Settings>) -> actix_web::Result<HttpResponse> { let dto = payload.into_inner(); let pool_c = pool.clone(); let user = actix_web::web::block(move || { let mut conn = pool_c.get().unwrap(); users.filter(email.eq(&dto.email)).first::<User>(&mut conn).optional() }).await.map_err(|e| actix_web::error::ErrorInternalServerError(e))?.flatten(); if let Some(u) = user { if verify_password(&u.password_hash, &dto.password) { let token = make_token(&cfg.jwt_secret, &cfg.jwt_issuer, &u.id.to_string(), &u.role); return Ok(HttpResponse::Ok().json(serde_json::json!({"access_token": token, "token_type":"Bearer"}))) }} Err(actix_web::error::ErrorUnauthorized("Credenciales inválidas")) } #[get("/me")] pub async fn me(claims: crate::middleware::jwt_middleware::ClaimsExtractor) -> actix_web::Result<HttpResponse> { Ok(HttpResponse::Ok().json(serde_json::json!({"sub": claims.0.sub, "role": claims.0.role}))) } |
Explicación: registro, login y endpoint /me protegido. Password hashing Argon2 y emisión de JWT HS256.
src/middleware/jwt_middleware.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 |
use actix_web::{dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, Error, HttpMessage, HttpResponse}; use futures_util::future::{LocalBoxFuture, Ready}; use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm}; use serde::{Deserialize, Serialize}; use std::{future::ready, rc::Rc}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { pub sub: String, pub role: String, pub iss: String, pub exp: usize } pub struct JwtMiddleware { secret: String, issuer: String } impl JwtMiddleware { pub fn new(secret: String, issuer: String) -> Self { Self { secret, issuer } } } impl<S, B> Transform<S, ServiceRequest> for JwtMiddleware where S: Service<ServiceRequest, Response=ServiceResponse<B>, Error=Error> + 'static, B: 'static { type Response = ServiceResponse<B>; type Error = Error; type Transform = JwtMiddlewareService<S>; type InitError = (); type Future = Ready<Result<Self::Transform, Self::InitError>>; fn new_transform(&self, service: S) -> Self::Future { ready(Ok(JwtMiddlewareService { service: Rc::new(service), secret: self.secret.clone(), issuer: self.issuer.clone() })) } } pub struct JwtMiddlewareService<S> { service: Rc<S>, secret: String, issuer: String } impl<S, B> Service<ServiceRequest> for JwtMiddlewareService<S> where S: Service<ServiceRequest, Response=ServiceResponse<B>, Error=Error> + 'static, B: 'static { type Response = ServiceResponse<B>; type Error = Error; type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>; forward_ready!(service); fn call(&self, mut req: ServiceRequest) -> Self::Future { let auth = req.headers().get("Authorization").and_then(|v| v.to_str().ok()).unwrap_or(""); let token = auth.strip_prefix("Bearer "); let secret = self.secret.clone(); let issuer = self.issuer.clone(); let srv = self.service.clone(); Box::pin(async move { if let Some(t) = token { let mut v = Validation::new(Algorithm::HS256); v.set_issuer(&[&issuer]); match decode::<Claims>(t, &DecodingKey::from_secret(secret.as_bytes()), &v) { Ok(data) => { req.extensions_mut().insert(data.claims); return srv.call(req).await } Err(_) => return Ok(req.into_response(HttpResponse::Unauthorized().finish())), } } Ok(req.into_response(HttpResponse::Unauthorized().finish())) }) } } pub struct ClaimsExtractor(pub Claims); impl actix_web::FromRequest for ClaimsExtractor { type Error = actix_web::Error; type Future = Ready<Result<Self, Self::Error>>; type Config = (); fn from_request(req: &actix_web::HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future { if let Some(c) = req.extensions().get::<Claims>() { return ready(Ok(ClaimsExtractor(c.clone()))) } ready(Err(actix_web::error::ErrorUnauthorized("Missing claims"))) } } |
Explicación: middleware de validación JWT que inyecta Claims en las extensions de la request y extractor para handers.
Dockerfile (docker/Dockerfile)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# Etapa builder FROM rust:1 as builder RUN apt-get update && apt-get install -y libpq-dev pkg-config musl-tools && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY src ./src COPY migrations ./migrations COPY .env.example . RUN cargo build --release # Etapa runtime FROM debian:bookworm-slim RUN useradd -m app && apt-get update && apt-get install -y ca-certificates libpq5 && rm -rf /var/lib/apt/lists/* WORKDIR /home/app COPY --from=builder /app/target/release/rust_api_demo /usr/local/bin/app USER app EXPOSE 8080 ENV RUST_LOG=info CMD ["/usr/local/bin/app"] |
Explicación: compilación en builder y runtime mínimo con libpq.
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
version: "3.9" services: db: image: postgres:16 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: rust_api_demo ports: ["5432:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 5s timeout: 5s retries: 10 api: build: context: . dockerfile: docker/Dockerfile environment: DATABASE_URL: postgres://postgres:postgres@db:5432/rust_api_demo HOST: 0.0.0.0 PORT: 8080 JWT_SECRET: supersecret JWT_ISSUER: rust-api CORS_ORIGIN: http://localhost:3000 RUST_LOG: info ports: ["8080:8080"] depends_on: db: condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"] interval: 5s timeout: 3s retries: 10 prometheus: image: prom/prometheus:latest volumes: - ./docker/prometheus.yml:/etc/prometheus/prometheus.yml ports: ["9090:9090"] depends_on: - api |
Explicación: orquesta DB, API y Prometheus para observabilidad.
Ejemplo de docker/prometheus.yml:
1 2 3 4 5 6 7 8 |
global: scrape_interval: 5s scrape_configs: - job_name: "rust_api" static_configs: - targets: ["api:8080"] metrics_path: /metrics |
.env.example
1 2 3 4 5 6 7 8 |
HOST=127.0.0.1 PORT=8080 DATABASE_URL=postgres://postgres:postgres@localhost:5432/rust_api_demo JWT_SECRET=supersecret JWT_ISSUER=rust-api CORS_ORIGIN=http://localhost:3000 RUST_LOG=info |
Explicación: variables de entorno por defecto para desarrollo local.
README (comandos esenciales)
1 2 3 4 5 6 7 8 9 10 11 12 |
# Ejecutar local cp .env.example .env # Base de datos con Docker docker compose up -d db # Migraciones export DATABASE_URL=postgres://postgres:postgres@localhost:5432/rust_api_demo diesel setup && diesel migration run # API cargo run # Probar curl -s localhost:8080/healthz | jq |
Explicación: secuencia mínima para levantar DB, migrar y correr la API.
tests/integration_tests.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
use actix_web::{test, App}; use std::process::Command; #[actix_rt::test] async fn healthz_works() { // Arranque mínimo de App sin DB let app = test::init_service(App::new().service(crate::health)).await; let req = test::TestRequest::get().uri("/healthz").to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); } #[actix_rt::test] async fn register_and_login_flow() { // Requiere DATABASE_URL apuntando a DB de test y diesel_cli instalado if std::env::var("DATABASE_URL").is_err() { eprintln!("skip: DATABASE_URL no seteado"); return; } let _ = Command::new("diesel").args(["migration", "run"]).status(); // TODO: inicializar App con pool real y probar /auth endpoints // Limpieza let _ = Command::new("diesel").args(["migration", "revert", "--all"]).status(); } |
Explicación: ejemplo de test de healthz y esqueleto para flujo de auth con migraciones en setup/teardown.
.github/workflows/ci.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 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 |
name: CI on: [push, pull_request] jobs: build-test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: rust_api_demo ports: ["5432:5432"] options: >- --health-cmd="pg_isready -U postgres" --health-interval=5s --health-timeout=5s --health-retries=10 steps: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable override: true - name: Cache cargo uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Install diesel_cli run: cargo install diesel_cli --no-default-features --features postgres - name: fmt run: cargo fmt -- --check - name: clippy run: cargo clippy -- -D warnings - name: Migrations env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/rust_api_demo run: diesel setup && diesel migration run - name: Test env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/rust_api_demo run: cargo test --all --verbose docker: needs: build-test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build image run: docker build -t ghcr.io/${{ github.repository }}:sha-${{ github.sha }} -f docker/Dockerfile . - name: Login registry run: echo $CR_PAT | docker login ghcr.io -u ${{ github.actor }} --password-stdin env: CR_PAT: ${{ secrets.CR_PAT }} - name: Push image run: docker push ghcr.io/${{ github.repository }}:sha-${{ github.sha }} |
Explicación: pipeline con verificación estática, migraciones y tests; luego construye y publica la imagen.
Conclusión y siguientes pasos
Has construido una API REST de producción en Rust con Actix Web y Diesel: autenticación JWT, validación, observabilidad, contenedorización y CI. Para continuar:
- Añade endpoints de productos y pedidos, con transacciones Diesel.
- Implementa refresh tokens y roles avanzados.
- Integra OpenTelemetry OTLP hacia tu backend de trazas.
- Automatiza despliegues en Fly.io, ECS o GKE y configura TLS en tu gateway.