use axum::response::IntoResponse;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Instant;
const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const DEFAULT_TTL_SECS: u64 = 86400; const MAX_URL_LENGTH: usize = 4096;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TinyUrlEntry {
pub id: String,
pub original_url: String,
pub short_url: String,
pub created_at: u64,
pub expires_at: u64,
pub hits: u64,
#[serde(skip)]
pub last_accessed: Option<Instant>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TinyUrlConfig {
pub enabled: bool,
pub base_url: String,
pub ttl_secs: u64,
pub max_entries: usize,
pub secret: String,
}
impl Default for TinyUrlConfig {
fn default() -> Self {
Self {
enabled: true,
base_url: "http://localhost:8787".into(),
ttl_secs: DEFAULT_TTL_SECS,
max_entries: 100_000,
secret: "portail-tinyurl-secret".into(),
}
}
}
pub struct TinyUrlStore {
entries: std::sync::RwLock<FxHashMap<String, TinyUrlEntry>>,
config: TinyUrlConfig,
}
impl TinyUrlStore {
pub fn new(config: TinyUrlConfig) -> Self {
Self {
entries: std::sync::RwLock::new(FxHashMap::default()),
config,
}
}
pub fn shorten(&self, url: &str) -> Result<TinyUrlEntry, String> {
if url.len() > MAX_URL_LENGTH {
return Err(format!("URL too long (max {} chars)", MAX_URL_LENGTH));
}
let id = self.generate_id(url);
let short_url = format!("{}/s/{}", self.config.base_url, id);
let now = now_secs();
let entry = TinyUrlEntry {
id: id.clone(),
original_url: url.to_string(),
short_url: short_url.clone(),
created_at: now,
expires_at: now + self.config.ttl_secs,
hits: 0,
last_accessed: None,
};
let mut entries = self.entries.write().unwrap();
if entries.len() >= self.config.max_entries {
if let Some(oldest_key) = entries
.iter()
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone())
{
entries.remove(&oldest_key);
}
}
entries.insert(id.clone(), entry.clone());
Ok(entry)
}
pub fn resolve(&self, id: &str) -> Option<String> {
let mut entries = self.entries.write().unwrap();
let now = now_secs();
if let Some(entry) = entries.get_mut(id) {
if entry.expires_at > now {
entry.hits += 1;
entry.last_accessed = Some(Instant::now());
return Some(entry.original_url.clone());
} else {
entries.remove(id);
}
}
None
}
pub fn get_stats(&self) -> TinyUrlStats {
let entries = self.entries.read().unwrap();
let now = now_secs();
let total = entries.len();
let active = entries.values().filter(|e| e.expires_at > now).count();
let expired = total - active;
let total_hits = entries.values().map(|e| e.hits).sum();
TinyUrlStats {
total_entries: total,
active_entries: active,
expired_entries: expired,
total_hits,
}
}
pub fn cleanup_expired(&self) -> usize {
let mut entries = self.entries.write().unwrap();
let now = now_secs();
let before = entries.len();
entries.retain(|_, e| e.expires_at > now);
before - entries.len()
}
fn generate_id(&self, url: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
url.hash(&mut hasher);
self.config.secret.hash(&mut hasher);
let hash = hasher.finish();
base62_encode(hash)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TinyUrlStats {
pub total_entries: usize,
pub active_entries: usize,
pub expired_entries: usize,
pub total_hits: u64,
}
fn base62_encode(mut num: u64) -> String {
if num == 0 {
return "0".to_string();
}
let mut result = Vec::new();
while num > 0 {
result.push(BASE62[(num % 62) as usize]);
num /= 62;
}
result.reverse();
String::from_utf8(result).unwrap()
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub async fn handle_shorten(
axum::extract::State(state): axum::extract::State<Arc<crate::AppState>>,
axum::Json(req): axum::Json<serde_json::Value>,
) -> impl axum::response::IntoResponse {
let url = match req["url"].as_str() {
Some(u) => u,
None => {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "missing url"})),
);
}
};
match state.tinyurl.shorten(url) {
Ok(entry) => (
axum::http::StatusCode::CREATED,
axum::Json(serde_json::to_value(entry).unwrap()),
),
Err(e) => (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": e})),
),
}
}
pub async fn handle_resolve(
axum::extract::State(state): axum::extract::State<Arc<crate::AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> axum::response::Response {
match state.tinyurl.resolve(&id) {
Some(url) => axum::response::Redirect::permanent(&url).into_response(),
None => (
axum::http::StatusCode::NOT_FOUND,
"Short URL not found or expired",
)
.into_response(),
}
}
pub async fn handle_stats(
axum::extract::State(state): axum::extract::State<Arc<crate::AppState>>,
) -> axum::Json<TinyUrlStats> {
axum::Json(state.tinyurl.get_stats())
}
pub fn router() -> axum::Router<Arc<crate::AppState>> {
axum::Router::new()
.route("/tinyurl/shorten", axum::routing::post(handle_shorten))
.route("/tinyurl/stats", axum::routing::get(handle_stats))
.route("/s/{id}", axum::routing::get(handle_resolve))
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> TinyUrlConfig {
TinyUrlConfig {
enabled: true,
base_url: "http://localhost:8787".into(),
ttl_secs: 3600,
max_entries: 1000,
secret: "test-secret".into(),
}
}
#[test]
fn shorten_and_resolve() {
let store = TinyUrlStore::new(test_config());
let entry = store.shorten("https://example.com/very/long/url").unwrap();
assert!(!entry.id.is_empty());
assert!(entry.short_url.contains(&entry.id));
let resolved = store.resolve(&entry.id).unwrap();
assert_eq!(resolved, "https://example.com/very/long/url");
}
#[test]
fn resolve_nonexistent() {
let store = TinyUrlStore::new(test_config());
assert!(store.resolve("nonexistent").is_none());
}
#[test]
fn resolve_expired() {
let config = TinyUrlConfig {
ttl_secs: 0, ..test_config()
};
let store = TinyUrlStore::new(config);
let entry = store.shorten("https://example.com").unwrap();
assert!(store.resolve(&entry.id).is_none());
}
#[test]
fn stats_tracking() {
let store = TinyUrlStore::new(test_config());
store.shorten("https://example.com/1").unwrap();
store.shorten("https://example.com/2").unwrap();
let stats = store.get_stats();
assert_eq!(stats.total_entries, 2);
assert_eq!(stats.active_entries, 2);
}
#[test]
fn base62_encoding() {
assert_eq!(base62_encode(0), "0");
assert_eq!(base62_encode(1), "1");
assert_eq!(base62_encode(61), "z");
assert_eq!(base62_encode(62), "10");
}
#[test]
fn hit_counter() {
let store = TinyUrlStore::new(test_config());
let entry = store.shorten("https://example.com").unwrap();
store.resolve(&entry.id);
store.resolve(&entry.id);
let stats = store.get_stats();
assert_eq!(stats.total_hits, 2);
}
}