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
- Crear directorio del proyecto y archivos según estructura.
- Crear carpeta data/: mkdir -p data
- Configurar .env opcionalmente.
- 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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación