URL Shortener em Zig — Tutorial Passo a Passo

URL Shortener em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um serviço de encurtamento de URLs com servidor HTTP embutido em Zig. O serviço aceita URLs longas, gera códigos curtos e faz o redirecionamento quando o código é acessado.

O Que Vamos Construir

Nosso URL shortener vai:

  • Servidor HTTP que aceita requisições POST para criar URLs curtas
  • Gerador de códigos alfanuméricos curtos (6 caracteres)
  • Redirecionamento HTTP 301 ao acessar URL curta
  • Armazenamento em memória com contagem de acessos
  • API JSON para criação e consulta de URLs
  • Página de estatísticas

Por Que Este Projeto?

URL shorteners são um dos projetos web mais instrutivos. Eles combinam servidor HTTP, geração de identificadores, armazenamento de dados e redirecionamento — tudo em um projeto compacto. Em Zig, construímos o servidor HTTP do zero, o que nos dá entendimento profundo do protocolo.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir url-shortener
cd url-shortener
zig init

Passo 2: Armazenamento de URLs

const std = @import("std");
const net = std.net;
const posix = std.posix;
const io = std.io;
const mem = std.mem;
const fmt = std.fmt;
const time = std.time;

/// Representa uma URL armazenada no sistema.
const UrlEntry = struct {
    url_original: []const u8,
    codigo: []const u8,
    acessos: u64,
    criado_em: i64, // timestamp em ms
};

/// Armazena e gerencia as URLs encurtadas.
const UrlStore = struct {
    /// Mapeia código curto -> entrada de URL
    por_codigo: std.StringHashMap(UrlEntry),
    /// Mapeia URL original -> código (para evitar duplicatas)
    por_url: std.StringHashMap([]const u8),
    allocator: mem.Allocator,
    contador: u64,

    const Self = @This();
    const CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    const TAMANHO_CODIGO = 6;

    pub fn init(allocator: mem.Allocator) Self {
        return .{
            .por_codigo = std.StringHashMap(UrlEntry).init(allocator),
            .por_url = std.StringHashMap([]const u8).init(allocator),
            .allocator = allocator,
            .contador = 0,
        };
    }

    pub fn deinit(self: *Self) void {
        var it = self.por_codigo.iterator();
        while (it.next()) |entry| {
            self.allocator.free(entry.value_ptr.url_original);
            self.allocator.free(entry.value_ptr.codigo);
        }
        self.por_codigo.deinit();
        self.por_url.deinit();
    }

    /// Gera um código curto baseado no contador interno.
    fn gerarCodigo(self: *Self, buf: *[TAMANHO_CODIGO]u8) void {
        self.contador += 1;
        var n = self.contador;
        for (0..TAMANHO_CODIGO) |i| {
            const idx = n % CHARSET.len;
            buf[TAMANHO_CODIGO - 1 - i] = CHARSET[idx];
            n /= CHARSET.len;
        }
    }

    /// Encurta uma URL. Se já foi encurtada antes, retorna o código existente.
    pub fn encurtar(self: *Self, url_original: []const u8) ![]const u8 {
        // Verificar se já existe
        if (self.por_url.get(url_original)) |codigo_existente| {
            return codigo_existente;
        }

        // Gerar novo código
        var codigo_buf: [TAMANHO_CODIGO]u8 = undefined;
        self.gerarCodigo(&codigo_buf);

        const url_copia = try self.allocator.dupe(u8, url_original);
        errdefer self.allocator.free(url_copia);

        const codigo_copia = try self.allocator.dupe(u8, &codigo_buf);
        errdefer self.allocator.free(codigo_copia);

        const entrada = UrlEntry{
            .url_original = url_copia,
            .codigo = codigo_copia,
            .acessos = 0,
            .criado_em = time.milliTimestamp(),
        };

        try self.por_codigo.put(codigo_copia, entrada);
        try self.por_url.put(url_copia, codigo_copia);

        return codigo_copia;
    }

    /// Busca a URL original pelo código curto e incrementa o contador de acessos.
    pub fn resolver(self: *Self, codigo: []const u8) ?[]const u8 {
        if (self.por_codigo.getPtr(codigo)) |entrada| {
            entrada.acessos += 1;
            return entrada.url_original;
        }
        return null;
    }

    /// Retorna estatísticas de uma URL pelo código.
    pub fn stats(self: *const Self, codigo: []const u8) ?UrlEntry {
        return self.por_codigo.get(codigo);
    }

    /// Retorna o total de URLs armazenadas.
    pub fn total(self: *const Self) usize {
        return self.por_codigo.count();
    }
};

Passo 3: Parser HTTP Simples

/// Representa uma requisição HTTP parseada.
const HttpRequest = struct {
    metodo: []const u8,
    caminho: []const u8,
    corpo: []const u8,
    content_length: usize,
};

