Neste projeto vamos construir um serviço encurtador de URLs completo em Rust usando Axum. O serviço vai receber URLs longas, gerar códigos curtos únicos, redirecionar visitantes e contar os cliques de cada link. É um projeto clássico que ensina conceitos fundamentais de desenvolvimento web: roteamento, redirecionamento HTTP, geração de identificadores e gerenciamento de estado.
Encurtadores de URLs são serviços usados diariamente por milhões de pessoas. Construir um do zero vai mostrar como Rust lida com requisições web de forma extremamente eficiente.
O Que Vamos Construir
Um serviço encurtador de URLs com as seguintes funcionalidades:
- POST /api/encurtar — Recebe uma URL longa e retorna um código curto
- GET /:codigo — Redireciona para a URL original
- GET /api/stats/:codigo — Retorna estatísticas de cliques
- GET /api/links — Lista todos os links encurtados
- Geração de códigos curtos aleatórios de 6 caracteres
- Contagem automática de cliques em cada redirecionamento
- Registro de data/hora de criação
Estrutura do Projeto
url-shortener/
├── Cargo.toml
└── src/
└── main.rs
Configurando o Projeto
cargo new url-shortener
cd url-shortener
Configure o Cargo.toml:
[package]
name = "url-shortener"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }
Usamos rand para gerar códigos aleatórios e chrono para registrar datas de criação.
Passo 1: Definindo os Modelos e Geração de Códigos
Vamos definir as estruturas de dados e a função que gera códigos curtos:
use chrono::{DateTime, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
// Representa um link encurtado armazenado no sistema
#[derive(Debug, Clone, Serialize)]
pub struct LinkEncurtado {
pub codigo: String,
pub url_original: String,
pub url_curta: String,
pub cliques: u64,
pub criado_em: DateTime<Utc>,
}
// Dados recebidos para encurtar uma URL
#[derive(Debug, Deserialize)]
pub struct RequisicaoEncurtar {
pub url: String,
}
// Resposta ao encurtar uma URL
#[derive(Debug, Serialize)]
pub struct RespostaEncurtar {
pub codigo: String,
pub url_curta: String,
pub url_original: String,
}
// Estado compartilhado da aplicação
pub type EstadoApp = Arc<Mutex<HashMap<String, LinkEncurtado>>>;
// Gera um código curto aleatório de 6 caracteres alfanuméricos
fn gerar_codigo() -> String {
let caracteres: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = rand::thread_rng();
(0..6)
.map(|_| {
let indice = rng.gen_range(0..caracteres.len());
caracteres[indice] as char
})
.collect()
}
A função gerar_codigo cria uma string de 6 caracteres aleatórios usando letras e números. Com 62 caracteres possíveis em 6 posições, temos mais de 56 bilhões de combinações possíveis.
Passo 2: Implementando os Handlers da API
Agora vamos criar os handlers para cada endpoint:
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Redirect},
Json,
};
const URL_BASE: &str = "http://localhost:3000";
// POST /api/encurtar — Cria um novo link encurtado
async fn encurtar_url(
State(estado): State<EstadoApp>,
Json(requisicao): Json<RequisicaoEncurtar>,
) -> Result<(StatusCode, Json<RespostaEncurtar>), (StatusCode, Json<serde_json::Value>)> {
// Validação simples da URL
if !requisicao.url.starts_with("http://") && !requisicao.url.starts_with("https://") {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"erro": "URL inválida. Deve começar com http:// ou https://"
})),
));
}
let mut links = estado.lock().unwrap();
// Gera um código único (tenta novamente se houver colisão)
let codigo = loop {
let candidato = gerar_codigo();
if !links.contains_key(&candidato) {
break candidato;
}
};
let url_curta = format!("{}/{}", URL_BASE, codigo);
let link = LinkEncurtado {
codigo: codigo.clone(),
url_original: requisicao.url.clone(),
url_curta: url_curta.clone(),
cliques: 0,
criado_em: Utc::now(),
};
let resposta = RespostaEncurtar {
codigo: codigo.clone(),
url_curta,
url_original: requisicao.url,
};
links.insert(codigo, link);
Ok((StatusCode::CREATED, Json(resposta)))
}
// GET /:codigo — Redireciona para a URL original
async fn redirecionar(
State(estado): State<EstadoApp>,
Path(codigo): Path<String>,
) -> Result<Redirect, StatusCode> {
let mut links = estado.lock().unwrap();
if let Some(link) = links.get_mut(&codigo) {
link.cliques += 1;
let url = link.url_original.clone();
Ok(Redirect::temporary(&url))
} else {
Err(StatusCode::NOT_FOUND)
}
}
// GET /api/stats/:codigo — Estatísticas de um link
async fn estatisticas(
State(estado): State<EstadoApp>,
Path(codigo): Path<String>,
) -> Result<Json<LinkEncurtado>, StatusCode> {
let links = estado.lock().unwrap();
links
.get(&codigo)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
// GET /api/links — Lista todos os links
async fn listar_links(State(estado): State<EstadoApp>) -> impl IntoResponse {
let links = estado.lock().unwrap();
let lista: Vec<LinkEncurtado> = links.values().cloned().collect();
Json(lista)
}
O handler redirecionar incrementa o contador de cliques antes de redirecionar. Usamos Redirect::temporary (código 307) para que o navegador siga o redirecionamento automaticamente. A validação em encurtar_url verifica se a URL começa com um protocolo válido.
Passo 3: Configurando as Rotas
Precisamos ter cuidado com a ordem das rotas para que /:codigo não capture requisições da API:
use axum::routing::get;
use axum::Router;
fn criar_app(estado: EstadoApp) -> Router {
Router::new()
// Rotas da API (definidas primeiro para ter prioridade)
.route("/api/encurtar", axum::routing::post(encurtar_url))
.route("/api/stats/{codigo}", get(estatisticas))
.route("/api/links", get(listar_links))
// Rota de redirecionamento (captura qualquer código)
.route("/{codigo}", get(redirecionar))
.with_state(estado)
}
As rotas com prefixo /api/ são definidas antes da rota genérica /{codigo} para que o Axum as priorize corretamente.
Passo 4: Montando o main.rs Completo
Aqui está o código completo do src/main.rs:
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Redirect},
routing::get,
Json, Router,
};
use chrono::{DateTime, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
// === Modelos ===
#[derive(Debug, Clone, Serialize)]
pub struct LinkEncurtado {
pub codigo: String,
pub url_original: String,
pub url_curta: String,
pub cliques: u64,
pub criado_em: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct RequisicaoEncurtar {
pub url: String,
}
#[derive(Debug, Serialize)]
pub struct RespostaEncurtar {
pub codigo: String,
pub url_curta: String,
pub url_original: String,
}
pub type EstadoApp = Arc<Mutex<HashMap<String, LinkEncurtado>>>;
const URL_BASE: &str = "http://localhost:3000";
// === Funções auxiliares ===
fn gerar_codigo() -> String {
let caracteres: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = rand::thread_rng();
(0..6)
.map(|_| {
let indice = rng.gen_range(0..caracteres.len());
caracteres[indice] as char
})
.collect()
}
// === Handlers ===
async fn encurtar_url(
State(estado): State<EstadoApp>,
Json(requisicao): Json<RequisicaoEncurtar>,
) -> Result<(StatusCode, Json<RespostaEncurtar>), (StatusCode, Json<serde_json::Value>)> {
if !requisicao.url.starts_with("http://") && !requisicao.url.starts_with("https://") {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"erro": "URL inválida. Deve começar com http:// ou https://"
})),
));
}
let mut links = estado.lock().unwrap();
let codigo = loop {
let candidato = gerar_codigo();
if !links.contains_key(&candidato) {
break candidato;
}
};
let url_curta = format!("{}/{}", URL_BASE, codigo);
let link = LinkEncurtado {
codigo: codigo.clone(),
url_original: requisicao.url.clone(),
url_curta: url_curta.clone(),
cliques: 0,
criado_em: Utc::now(),
};
let resposta = RespostaEncurtar {
codigo: codigo.clone(),
url_curta,
url_original: requisicao.url,
};
links.insert(codigo, link);
Ok((StatusCode::CREATED, Json(resposta)))
}
async fn redirecionar(
State(estado): State<EstadoApp>,
Path(codigo): Path<String>,
) -> Result<Redirect, StatusCode> {
let mut links = estado.lock().unwrap();
if let Some(link) = links.get_mut(&codigo) {
link.cliques += 1;
let url = link.url_original.clone();
Ok(Redirect::temporary(&url))
} else {
Err(StatusCode::NOT_FOUND)
}
}
async fn estatisticas(
State(estado): State<EstadoApp>,
Path(codigo): Path<String>,
) -> Result<Json<LinkEncurtado>, StatusCode> {
let links = estado.lock().unwrap();
links
.get(&codigo)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn listar_links(State(estado): State<EstadoApp>) -> impl IntoResponse {
let links = estado.lock().unwrap();
let lista: Vec<LinkEncurtado> = links.values().cloned().collect();
Json(lista)
}
// === Aplicação ===
#[tokio::main]
async fn main() {
let estado: EstadoApp = Arc::new(Mutex::new(HashMap::new()));
let app = Router::new()
.route("/api/encurtar", axum::routing::post(encurtar_url))
.route("/api/stats/{codigo}", get(estatisticas))
.route("/api/links", get(listar_links))
.route("/{codigo}", get(redirecionar))
.with_state(estado);
let endereco = "0.0.0.0:3000";
println!("Encurtador de URLs rodando em http://{}", endereco);
let listener = tokio::net::TcpListener::bind(endereco).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Como Executar
Compile e inicie o servidor:
cargo run
Teste com curl:
# Encurtar uma URL
curl -X POST http://localhost:3000/api/encurtar \
-H "Content-Type: application/json" \
-d '{"url": "https://www.rust-lang.org/pt-BR/learn"}'
# Resposta:
# {"codigo":"aB3xY7","url_curta":"http://localhost:3000/aB3xY7","url_original":"https://www.rust-lang.org/pt-BR/learn"}
# Testar redirecionamento (use -L para seguir redirects ou -I para ver headers)
curl -I http://localhost:3000/aB3xY7
# HTTP/1.1 307 Temporary Redirect
# location: https://www.rust-lang.org/pt-BR/learn
# Ver estatísticas
curl http://localhost:3000/api/stats/aB3xY7
# Listar todos os links
curl http://localhost:3000/api/links
Desafios para Expandir
Códigos customizados — Permita que o usuário escolha um código curto personalizado (ex:
meu-site) em vez de usar apenas geração aleatória, com validação de caracteres permitidos.Expiração de links — Adicione um campo
expira_emopcional para que links expirem automaticamente após um período definido, retornando 410 Gone quando acessados após a expiração.QR Code — Integre a crate
qrcodepara gerar um QR Code PNG para cada URL encurtada, acessível viaGET /api/qr/:codigo.Proteção contra abuso — Implemente rate limiting por IP e bloqueio de URLs maliciosas usando uma lista negra de domínios conhecidos por phishing.
Dashboard HTML — Crie uma página web simples servida pelo próprio servidor que exibe todos os links com suas estatísticas em uma tabela HTML.
Veja Também
- HashMap na Biblioteca Padrão — Estrutura usada como armazenamento dos links
- String em Rust — Manipulação de strings para construir URLs
- Gerando Hashes em Rust — Alternativa para geração de códigos curtos
- Rust para Desenvolvimento Web — Panorama geral do ecossistema web em Rust
- Criando um Servidor HTTP — Fundamentos de servidores HTTP