Cómo construir un servicio web REST seguro y eficiente en Rust con Actix-web y SQLx

rust Cómo construir un servicio web REST seguro y eficiente en Rust con Actix-web y SQLx

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 offline o 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

  1. Levanta Postgres: docker-compose up -d
  2. Pregunta de migraciones (opcional): con sqlx-cli
    DATABASE_URL="postgres://example:example@localhost/example" sqlx migrate run
  3. Exporta variables y ejecuta:
    export DATABASE_URL=postgres://example:example@localhost/example
    RUST_LOG=info
    cargo run
  4. 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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión