Encurtador de URLs em Rust

Construa um serviço encurtador de URLs em Rust com Axum, geração de códigos curtos, redirecionamento HTTP e contagem de cliques.

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

  1. 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.

  2. Expiração de links — Adicione um campo expira_em opcional para que links expirem automaticamente após um período definido, retornando 410 Gone quando acessados após a expiração.

  3. QR Code — Integre a crate qrcode para gerar um QR Code PNG para cada URL encurtada, acessível via GET /api/qr/:codigo.

  4. Proteção contra abuso — Implemente rate limiting por IP e bloqueio de URLs maliciosas usando uma lista negra de domínios conhecidos por phishing.

  5. 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