/// Faz o parsing básico de uma requisição HTTP.
fn parsearRequest(dados: []const u8) ?HttpRequest {
    // Encontrar a primeira linha (Request Line)
    const fim_linha = mem.indexOf(u8, dados, "\r\n") orelse return null;
    const request_line = dados[0..fim_linha];

    var it = mem.splitScalar(u8, request_line, ' ');
    const metodo = it.next() orelse return null;
    const caminho = it.next() orelse return null;

    // Encontrar Content-Length
    var content_length: usize = 0;
    if (mem.indexOf(u8, dados, "Content-Length: ")) |pos| {
        const inicio = pos + "Content-Length: ".len;
        const fim = mem.indexOfPos(u8, dados, inicio, "\r\n") orelse dados.len;
        content_length = fmt.parseInt(usize, dados[inicio..fim], 10) catch 0;
    }

    // Encontrar o corpo (depois de \r\n\r\n)
    const corpo = if (mem.indexOf(u8, dados, "\r\n\r\n")) |pos|
        dados[pos + 4 ..]
    else
        "";

    return .{
        .metodo = metodo,
        .caminho = caminho,
        .corpo = corpo,
        .content_length = content_length,
    };
}

/// Envia uma resposta HTTP.
fn enviarResposta(
    socket: posix.socket_t,
    status: []const u8,
    content_type: []const u8,
    corpo: []const u8,
) void {
    var buf: [8192]u8 = undefined;
    const resposta = fmt.bufPrint(&buf,
        "HTTP/1.1 {s}\r\n" ++
            "Content-Type: {s}\r\n" ++
            "Content-Length: {d}\r\n" ++
            "Connection: close\r\n" ++
            "\r\n" ++
            "{s}",
        .{ status, content_type, corpo.len, corpo },
    ) catch return;
    _ = posix.write(socket, resposta) catch {};
}

/// Envia um redirecionamento HTTP 301.
fn enviarRedirecionamento(socket: posix.socket_t, url: []const u8) void {
    var buf: [4096]u8 = undefined;
    const resposta = fmt.bufPrint(&buf,
        "HTTP/1.1 301 Moved Permanently\r\n" ++
            "Location: {s}\r\n" ++
            "Content-Length: 0\r\n" ++
            "Connection: close\r\n" ++
            "\r\n",
        .{url},
    ) catch return;
    _ = posix.write(socket, resposta) catch {};
}

Passo 4: Roteamento e Handlers

/// Processa uma requisição e envia a resposta apropriada.
fn processarRequest(
    store: *UrlStore,
    request: HttpRequest,
    socket: posix.socket_t,
    base_url: []const u8,
) void {
    // POST /encurtar — cria uma URL curta
    if (mem.eql(u8, request.metodo, "POST") and mem.eql(u8, request.caminho, "/encurtar")) {
        const url = mem.trim(u8, request.corpo, " \t\r\n");
        if (url.len == 0) {
            enviarResposta(socket, "400 Bad Request", "text/plain", "URL nao fornecida");
            return;
        }

        const codigo = store.encurtar(url) catch {
            enviarResposta(socket, "500 Internal Server Error", "text/plain", "Erro interno");
            return;
        };

        var buf_resposta: [512]u8 = undefined;
        const json = fmt.bufPrint(&buf_resposta,
            \\{{"codigo":"{s}","url_curta":"{s}/{s}","url_original":"{s}"}}
        , .{ codigo, base_url, codigo, url }) catch return;

        enviarResposta(socket, "201 Created", "application/json", json);
        return;
    }

    // GET /stats/<codigo> — estatísticas de uma URL
    if (mem.eql(u8, request.metodo, "GET") and mem.startsWith(u8, request.caminho, "/stats/")) {
        const codigo = request.caminho[7..];
        if (store.stats(codigo)) |entrada| {
            var buf_resposta: [1024]u8 = undefined;
            const json = fmt.bufPrint(&buf_resposta,
                \\{{"codigo":"{s}","url_original":"{s}","acessos":{d}}}
            , .{ entrada.codigo, entrada.url_original, entrada.acessos }) catch return;

            enviarResposta(socket, "200 OK", "application/json", json);
        } else {
            enviarResposta(socket, "404 Not Found", "application/json", "{\"erro\":\"URL nao encontrada\"}");
        }
        return;
    }

    // GET / — página principal
    if (mem.eql(u8, request.metodo, "GET") and mem.eql(u8, request.caminho, "/")) {
        const html =
            \\<!DOCTYPE html><html><head><title>URL Shortener Zig</title></head>
            \\<body><h1>URL Shortener em Zig</h1>
            \\<p>API: POST /encurtar com a URL no corpo</p>
            \\<p>Exemplo: curl -X POST -d "https://ziglang.org" http://localhost:8080/encurtar</p>
            \\</body></html>
        ;
        enviarResposta(socket, "200 OK", "text/html", html);
        return;
    }

    // GET /<codigo> — redirecionamento
    if (mem.eql(u8, request.metodo, "GET") and request.caminho.len > 1) {
        const codigo = request.caminho[1..]; // remove o '/' inicial
        if (store.resolver(codigo)) |url_original| {
            enviarRedirecionamento(socket, url_original);
            return;
        }
    }

    enviarResposta(socket, "404 Not Found", "text/plain", "Pagina nao encontrada");
}

