Construye un acortador de URLs en Rust con Actix-web y SQLx (SQLite)

rust Construye un acortador de URLs en Rust con Actix-web y SQLx (SQLite)

Construye un acortador de URLs en Rust con Actix-web y SQLx (SQLite)

Proyecto práctico: una API mínima que crea códigos cortos para URLs, redirige y cuenta visitas. Enfocado en código claro, asincronía y persistencia ligera con SQLite.

Requisitos previos

  • Rust (stable) y cargo instalados
  • sqlite3 (para inspeccionar la base si quieres)
  • Conocimientos básicos de Rust async y web

Estructura de carpetas

rust-url-shortener/
├─ Cargo.toml
├─ .env
└─ src/
   ├─ main.rs
   ├─ db.rs
   ├─ models.rs
   └─ routes.rs

Cargo.toml (dependencias)

[package]
name = "rust-url-shortener"
edition = "2021"

[dependencies]
actix-web = "4"
sqlx = { version = "0.6", features = ["sqlite", "runtime-tokio-rustls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rand = "0.8"
dotenv = "0.15"
url = "2"
env_logger = "0.9"
log = "0.4"

.env

DATABASE_URL=sqlite://./data/urls.db
BIND=127.0.0.1:8080

Crea la carpeta data/ o modifica DATABASE_URL según prefieras.

Migración / inicialización de tabla

El servidor ejecutará la creación de tabla si no existe. Aquí la SQL usada:

CREATE TABLE IF NOT EXISTS urls (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  code TEXT NOT NULL UNIQUE,
  url TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now')),
  visits INTEGER DEFAULT 0
);

src/db.rs

use sqlx::SqlitePool;
use std::env;

pub async fn init_pool() -> anyhow::Result {
    let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://./data/urls.db".into());
    let pool = SqlitePool::connect(&db_url).await?;

    // Crear tabla si no existe
    sqlx::query(
        r#"CREATE TABLE IF NOT EXISTS urls (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            code TEXT NOT NULL UNIQUE,
            url TEXT NOT NULL,
            created_at TEXT DEFAULT (datetime('now')),
            visits INTEGER DEFAULT 0
        );"#,
    )
    .execute(&pool)
    .await?;

    Ok(pool)
}

src/models.rs

use serde::{Deserialize, Serialize};
use sqlx::FromRow;

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

#[derive(Serialize, FromRow)]
pub struct UrlRecord {
    pub id: i64,
    pub code: String,
    pub url: String,
    pub created_at: String,
    pub visits: i64,
}

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

#[derive(Serialize)]
pub struct StatsResponse {
    pub code: String,
    pub url: String,
    pub created_at: String,
    pub visits: i64,
}

src/routes.rs

use actix_web::{web, HttpResponse, Responder};
use rand::{distributions::Alphanumeric, Rng};
use sqlx::SqlitePool;
use crate::models::*;
use url::Url;

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

pub async fn shorten(
    pool: web::Data,
    req: web::Json,
    host: web::Data,
) -> impl Responder {
    // Validación básica de URL
    if Url::parse(&req.url).is_err() {
        return HttpResponse::BadRequest().body("url inválida");
    }

    // Intentar inserciones hasta N reintentos por colisión
    let mut code;
    let mut inserted = false;
    for _ in 0..5 {
        code = generate_code(6);
        let res = sqlx::query("INSERT INTO urls (code, url) VALUES (?, ?)")
            .bind(&code)
            .bind(&req.url)
            .execute(pool.get_ref())
            .await;

        if res.is_ok() {
            let short_url = format!("{}/{}", host.get_ref(), code);
            let response = ShortenResponse { code, short_url };
            inserted = true;
            return HttpResponse::Ok().json(response);
        }
        // si falla por UNIQUE, se repite
    }

    if !inserted {
        HttpResponse::InternalServerError().body("no se pudo generar un código único")
    } else {
        HttpResponse::InternalServerError().finish()
    }
}

pub async fn redirect(
    pool: web::Data,
    path: web::Path,
) -> impl Responder {
    let code = path.into_inner();

    let rec = sqlx::query_as::<_, UrlRecord>("SELECT id, code, url, created_at, visits FROM urls WHERE code = ?")
        .bind(&code)
        .fetch_optional(pool.get_ref())
        .await;

    match rec {
        Ok(Some(url_rec)) => {
            // Incrementar visitas (no crítico si falla)
            let _ = sqlx::query("UPDATE urls SET visits = visits + 1 WHERE id = ?")
                .bind(url_rec.id)
                .execute(pool.get_ref())
                .await;

            HttpResponse::Found()
                .append_header(("Location", url_rec.url))
                .finish()
        }
        Ok(None) => HttpResponse::NotFound().body("código no encontrado"),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

pub async fn stats(
    pool: web::Data,
    path: web::Path,
) -> impl Responder {
    let code = path.into_inner();

    match sqlx::query_as::<_, UrlRecord>("SELECT id, code, url, created_at, visits FROM urls WHERE code = ?")
        .bind(&code)
        .fetch_optional(pool.get_ref())
        .await
    {
        Ok(Some(r)) => HttpResponse::Ok().json(StatsResponse {
            code: r.code,
            url: r.url,
            created_at: r.created_at,
            visits: r.visits,
        }),
        Ok(None) => HttpResponse::NotFound().body("código no encontrado"),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

src/main.rs

mod db;
mod models;
mod routes;

use actix_web::{web, App, HttpServer};
use dotenv::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 pool = db::init_pool().await.expect("failed to init db");

    let bind = env::var("BIND").unwrap_or_else(|_| "127.0.0.1:8080".into());
    let host_for_short = format!("http://{}", bind);

    println!("Listening on {}", bind);

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(pool.clone()))
            .app_data(web::Data::new(host_for_short.clone()))
            .route("/api/shorten", web::post().to(routes::shorten))
            .route("/api/stats/{code}", web::get().to(routes::stats))
            .route("/{code}", web::get().to(routes::redirect))
    })
    .bind(bind)?
    .run()
    .await
}

Por qué estas decisiones

  • Actix-web: rendimiento y estabilidad para endpoints HTTP asincrónicos.
  • SQLx + SQLite: SQLx es asíncrono y ligero; SQLite facilita despliegue y pruebas locales. La inicialización automática evita necesidad de herramientas de migración para este ejemplo.
  • Generación de código aleatorio (Alphanumeric): simple y suficiente para PoC; la columna UNIQUE y reintentos manejan colisiones.
  • Separación en módulos (db, models, routes): mantiene código organizado y testable.

Cómo ejecutar

  1. Crear directorio del proyecto y archivos según estructura.
  2. Crear carpeta data/: mkdir -p data
  3. Configurar .env opcionalmente.
  4. cargo run --release

Pruebas rápidas con curl

# Crear un short
curl -X POST -H "Content-Type: application/json" -d '{"url": "https://www.rust-lang.org/"}' http://127.0.0.1:8080/api/shorten

# Redirigir (sustituye )
curl -i http://127.0.0.1:8080/

# Estadísticas
curl http://127.0.0.1:8080/api/stats/

Extensiones y siguientes pasos

  • Agregar validación y normalización avanzada de URLs (p. ej. forzar https cuando aplique).
  • Implementar TTL (expiración) o límites por usuario (necesita autenticación).
  • Cachear redirecciones calientes en Redis para reducir latencia en grandes volúmenes.
  • Implementar pruebas unitarias e integración (mock de BD o SQLite en memoria).

Consejo avanzado: si vas a producción, evita servir redirecciones hacia URLs con esquemas desconocidos (ejemplo: javascript:) y añade un Content Security Policy o validación estricta para prevenir open-redirects y vectores de phishing. Para alta carga, introduce un contador en memoria con flush batched a la base de datos o utiliza Redis para incrementar visitas atómicamente y delegar la persistencia a procesos asíncronos.

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