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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación