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
- Crear el proyecto e instalar dependencias:
cargo build - Ejecutar la app:
La aplicación crearácargo runurls.dbsi no existe. - Crear una URL corta (ejemplo con curl):
Obtendrás JSON concurl -X POST -H "Content-Type: application/json" -d '{"url":"https://rust-lang.org"}' http://localhost:8080/shortencodeyshort_url. - 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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación