Proyecto: Acortador de URLs en Rust con Actix-web y SQLx (SQLite)

rust Proyecto: Acortador de URLs en Rust con Actix-web y SQLx (SQLite)

Proyecto práctico: Acortador de URLs en Rust con Actix-web y SQLx

En este tutorial crearás un acortador de URLs mínimo pero completo usando actix-web y sqlx con SQLite. El servicio tendrá dos endpoints principales: uno para crear la URL corta y otro para redirigir desde el código corto.

Requisitos previos

  • Rust toolchain (rustc + cargo) instalado (recomiendo stable o nightly reciente).
  • SQLite (opcional para inspeccionar la DB; la app crea el archivo si no existe).
  • Conocimientos básicos de Rust asíncrono y web (no estrictamente necesarios).

Estructura del proyecto

url-shortener-rust/
├─ Cargo.toml
└─ src/
   └─ main.rs

Optamos por mantener el proyecto pequeño: todo en main.rs para que puedas copiar y entender fácilmente. En entornos reales separarías módulos.

Cargo.toml

[package]
name = "url_shortener"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4"
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-native-tls", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rand = "0.8"
url = "2"

src/main.rs (código completo)

use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder, Result, http::header};
use rand::{distributions::Alphanumeric, Rng};
use serde::{Deserialize, Serialize};
use sqlx::{SqlitePool};
use std::time::SystemTime;
use url::Url;

#[derive(Deserialize)]
struct ShortenRequest {
    url: String,
}

#[derive(Serialize)]
struct ShortenResponse {
    code: String,
    short_url: String,
}

// DB row
struct UrlRow {
    url: String,
}

async fn ensure_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
    // Crea la tabla si no existe
    sqlx::query(
        r#"CREATE TABLE IF NOT EXISTS urls (
            code TEXT PRIMARY KEY,
            url TEXT NOT NULL,
            created_at INTEGER NOT NULL,
            clicks INTEGER NOT NULL DEFAULT 0
        )"#,
    )
    .execute(pool)
    .await?;
    Ok(())
}

fn generate_code(len: usize) -> String {
    let code: String = rand::thread_rng()
        .sample_iter(&Alphanumeric)
        .take(len)
        .map(char::from)
        .collect();
    code
}

fn validate_url(candidate: &str) -> Result {
    match Url::parse(candidate) {
        Ok(url) => {
            match url.scheme() {
                "http" | "https" => Ok(url),
                _ => Err("Only http and https schemes are allowed"),
            }
        }
        Err(_) => Err("Invalid URL format"),
    }
}

