Cómo construir un servicio web en Rust con Actix-web y SQLx paso a paso

rust Cómo construir un servicio web en Rust con Actix-web y SQLx paso a paso

Cómo construir un servicio web en Rust con Actix-web y SQLx paso a paso

En este tutorial práctico construimos desde cero una API REST simple en Rust usando actix-web y sqlx (Postgres). Verás estructura de proyecto, código completo, migraciones SQL, y cómo ejecutar todo con Docker y variables de entorno.

Qué vas a aprender

  • Configurar proyecto Cargo para web + async DB
  • Conexión segura con PgPool de sqlx
  • Crear handlers, modelos y rutas
  • Escribir y aplicar migrations
  • Probar endpoints con curl

Requisitos

  • Rust (stable)
  • cargo
  • Docker (para Postgres) o Postgres local
  • sqlx-cli (opcional, para migraciones)

Estructura de carpetas

rust-actix-sqlx/
├── Cargo.toml
├── .env
├── migrations/
│   └── 20260701_create_users_table.sql
└── src/
    ├── main.rs
    ├── db.rs
    ├── handlers.rs
    └── models.rs

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 = ["runtime-tokio-rustls", "postgres", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
uuid = { version = "1", features = ["v4"] }
env_logger = "0.10"
dotenvy = "0.15"

.env (en la raíz)

DATABASE_URL=postgres://postgres:postgres@localhost:5432/rust_api

Usamos dotenvy para cargar variables de entorno localmente. Nunca subas .env a repos públicos.

Migración: migrations/20260701_create_users_table.sql

-- Up
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY,
    username TEXT NOT NULL UNIQUE,
    email TEXT NOT NULL UNIQUE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);

-- Down
DROP TABLE IF EXISTS users;

src/models.rs

use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Serialize)]
pub struct User {
    pub id: Uuid,
    pub username: String,
    pub email: String,
    pub created_at: chrono::DateTime,
}

#[derive(Deserialize)]
pub struct CreateUser {
    pub username: String,
    pub email: String,
}

src/db.rs

use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;

pub async fn create_pool(database_url: &str) -> anyhow::Result {
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect_timeout(Duration::from_secs(5))
        .connect(database_url)
        .await?;
    Ok(pool)
}

src/handlers.rs

use actix_web::{get, post, web, HttpResponse, Responder};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{CreateUser, User};

#[post("/users")]
pub async fn create_user(db: web::Data, payload: web::Json) -> impl Responder {
    let id = Uuid::new_v4();

    let res = sqlx::query_as!(
        // mapping to struct needs exact column names
        User,
        r#"
        INSERT INTO users (id, username, email)
        VALUES ($1, $2, $3)
        RETURNING id, username, email, created_at
        "#,
        id,
        payload.username,
        payload.email
    )
    .fetch_one(db.get_ref())
    .await;

    match res {
        Ok(user) => HttpResponse::Created().json(user),
        Err(e) => {
            // not leaking internals; in prod map errors properly
            HttpResponse::InternalServerError().body(format!("DB error: {}", e))
        }
    }
}

#[get("/users/{id}")]
pub async fn get_user(db: web::Data, path: web::Path) -> impl Responder {
    let id = path.into_inner();
    let res = sqlx::query_as!(
        User,
        r#"SELECT id, username, email, created_at FROM users WHERE id = $1"#,
        id
    )
    .fetch_one(db.get_ref())
    .await;

    match res {
        Ok(user) => HttpResponse::Ok().json(user),
        Err(sqlx::Error::RowNotFound) => HttpResponse::NotFound().finish(),
        Err(e) => HttpResponse::InternalServerError().body(format!("DB error: {}", e)),
    }
}

src/main.rs

mod db;
mod handlers;
mod models;

use actix_web::{middleware::Logger, web, App, HttpServer};
use dotenvy::dotenv;
use std::env;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    env_logger::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 bind = "127.0.0.1:8080";
    println!("Listening on http://{}", bind);

    HttpServer::new(move || {
        App::new()
            .wrap(Logger::default())
            .app_data(web::Data::new(pool.clone()))
            .service(handlers::create_user)
            .service(handlers::get_user)
    })
    .bind(bind)?
    .run()
    .await
}

Aplicar migraciones

Opción A: usar sqlx-cli (recomendado durante desarrollo):

cargo install sqlx-cli --no-default-features --features postgres
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/rust_api
sqlx migrate add create_users_table        # si quieres generar archivo
sqlx migrate run

Opción B: ejecutar el SQL manualmente contra tu base de datos (psql o cliente)

Levantar Postgres con Docker Compose

version: '3.8'
services:
  db:
    image: postgres:15
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: rust_api
    ports:
      - "5432:5432"
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:

Comandos para probar

# 1) Levanta DB
docker compose up -d

# 2) Aplica migración (sqlx-cli o psql)
# si usas sqlx-cli:
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/rust_api
sqlx migrate run

# 3) Ejecuta la app
cargo run

# 4) Crear usuario
curl -X POST -H "Content-Type: application/json" \
  -d '{"username":"alice","email":"alice@example.com"}' \
  http://127.0.0.1:8080/users

# 5) Obtener usuario (reemplaza UUID real)
curl http://127.0.0.1:8080/users/

Por qué esta arquitectura

  • actix-web: alto rendimiento y ergonomía para handlers async.
  • sqlx con macros: queries tipeadas en tiempo de compilación si corres DATABASE_URL durante build o usas cargo sqlx prepare.
  • Separación en db, handlers, models mantiene el código testeable y claro.

Buenas prácticas y notas importantes

  • Usa sqlx::query_as! para asegurarte de que las columnas devueltas mapeen exactamente a tu struct.
  • Maneja errores de forma explícita: transforma errores de SQL en respuestas HTTP apropiadas.
  • Configura límites de conexiones según la carga y tamaño de la base de datos.
  • No ejecutes sqlx::query! con strings concatenados; usa parámetros para evitar inyección.
  • En producción, usa TLS entre app y DB y revisa timeouts y retry policies.

Optimización rápida

Si tu app crece, considera:

  • Incrementar max_connections o usar PgBouncer para multiplexado.
  • Usar prepared statements y cachear consultas frecuentes.
  • Agregar métricas (Prometheus) y tracing (opentelemetry + tracing).

Próximos pasos sugeridos

Agregar autenticación (JWT), validación de entrada con validator, paginación para endpoints de lista y pruebas (integration tests usando Testcontainers o una DB en memoria). Un paso avanzado: integrar sqlx offline mode para garantizar que tus queries sigan tipeadas sin exponer la cadena de conexión en CI.

Consejo avanzado: habilita las comprobaciones de SQL en tiempo de compilación con cargo sqlx prepare -- --lib en tu pipeline CI y usa variables de entorno seguras para la cadena de conexión; esto evita regresiones silenciosas en queries.

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