Passo 5: Servidor HTTP Principal

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const stdout = io.getStdOut().writer();
    const porta: u16 = 8080;
    const base_url = "http://localhost:8080";

    var store = UrlStore.init(allocator);
    defer store.deinit();

    // Criar socket do servidor
    const endereco = net.Address.initIp4(.{ 0, 0, 0, 0 }, porta);
    const socket = try posix.socket(posix.AF.INET, posix.SOCK.STREAM, 0);
    defer posix.close(socket);

    const optval: i32 = 1;
    try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.REUSEADDR, mem.asBytes(&optval));
    try posix.bind(socket, &endereco.any, endereco.getOsSockLen());
    try posix.listen(socket, 10);

    try stdout.print(
        \\
        \\  ==========================================
        \\     URL SHORTENER - Zig
        \\  ==========================================
        \\  Rodando em {s}
        \\
        \\  Endpoints:
        \\    POST /encurtar        - Encurtar URL
        \\    GET  /<codigo>        - Redirecionar
        \\    GET  /stats/<codigo>  - Estatisticas
        \\
        \\  Teste:
        \\    curl -X POST -d "https://ziglang.org" {s}/encurtar
        \\  ==========================================
        \\
    , .{ base_url, base_url });

    // Loop principal — aceita conexões
    while (true) {
        const cliente = posix.accept(socket, null, null) catch continue;

        // Ler request
        var buf: [8192]u8 = undefined;
        const n = posix.read(cliente, &buf) catch {
            posix.close(cliente);
            continue;
        };
        if (n == 0) {
            posix.close(cliente);
            continue;
        }

        if (parsearRequest(buf[0..n])) |request| {
            try stdout.print("  {s} {s}\n", .{ request.metodo, request.caminho });
            processarRequest(&store, request, cliente, base_url);
        } else {
            enviarResposta(cliente, "400 Bad Request", "text/plain", "Request invalida");
        }

        posix.close(cliente);
    }
}

Testes

test "url store - encurtar e resolver" {
    const allocator = std.testing.allocator;
    var store = UrlStore.init(allocator);
    defer store.deinit();

    const codigo = try store.encurtar("https://ziglang.org");
    try std.testing.expectEqual(@as(usize, 6), codigo.len);

    const url = store.resolver(codigo);
    try std.testing.expect(url != null);
    try std.testing.expectEqualStrings("https://ziglang.org", url.?);
}

test "url store - deduplicacao" {
    const allocator = std.testing.allocator;
    var store = UrlStore.init(allocator);
    defer store.deinit();

    const c1 = try store.encurtar("https://example.com");
    const c2 = try store.encurtar("https://example.com");
    try std.testing.expectEqualStrings(c1, c2);
}

test "url store - contagem de acessos" {
    const allocator = std.testing.allocator;
    var store = UrlStore.init(allocator);
    defer store.deinit();

    const codigo = try store.encurtar("https://zig.news");
    _ = store.resolver(codigo);
    _ = store.resolver(codigo);
    _ = store.resolver(codigo);

    const entry = store.stats(codigo).?;
    try std.testing.expectEqual(@as(u64, 3), entry.acessos);
}

test "parser http - GET simples" {
    const raw = "GET /abc123 HTTP/1.1\r\nHost: localhost\r\n\r\n";
    const req = parsearRequest(raw).?;
    try std.testing.expectEqualStrings("GET", req.metodo);
    try std.testing.expectEqualStrings("/abc123", req.caminho);
}

Compilando e Executando

# Compilar e executar
zig build run

# Em outro terminal, testar:
# Criar URL curta
curl -X POST -d "https://ziglang.org" http://localhost:8080/encurtar

# Acessar URL curta (redirecionamento)
curl -L http://localhost:8080/aaaaab

# Ver estatísticas
curl http://localhost:8080/stats/aaaaab

# Rodar testes
zig build test

Conceitos Aprendidos

  • Servidor HTTP com parsing manual do protocolo
  • Roteamento de requisições por método e caminho
  • Geração de códigos com base alfanumérica
  • Deduplicação com mapa reverso
  • Respostas JSON e redirecionamento HTTP 301

Próximos Passos

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.