Cómo construir una API REST eficiente en Rust con Actix-web y SQLx

rust Cómo construir una API REST eficiente en Rust con Actix-web y SQLx

Cómo construir una API REST eficiente en Rust con Actix-web y SQLx

En este tutorial práctico vas a crear desde cero una pequeña API CRUD de tasks usando Actix-web y SQLx sobre PostgreSQL. Verás la estructura de carpetas, el código completo, por qué se diseña así y consejos de rendimiento y seguridad. Enfocado a desarrolladores con conocimientos básicos de Rust.

Requisitos

  • Rust (stable) y Cargo
  • Docker (para ejecutar PostgreSQL localmente)
  • Editor y curl o Postman

Características del ejemplo

  • Endpoints: GET /tasks, GET /tasks/{id}, POST /tasks, PUT /tasks/{id}, DELETE /tasks/{id}
  • Conexión a PostgreSQL con pool
  • Validación básica y manejo de errores
  • Ejemplos curl

Estructura del proyecto

task-api/
├─ Cargo.toml
├─ docker-compose.yml
└─ src/
   ├─ main.rs
   ├─ db.rs
   ├─ models.rs
   ├─ handlers.rs
   └─ routes.rs

Cargo.toml

[package]
name = "task-api"
version = "0.1.0"
edition = "2021"

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

Docker Compose para Postgres (local)

version: "3.7"
services:
  db:
    image: postgres:15
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: taskdb
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Levantar con: docker-compose up -d. La URL de conexión será algo como postgres://postgres:postgres@localhost:5432/taskdb.

Migración SQL (ejemplo simple)

CREATE TABLE tasks (
  id UUID PRIMARY KEY,
  title TEXT NOT NULL,
  completed BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);

src/db.rs

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

pub async fn establish_pool(database_url: &str) -> anyhow::Result {
  let pool = PgPoolOptions::new()
    .max_connections(5) // ajustar según carga
    .connect_timeout(Duration::from_secs(5))
    .connect(database_url)
    .await?;

  Ok(pool)
}

src/models.rs

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

#[derive(sqlx::FromRow, Serialize, Debug)]
pub struct Task {
  pub id: Uuid,
  pub title: String,
  pub completed: bool,
  pub created_at: DateTime,
}

#[derive(Deserialize)]
pub struct CreateTask {
  pub title: String,
}

#[derive(Deserialize)]
pub struct UpdateTask {
  pub title: Option,
  pub completed: Option,
}

src/handlers.rs

use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{Task, CreateTask, UpdateTask};

