Cómo construir una API REST en Rust con Actix-web y SQLx (Postgres) — Tutorial completo

rust Cómo construir una API REST en Rust con Actix-web y SQLx (Postgres) — Tutorial completo

Cómo construir una API REST en Rust con Actix-web y SQLx (Postgres)

En este tutorial práctico construirás una API REST mínima pero completa en Rust utilizando Actix-web (servidor HTTP asíncrono) y SQLx (conexión segura a Postgres). Verás estructura de carpetas, código completo, por qué tomamos cada decisión, pruebas básicas, despliegue con Docker y recomendaciones de rendimiento y seguridad.

Qué construiremos

  • CRUD para recursos "users" (id: UUID, name, email, created_at)
  • Conexión a Postgres mediante SQLx y pool
  • Manejo de errores centralizado y respuestas JSON
  • Docker Compose para Postgres + app

Estructura del proyecto

rust_api/
├─ Cargo.toml
├─ .env
├─ src/
│  ├─ main.rs
│  ├─ db.rs
│  ├─ models.rs
│  ├─ handlers.rs
│  └─ errors.rs
├─ migrations/
│  └─ 20260801_create_users.sql
└─ docker-compose.yml

Cargo.toml

Dependencias clave: actix-web, sqlx (Postgres), serde, dotenvy, log.

[package]
name = "rust_api"
edition = "2021"

[dependencies]
actix-web = "4"
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] }
dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
env_logger = "0.9"
uuid = { version = "1", features = ["serde", "v4"] }
anyhow = "1.0"

.env (ejemplo)

DATABASE_URL=postgres://postgres:password@db:5432/rust_api
RUST_LOG=info

Migration SQL (migrations/20260801_create_users.sql)

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

src/db.rs

Función para crear pool reutilizable.

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(10) // ajustar según CPU / carga
        .connect_timeout(Duration::from_secs(5))
        .connect(database_url)
        .await?;

    Ok(pool)
}

src/models.rs

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct User {
    pub id: Uuid,
    pub name: String,
    pub email: String,
    pub created_at: DateTime,
}

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

#[derive(Deserialize)]
pub struct UpdateUser {
    pub name: Option,
    pub email: Option,
}

src/errors.rs

Manejo de errores centralizado que convierte errores en respuestas HTTP JSON.

use actix_web::{HttpResponse, ResponseError};
use derive_more::Display;
use sqlx::Error as SqlxError;

#[derive(Debug, Display)]
pub enum AppError {
    #[display(fmt = "Not Found")]
    NotFound,
    #[display(fmt = "Bad Request: {}", _0)]
    BadRequest(String),
    #[display(fmt = "Internal Error")]
    InternalError,
}

impl ResponseError for AppError {
    fn error_response(&self) -> HttpResponse {
        match self {
            AppError::NotFound => HttpResponse::NotFound().json("Not Found"),
            AppError::BadRequest(msg) => HttpResponse::BadRequest().json(msg),
            AppError::InternalError => HttpResponse::InternalServerError().json("Internal Server Error"),
        }
    }
}

impl From for AppError {
    fn from(err: SqlxError) -> Self {
        match err {
            SqlxError::RowNotFound => AppError::NotFound,
            _ => AppError::InternalError,
        }
    }
}

src/handlers.rs

use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{CreateUser, UpdateUser, User};
use crate::errors::AppError;
use chrono::Utc;

pub async fn list_users(pool: web::Data) -> Result {
    let rows = sqlx::query!(
        "SELECT id, name, email, created_at FROM users ORDER BY created_at DESC LIMIT 100"
    )
    .fetch_all(pool.get_ref())
    .await?;

    let users: Vec = rows
        .into_iter()
        .map(|r| User { id: r.id, name: r.name, email: r.email, created_at: r.created_at })
        .collect();

    Ok(HttpResponse::Ok().json(users))
}

pub async fn create_user(
    pool: web::Data,
    payload: web::Json,
) -> Result {
    // Validación simple
    if payload.name.trim().is_empty() || payload.email.trim().is_empty() {
        return Err(AppError::BadRequest("name and email are required".into()));
    }

    let id = Uuid::new_v4();
    let created_at = Utc::now();

    sqlx::query!(
        "INSERT INTO users (id, name, email, created_at) VALUES ($1, $2, $3, $4)",
        id,
        payload.name,
        payload.email,
        created_at
    )
    .execute(pool.get_ref())
    .await
    .map_err(|e| e.into())?;

    let user = User { id, name: payload.name.clone(), email: payload.email.clone(), created_at };
    Ok(HttpResponse::Created().json(user))
}

pub async fn get_user(pool: web::Data, path: web::Path) -> Result {
    let id = path.into_inner();

    let r = sqlx::query!("SELECT id, name, email, created_at FROM users WHERE id = $1", id)
        .fetch_one(pool.get_ref())
        .await?;

    let user = User { id: r.id, name: r.name, email: r.email, created_at: r.created_at };
    Ok(HttpResponse::Ok().json(user))
}