#[post("/shorten")]
async fn shorten(
    pool: web::Data,
    req: web::Json,
) -> Result {
    // Validación básica
    let parsed = match validate_url(&req.url) {
        Ok(u) => u,
        Err(e) => return Ok(HttpResponse::BadRequest().body(e)),
    };

    // Intentos para evitar colisiones (muy improbable con 6+ chars)
    let mut attempts = 0;
    let code_len = 6usize;

    while attempts < 5 {
        let code = generate_code(code_len);
        let now = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64;

        let res = sqlx::query("INSERT INTO urls (code, url, created_at, clicks) VALUES (?, ?, ?, 0)")
            .bind(&code)
            .bind(parsed.as_str())
            .bind(now)
            .execute(pool.get_ref())
            .await;

        match res {
            Ok(_) => {
                let host = std::env::var("HOST_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
                let short_url = format!("{}/{}", host.trim_end_matches('/'), code);
                let body = ShortenResponse { code, short_url };
                return Ok(HttpResponse::Ok().json(body));
            }
            Err(e) => {
                // Si la causa es conflicto de PK, generamos otro código
                // Otros errores: fallamos
                let msg = e.to_string();
                if msg.contains("UNIQUE") || msg.contains("PRIMARY") || msg.contains("constraint") {
                    attempts += 1;
                    continue;
                } else {
                    return Ok(HttpResponse::InternalServerError().body("DB error"));
                }
            }
        }
    }

    Ok(HttpResponse::InternalServerError().body("Could not generate unique code"))
}

#[get("/{code}")]
async fn redirect(
    pool: web::Data,
    path: web::Path<(String,)>,
) -> Result {
    let code = &path.0;

    // Buscamos URL
    let rec = sqlx::query_as!(
        UrlRow,
        "SELECT url as \"url: _\" FROM urls WHERE code = ?",
        code
    )
    .fetch_optional(pool.get_ref())
    .await
    .map_err(|_| HttpResponse::InternalServerError().finish())?;

    match rec {
        Some(row) => {
            // Incrementar clicks (no es estrictamente atómico con SELECT + UPDATE aquí, pero suficiente para demo)
            let _ = sqlx::query("UPDATE urls SET clicks = clicks + 1 WHERE code = ?")
                .bind(code)
                .execute(pool.get_ref())
                .await;

            Ok(HttpResponse::Found()
                .append_header((header::LOCATION, row.url))
                .finish())
        }
        None => Ok(HttpResponse::NotFound().body("Code not found")),
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Opcional: configurar variable HOST_URL para generar enlaces completos
    // std::env::set_var("HOST_URL", "http://localhost:8080");

    let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://urls.db".to_string());
    let pool = SqlitePool::connect(&database_url).await.expect("DB connect");

    ensure_db(&pool).await.expect("DB init");

    println!("Listening on http://0.0.0.0:8080");

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(pool.clone()))
            .service(shorten)
            .service(redirect)
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

Cómo probar

  1. Crear el proyecto e instalar dependencias:
    cargo build
  2. Ejecutar la app:
    cargo run
    La aplicación creará urls.db si no existe.
  3. Crear una URL corta (ejemplo con curl):
    curl -X POST -H "Content-Type: application/json" -d '{"url":"https://rust-lang.org"}' http://localhost:8080/shorten
    Obtendrás JSON con code y short_url.
  4. Visitar la URL corta en el navegador o con curl para redirigir:
    curl -v http://localhost:8080/ABC123

Por qué estas decisiones

  • actix-web: alto rendimiento, ergonomía y comunidad amplia. Fácil de montar rutas y responses.
  • sqlx + SQLite: SQLx ofrece queries compiladas en tiempo de compilación con la feature macros. SQLite simplifica el despliegue (no requiere servidor externo) y es suficiente para prototipos.
  • Generación aleatoria del código: simple y rápida. Para sistemas a escala usarías hashes o un servicio incremental para evitar colisiones y mejorar trazabilidad.
  • Validación de URL: se rechazan esquemas no HTTP/HTTPS para evitar usos indebidos.

Mejoras y consideraciones

  • Rate limiting: impedir abuso del endpoint de creación (ej. con Redis y token bucket).
  • Autenticación/privacidad: permitir URLs privadas o caducidad.
  • Escalado: mover a PostgreSQL o MySQL, y usar un almacén en memoria (Redis) para redirecciones calientes.
  • Analítica: guardar referer, IP (cumpliendo GDPR) y timestamp para obtener métricas.
  • Atomicidad: para clicks y otras métricas a escala, usar operaciones atómicas o colas de eventos.

Consejo avanzado: si vas a producción, evita usar solo códigos alfanuméricos aleatorios; considera un ID secuencial cifrado/base62 o una estrategia de sharding que facilite auditoría y evita colisiones. Además, agrega validación de payload y límites de tamaño, y aplica rate limiting y saneamiento para prevenir abuse y ataques de tipo open redirect.

Seguridad: no almacenes URLs sin filtrar en logs visibles, y cuida las cabeceras al redirigir para evitar ataques XSS o fugas de referrer; considera agregar políticas de CSP y validar orígenes cuando sea necesario.

Siguiente paso natural: extraer lógica de DB a un módulo, añadir pruebas unitarias e integrar un panel mínimo que liste URLs y métricas.

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