pub async fn list_tasks(db: web::Data) -> actix_web::Result {
  let tasks = sqlx::query_as::<_, Task>("SELECT * FROM tasks ORDER BY created_at DESC")
    .fetch_all(db.get_ref())
    .await
    .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

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

pub async fn get_task(db: web::Data, path: web::Path) -> actix_web::Result {
  let id = path.into_inner();
  let task = sqlx::query_as::<_, Task>("SELECT * FROM tasks WHERE id = $1")
    .bind(id)
    .fetch_optional(db.get_ref())
    .await
    .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

  match task {
    Some(t) => Ok(HttpResponse::Ok().json(t)),
    None => Ok(HttpResponse::NotFound().finish()),
  }
}

pub async fn create_task(db: web::Data, payload: web::Json) -> actix_web::Result {
  if payload.title.trim().is_empty() {
    return Ok(HttpResponse::BadRequest().body("title is required"));
  }

  let id = Uuid::new_v4();
  let rec = sqlx::query_as::<_, Task>("INSERT INTO tasks (id, title, completed) VALUES ($1, $2, $3) RETURNING *")
    .bind(id)
    .bind(&payload.title)
    .bind(false)
    .fetch_one(db.get_ref())
    .await
    .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

  Ok(HttpResponse::Created().json(rec))
}

pub async fn update_task(db: web::Data, path: web::Path, payload: web::Json) -> actix_web::Result {
  let id = path.into_inner();
  let mut tx = db.begin().await.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

  // Obtener actual
  let current = sqlx::query_as::<_, Task>("SELECT * FROM tasks WHERE id = $1")
    .bind(id)
    .fetch_optional(&mut tx)
    .await
    .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

  let current = match current {
    Some(c) => c,
    None => return Ok(HttpResponse::NotFound().finish()),
  };

  let new_title = payload.title.clone().unwrap_or(current.title);
  let new_completed = payload.completed.unwrap_or(current.completed);

  let updated = sqlx::query_as::<_, Task>("UPDATE tasks SET title = $1, completed = $2 WHERE id = $3 RETURNING *")
    .bind(new_title)
    .bind(new_completed)
    .bind(id)
    .fetch_one(&mut tx)
    .await
    .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

  tx.commit().await.map_err(|e| actix_web::error::ErrorInternalServerError(e))?;
  Ok(HttpResponse::Ok().json(updated))
}

pub async fn delete_task(db: web::Data, path: web::Path) -> actix_web::Result {
  let id = path.into_inner();
  let result = sqlx::query("DELETE FROM tasks WHERE id = $1")
    .bind(id)
    .execute(db.get_ref())
    .await
    .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

  if result.rows_affected() == 0 {
    Ok(HttpResponse::NotFound().finish())
  } else {
    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("/tasks")
      .route("", web::get().to(handlers::list_tasks))
      .route("", web::post().to(handlers::create_task))
      .route("/{id}", web::get().to(handlers::get_task))
      .route("/{id}", web::put().to(handlers::update_task))
      .route("/{id}", web::delete().to(handlers::delete_task)),
  );
}

src/main.rs

mod db;
mod handlers;
mod models;
mod routes;

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

#[actix_web::main]
async fn main() -> anyhow::Result<()> {
  env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();

  let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/taskdb".into());

  let pool = db::establish_pool(&database_url).await?;

  HttpServer::new(move || {
    App::new()
      .app_data(actix_web::web::Data::new(pool.clone()))
      .configure(routes::configure)
  })
  .bind(("0.0.0.0", 8080))?
  .run()
  .await?;

  Ok(())
}

Probar la API (curl)

# Crear
curl -X POST -H "Content-Type: application/json" -d '{"title":"Comprar leche"}' http://localhost:8080/tasks

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

# Obtener
curl http://localhost:8080/tasks/{id}

# Actualizar
curl -X PUT -H "Content-Type: application/json" -d '{"completed":true}' http://localhost:8080/tasks/{id}

# Borrar
curl -X DELETE http://localhost:8080/tasks/{id}

Por qué este diseño

  • Separa responsabilidades: db (pool), models, handlers y rutas para mantener el código claro y testeable.
  • SQLx con consultas parametrizadas evita inyección SQL y permite mapping directo a structs.
  • Uso de pool y límites de conexiones para proteger la DB bajo carga.
  • Manejo de transacciones donde es necesario (actualizar con lectura previa).

Mejoras de rendimiento y configuración

  • Compilar en release: cargo build --release para optimizaciones.
  • Ajustar max_connections del pool según CPU y tamaño de DB.
  • Habilitar keep-alive y TLS según necesidad (sqlx con rustls en features).
  • Utilizar índices en columnas consultadas frecuentemente (ej. created_at si se ordena).

Seguridad y prácticas recomendadas

  • Validar payloads y evitar campos inseguros. Aquí se valida título vacío como ejemplo.
  • Usar variables de entorno para credenciales y no subirlas al repo.
  • Limitar tamaño máximo de body en Actix con configuraciones si esperas payloads grandes.
  • Habilitar CORS con configuración estricta sólo para orígenes permitidos en producción.

Depuración y testing

  • Usa RUST_LOG=debug para obtener trazas durante desarrollo.
  • Agregar pruebas de integración que arranquen una BD de testing (Docker) y usen endpoints HTTP.

Errores comunes y cómo evitarlos

  • No ajustar el pool: poner un pool demasiado pequeño o demasiado grande causa latencia o sobrecarga.
  • No usar consultas parametrizadas: sqlx las usa por defecto con .bind().
  • No manejar timeouts de conexión: configurar connect_timeout y timeouts de request en Actix.

Si quieres llevar esto más lejos: añade autenticación (JWT), paginación, índices y metrics (Prometheus). Un consejo avanzado: habilita sqlx offline mode para compilar con verificación estática de queries en CI, generando un archivo de realidad con DATABASE_URL apuntando a una DB de test durante build. Eso reduce errores en producción y pone tus queries a prueba.

Advertencia: siempre realiza pruebas de carga y revisa límites de conexión antes de desplegar en producción; la configuración por defecto puede no ser segura bajo picos altos.

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