pub async fn update_user(
    pool: web::Data,
    path: web::Path,
    payload: web::Json,
) -> Result {
    let id = path.into_inner();

    // Fetch existing
    let existing = sqlx::query!("SELECT id, name, email, created_at FROM users WHERE id = $1", id)
        .fetch_one(pool.get_ref())
        .await?;

    let new_name = payload.name.clone().unwrap_or(existing.name);
    let new_email = payload.email.clone().unwrap_or(existing.email);

    sqlx::query!(
        "UPDATE users SET name = $1, email = $2 WHERE id = $3",
        new_name,
        new_email,
        id
    )
    .execute(pool.get_ref())
    .await?;

    let user = User { id, name: new_name, email: new_email, created_at: existing.created_at };
    Ok(HttpResponse::Ok().json(user))
}

pub async fn delete_user(pool: web::Data, path: web::Path) -> Result {
    let id = path.into_inner();

    sqlx::query!("DELETE FROM users WHERE id = $1", id)
        .execute(pool.get_ref())
        .await?;

    Ok(HttpResponse::NoContent().finish())
}

src/main.rs

mod db;
mod models;
mod handlers;
mod errors;

use actix_web::{web, App, HttpServer};
use dotenvy::dotenv;
use std::env;
use env_logger::Env;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    env_logger::Builder::from_env(Env::default().default_filter_or("info")).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 pool_data = web::Data::new(pool);

    HttpServer::new(move || {
        App::new()
            .app_data(pool_data.clone())
            .route("/users", web::get().to(handlers::list_users))
            .route("/users", web::post().to(handlers::create_user))
            .route("/users/{id}", web::get().to(handlers::get_user))
            .route("/users/{id}", web::put().to(handlers::update_user))
            .route("/users/{id}", web::delete().to(handlers::delete_user))
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

Docker Compose para desarrollo

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

  app:
    build: .
    env_file: .env
    depends_on:
      - db
    ports:
      - "8080:8080"

volumes:
  db_data:

Dockerfile (opcional)

FROM rust:1.71 as builder
WORKDIR /usr/src/app
COPY . .
RUN cargo build --release

FROM debian:buster-slim
COPY --from=builder /usr/src/app/target/release/rust_api /usr/local/bin/rust_api
ENV RUST_LOG=info
CMD ["/usr/local/bin/rust_api"]

Pruebas básicas (curl)

# Crear
curl -X POST -H "Content-Type: application/json" -d '{"name":"Alice","email":"alice@example.com"}' http://localhost:8080/users

# Listar
curl http://localhost:8080/users

# Obtener
curl http://localhost:8080/users/

Razones detrás de las decisiones

  • Actix-web: rendimiento y ecosistema maduro para APIs en Rust.
  • SQLx: proveedores asíncronos y binding seguro de parámetros; evita inyección SQL y ofrece macros para compile-time checks (si configuras DATABASE_URL al compilar).
  • UUID + created_at: identificadores seguros y trazabilidad temporal.
  • Manejo de errores centralizado con ResponseError para respuestas consistentes.

Mejores prácticas y optimización

  • Pool de conexiones: ajusta max_connections según CPU y concurrencia esperada.
  • Compilar con las características de SQLx y usar query! para validación en tiempo de compilación (requiere conexión a la DB en build).
  • Evita serializar objetos grandes; selecciona solo columnas necesarias en consultas.
  • Considera PgBouncer en modo transaction para alta concurrencia y conexiones cortas.
  • Habilita TLS (runtime-tokio-rustls) para conexiones seguras si te conectas a DB remota.

Seguridad

  • Valida y sanitiza entrada: no confíes solo en la tipificación del JSON.
  • Usa consultas parametrizadas (como en SQLx) para eliminar riesgo de SQL injection.
  • Protege secretos usando gestores (Vault, AWS Secrets) o variables de entorno en sistemas orquestados.
  • Limita tamaño de payload y aplica rate limiting si expones públicamente.

Testing y CI

Incluye pruebas de integración que arranquen una BD de prueba (docker-compose -f docker-compose.test.yml up) y ejecuten migraciones. Con GitHub Actions puedes levantar un servicio Postgres y ejecutar cargo test.

Debugging

Usa RUST_LOG=debug y logs estructurados. Para latencias, mide con tracing o middleware que capture timings de request/response.

Extensiones sugeridas

  • Autenticación JWT y scopes por endpoint
  • Paginación eficiente con índices y cursores
  • Cache con Redis para lecturas frecuentes
  • Health checks y métricas (Prometheus)

Consejo avanzado: si esperas latencias muy bajas y alto RPS, evita operaciones síncronas en handlers — usa operaciones asíncronas, prepared statements reutilizables y considera un pool de conexiones más pequeño combinado con PgBouncer. Advertencia: compilar y usar las macros de SQLx (query!) requiere que la URL de la base de datos esté disponible en build time o usar el modo offline; esto mejora seguridad y detecta errores de esquema temprano.

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