Crear un acortador de URLs en Rust con Actix-web, Diesel y SQLite

rust

Crear un acortador de URLs en Rust con Actix-web, Diesel y SQLite

Proyecto práctico: vamos a construir un servicio REST mínimo que permite acortar URLs y redirigir a la original. Usa Actix-web como servidor, Diesel + SQLite para almacenamiento y r2d2 para el pool de conexiones.

Requisitos previos

  • Rust (stable) y cargo instalados
  • SQLite (solo para inspeccionar la DB si quieres)
  • diesel_cli instalado (opcional, para ejecutar migraciones): cargo install diesel_cli --no-default-features --features sqlite
  • Familiaridad básica con Actix-web y Diesel

Estructura de carpetas

url-shortener-rust/
├── Cargo.toml
├── migrations/
│   └── 00000000000000_create_urls/
│       ├── up.sql
│       └── down.sql
└── src/
    ├── main.rs
    ├── db.rs
    ├── handlers.rs
    ├── models.rs
    └── schema.rs

Cargo.toml (dependencias principales)

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

[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rand = "0.8"
diesel = { version = "2.0", features = ["sqlite", "r2d2", "chrono"] }
diesel_migrations = "2.0"
r2d2 = "0.8"
chrono = { version = "0.4", features = ["serde"] }
log = "0.4"
env_logger = "0.9"

Migración (migrations/00000000000000_create_urls/up.sql)

CREATE TABLE urls (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  short_code TEXT NOT NULL UNIQUE,
  original_url TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  visits INTEGER NOT NULL DEFAULT 0
);

Migración down (migrations/.../down.sql)

DROP TABLE urls;

Si usas diesel_cli puedes ejecutar diesel migration run. Alternativamente, incluiremos migraciones embebidas en tiempo de ejecución con diesel_migrations en el código.

src/schema.rs

diesel::table! {
    urls (id) {
        id -> Integer,
        short_code -> Text,
        original_url -> Text,
        created_at -> Timestamp,
        visits -> Integer,
    }
}

src/models.rs

use chrono::NaiveDateTime;
use serde::Serialize;

use crate::schema::urls;

#[derive(Queryable, Serialize)]
pub struct Url {
    pub id: i32,
    pub short_code: String,
    pub original_url: String,
    pub created_at: NaiveDateTime,
    pub visits: i32,
}

#[derive(Insertable)]
#[diesel(table_name = urls)]
pub struct NewUrl<'a> {
    pub short_code: &'a str,
    pub original_url: &'a str,
    pub created_at: NaiveDateTime,
}

src/db.rs

use diesel::r2d2::{ConnectionManager, Pool};
use diesel::sqlite::SqliteConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};

pub type DbPool = Pool>;

pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!();

pub fn establish_pool(database_url: &str) -> DbPool {
    let manager = ConnectionManager::::new(database_url);
    let pool = Pool::builder()
        .build(manager)
        .expect("Failed to create DB pool");

    // Run migrations on startup
    let mut conn = pool.get().expect("Failed to get DB connection for migrations");
    conn.run_pending_migrations(MIGRATIONS).expect("Failed to run migrations");

    pool
}

src/handlers.rs

use actix_web::{web, HttpResponse, Responder, http::header};
use diesel::prelude::*;
use rand::{distributions::Alphanumeric, Rng};
use chrono::Utc;

use crate::db::DbPool;
use crate::models::{NewUrl, Url};
use crate::schema::urls::dsl::*;

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

#[derive(serde::Serialize)]
struct ShortenResponse {
    pub short: String,
    pub original: String,
}

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

pub async fn shorten(
    pool: web::Data,
    req: web::Json,
) -> impl Responder {
    let original = req.url.clone();
    let pool = pool.clone();

    let result = web::block(move || {
        let mut conn = pool.get()?;

        // Try generate a unique code (retry up to N times)
        for _ in 0..5 {
            let code = generate_code(6);

            // Ensure uniqueness
            let exists: Option = urls
                .filter(short_code.eq(&code))
                .select(id)
                .first::(&mut conn)
                .optional()?;

            if exists.is_none() {
                let new = NewUrl {
                    short_code: &code,
                    original_url: &original,
                    created_at: Utc::now().naive_utc(),
                };

                diesel::insert_into(urls).values(&new).execute(&mut conn)?;

                return Ok::<_, diesel::result::Error>(code);
            }
        }

        // If we reach here, collision problem
        Err(diesel::result::Error::RollbackTransaction)
    })
    .await;

    match result {
        Ok(code) => {
            let resp = ShortenResponse { short: code, original };
            HttpResponse::Ok().json(resp)
        }
        Err(err) => {
            eprintln!("Error shortening URL: {:#?}", err);
            HttpResponse::InternalServerError().body("Failed to shorten URL")
        }
    }
}

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

    let res = web::block(move || {
        let mut conn = pool.get()?;

        let mut url_item: Url = urls.filter(short_code.eq(&code)).first(&mut conn)?;

        // Increment visits
        diesel::update(urls.find(url_item.id))
            .set(visits.eq(visits + 1))
            .execute(&mut conn)?;

        // fetch updated if needed, but we only need original_url
        Ok::<_, diesel::result::Error>(url_item.original_url)
    })
    .await;

    match res {
        Ok(original) => HttpResponse::Found().append_header((header::LOCATION, original)).finish(),
        Err(_) => HttpResponse::NotFound().body("Short URL not found"),
    }
}

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

    let res = web::block(move || {
        let mut conn = pool.get()?;
        let url_item: Url = urls.filter(short_code.eq(&code)).first(&mut conn)?;
        Ok::<_, diesel::result::Error>(url_item)
    })
    .await;

    match res {
        Ok(url) => HttpResponse::Ok().json(url),
        Err(_) => HttpResponse::NotFound().body("Not found"),
    }
}

src/main.rs

mod db;
mod handlers;
mod models;
mod schema;

use actix_web::{App, HttpServer, web};
use env_logger::Env;
use db::establish_pool;

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

    let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "shortener.db".into());
    let pool = establish_pool(&database_url);

    println!("Starting server at http://127.0.0.1:8080");

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

Por qué esta arquitectura

  • Actix-web: alto rendimiento y ergonomía para escribir handlers async.
  • Diesel con r2d2: consultas SQL tipadas y pool de conexiones sencillo para SQLite.
  • Migraciones embebidas (diesel_migrations): evita depender del CLI en producción/distribución.
  • Separación en módulos (db, handlers, models, schema): fácil de testear y extender.

Notas prácticas y mejoras

  • Validación de URL: deberías validar y normalizar la URL de entrada (esquema, host), y bloquear URLs internas.
  • Colisiones: aquí reintentamos 5 veces; para producción usa un generador determinista o una columna con hash + checks, o un espacio mayor (p.ej. 8-10 chars) y estrategias de fallback.
  • Caching: agrega Redis o memoria para resolver redirecciones muy frecuentes y reducir lecturas DB.
  • Analítica: registra IP, UA, referer en otra tabla si necesitas tracking.
  • Rate limiting y abuse prevention: imprescindible si el servicio queda público.

Siguiente paso recomendado: integrarlo con un front simple o exponer una API pública con autenticación (API keys) y añadir tests para los handlers y pruebas de integración con una base de datos en memoria.

Advertencia de seguridad: nunca permitas que usuarios introduzcan URLs que apunten a direcciones internas (SSRF). Valida el esquema (http/https) y la resolución DNS, aplica rate limiting, y protege el endpoint de creación con autenticación si es necesario.

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