Cliente HTTP Completo em Rust

Construa um cliente HTTP de linha de comando em Rust com suporte a GET, POST, headers, JSON e formatação de resposta.

Interagir com APIs HTTP é uma das tarefas mais comuns no desenvolvimento moderno. Ferramentas como curl e httpie são indispensáveis no dia a dia, e construir sua própria versão é uma excelente forma de aprender sobre protocolos HTTP, programação assíncrona e o ecossistema web do Rust. Neste projeto, vamos construir um cliente HTTP de linha de comando que suporta os principais métodos (GET, POST, PUT, DELETE), envio de headers customizados, corpo JSON e formatação elegante das respostas.

Este projeto usa reqwest (o cliente HTTP mais popular do Rust) e tokio (o runtime assíncrono padrão), introduzindo conceitos essenciais de async/await de forma prática e aplicada.

O Que Vamos Construir

Nosso httpc (HTTP Client) terá os seguintes recursos:

  • Métodos HTTP: GET, POST, PUT, DELETE
  • Headers customizados via flag
  • Corpo da requisição em JSON ou texto
  • Formatação colorida da resposta (status, headers, corpo)
  • Pretty-print automático de respostas JSON
  • Medição do tempo de resposta
  • Suporte a timeout configurável
  • Modo verbose com detalhes da requisição

Estrutura do Projeto

httpc/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── cli.rs
    ├── requisicao.rs
    └── formatador.rs

Configurando o Projeto

cargo new httpc
cd httpc

Configure o Cargo.toml:

[package]
name = "httpc"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
colored = "2"

As crates reqwest e tokio formam a dupla principal para operações HTTP assíncronas em Rust. O reqwest fornece uma API ergonômica para requisições e o tokio gerencia a execução assíncrona.

Passo 1: Interface de Linha de Comando

// src/cli.rs
use clap::{Parser, ValueEnum};

#[derive(Parser, Debug)]
#[command(name = "httpc")]
#[command(about = "Cliente HTTP de linha de comando — como curl, mas em Rust")]
pub struct Cli {
    /// URL da requisição
    pub url: String,

    /// Método HTTP
    #[arg(short, long, default_value = "get")]
    pub metodo: MetodoHttp,

    /// Headers no formato "Chave: Valor" (pode repetir)
    #[arg(short = 'H', long = "header")]
    pub headers: Vec<String>,

    /// Corpo da requisição (JSON ou texto)
    #[arg(short = 'd', long)]
    pub dados: Option<String>,

    /// Arquivo JSON para enviar como corpo
    #[arg(short = 'a', long)]
    pub arquivo: Option<String>,

    /// Timeout em segundos
    #[arg(short, long, default_value_t = 30)]
    pub timeout: u64,

    /// Exibir headers da resposta
    #[arg(long, default_value_t = true)]
    pub mostrar_headers: bool,

    /// Exibir corpo da resposta
    #[arg(long, default_value_t = true)]
    pub mostrar_corpo: bool,

    /// Modo verbose (mostra detalhes da requisição)
    #[arg(short, long)]
    pub verbose: bool,

    /// Não verificar certificado SSL
    #[arg(long)]
    pub inseguro: bool,
}

#[derive(Debug, Clone, ValueEnum)]
pub enum MetodoHttp {
    Get,
    Post,
    Put,
    Delete,
    Patch,
    Head,
}

impl MetodoHttp {
    pub fn para_reqwest(&self) -> reqwest::Method {
        match self {
            MetodoHttp::Get => reqwest::Method::GET,
            MetodoHttp::Post => reqwest::Method::POST,
            MetodoHttp::Put => reqwest::Method::PUT,
            MetodoHttp::Delete => reqwest::Method::DELETE,
            MetodoHttp::Patch => reqwest::Method::PATCH,
            MetodoHttp::Head => reqwest::Method::HEAD,
        }
    }

    pub fn nome(&self) -> &str {
        match self {
            MetodoHttp::Get => "GET",
            MetodoHttp::Post => "POST",
            MetodoHttp::Put => "PUT",
            MetodoHttp::Delete => "DELETE",
            MetodoHttp::Patch => "PATCH",
            MetodoHttp::Head => "HEAD",
        }
    }
}

Passo 2: Construção e Envio de Requisições

// src/requisicao.rs
use crate::cli::Cli;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use std::fs;
use std::time::{Duration, Instant};

/// Resultado de uma requisição HTTP
pub struct RespostaHttp {
    pub status: u16,
    pub texto_status: String,
    pub headers: Vec<(String, String)>,
    pub corpo: String,
    pub tempo: Duration,
    pub tamanho: usize,
    pub content_type: String,
}

/// Parseia headers do formato "Chave: Valor"
fn parsear_headers(headers_str: &[String]) -> Result<HeaderMap, String> {
    let mut mapa = HeaderMap::new();

    for header in headers_str {
        let partes: Vec<&str> = header.splitn(2, ':').collect();
        if partes.len() != 2 {
            return Err(format!("Header inválido: '{}'. Use o formato 'Chave: Valor'.", header));
        }

        let nome = partes[0].trim();
        let valor = partes[1].trim();

        let header_name = HeaderName::from_bytes(nome.as_bytes())
            .map_err(|e| format!("Nome de header inválido '{}': {}", nome, e))?;
        let header_value = HeaderValue::from_str(valor)
            .map_err(|e| format!("Valor de header inválido '{}': {}", valor, e))?;

        mapa.insert(header_name, header_value);
    }

    Ok(mapa)
}

/// Obtém o corpo da requisição a partir dos argumentos
fn obter_corpo(cli: &Cli) -> Result<Option<String>, String> {
    if let Some(ref dados) = cli.dados {
        return Ok(Some(dados.clone()));
    }

    if let Some(ref arquivo) = cli.arquivo {
        let conteudo = fs::read_to_string(arquivo)
            .map_err(|e| format!("Erro ao ler '{}': {}", arquivo, e))?;
        return Ok(Some(conteudo));
    }

    Ok(None)
}

/// Executa a requisição HTTP e retorna a resposta
pub async fn executar(cli: &Cli) -> Result<RespostaHttp, String> {
    let metodo = cli.metodo.para_reqwest();

    // Construir o cliente
    let cliente = reqwest::Client::builder()
        .timeout(Duration::from_secs(cli.timeout))
        .danger_accept_invalid_certs(cli.inseguro)
        .build()
        .map_err(|e| format!("Erro ao criar cliente HTTP: {}", e))?;

    // Parsear headers
    let headers = parsear_headers(&cli.headers)?;

    // Construir a requisição
    let mut req = cliente.request(metodo, &cli.url).headers(headers);

    // Adicionar corpo se houver
    if let Some(corpo) = obter_corpo(cli)? {
        // Tentar detectar se é JSON
        if serde_json::from_str::<serde_json::Value>(&corpo).is_ok() {
            req = req
                .header("Content-Type", "application/json")
                .body(corpo);
        } else {
            req = req.body(corpo);
        }
    }

    // Medir tempo e enviar
    let inicio = Instant::now();
    let resposta = req
        .send()
        .await
        .map_err(|e| format!("Erro na requisição: {}", e))?;

    let tempo = inicio.elapsed();

    // Extrair informações da resposta
    let status = resposta.status().as_u16();
    let texto_status = resposta.status().canonical_reason()
        .unwrap_or("Desconhecido")
        .to_string();

    let content_type = resposta
        .headers()
        .get("content-type")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_string();

    let resp_headers: Vec<(String, String)> = resposta
        .headers()
        .iter()
        .map(|(nome, valor)| {
            (
                nome.to_string(),
                valor.to_str().unwrap_or("<binário>").to_string(),
            )
        })
        .collect();

    let corpo = resposta
        .text()
        .await
        .map_err(|e| format!("Erro ao ler corpo: {}", e))?;

    let tamanho = corpo.len();

    Ok(RespostaHttp {
        status,
        texto_status,
        headers: resp_headers,
        corpo,
        tempo,
        tamanho,
        content_type,
    })
}

A função executar é assíncrona (async fn) porque usa o reqwest, que opera de forma não bloqueante. O await suspende a execução até que a resposta HTTP chegue, permitindo que o runtime tokio gerencie recursos eficientemente.

Passo 3: Formatação da Resposta

// src/formatador.rs
use crate::requisicao::RespostaHttp;
use colored::*;
use std::time::Duration;

/// Exibe informações da requisição (modo verbose)
pub fn exibir_requisicao(metodo: &str, url: &str, headers: &[(String, String)]) {
    println!("{}", ">>> Requisição".bold().cyan());
    println!("  {} {}", metodo.bold().yellow(), url);

    if !headers.is_empty() {
        for (nome, valor) in headers {
            println!("  {}: {}", nome.dimmed(), valor);
        }
    }

    println!();
}

/// Exibe a resposta formatada
pub fn exibir_resposta(resposta: &RespostaHttp, mostrar_headers: bool, mostrar_corpo: bool) {
    // Linha de status
    let status_colorido = colorir_status(resposta.status, &format!(
        "{} {}",
        resposta.status,
        resposta.texto_status
    ));

    println!("{}", "<<< Resposta".bold().cyan());
    println!("  Status: {}", status_colorido);
    println!("  Tempo:  {}", formatar_tempo(resposta.tempo));
    println!("  Tamanho: {} bytes", resposta.tamanho);

    // Headers
    if mostrar_headers && !resposta.headers.is_empty() {
        println!("\n{}", "  Headers:".bold());
        for (nome, valor) in &resposta.headers {
            println!("    {}: {}", nome.dimmed(), valor);
        }
    }

    // Corpo
    if mostrar_corpo && !resposta.corpo.is_empty() {
        println!("\n{}", "  Corpo:".bold());

        if resposta.content_type.contains("json") {
            exibir_json_formatado(&resposta.corpo);
        } else {
            // Limitar exibição para corpos muito grandes
            if resposta.corpo.len() > 5000 {
                println!("{}", &resposta.corpo[..5000]);
                println!(
                    "\n  {} ... ({} bytes restantes omitidos)",
                    "TRUNCADO:".yellow(),
                    resposta.corpo.len() - 5000
                );
            } else {
                println!("{}", resposta.corpo);
            }
        }
    }
}

/// Colorir o código de status HTTP
fn colorir_status(codigo: u16, texto: &str) -> String {
    match codigo {
        200..=299 => texto.green().bold().to_string(),
        300..=399 => texto.blue().to_string(),
        400..=499 => texto.yellow().bold().to_string(),
        500..=599 => texto.red().bold().to_string(),
        _ => texto.to_string(),
    }
}

/// Formatar duração de forma legível
fn formatar_tempo(duracao: Duration) -> String {
    let ms = duracao.as_millis();
    if ms < 1000 {
        format!("{} ms", ms).to_string()
    } else {
        format!("{:.2} s", duracao.as_secs_f64())
    }
}

/// Exibir JSON com pretty-print
fn exibir_json_formatado(texto: &str) {
    match serde_json::from_str::<serde_json::Value>(texto) {
        Ok(valor) => {
            let formatado = serde_json::to_string_pretty(&valor)
                .unwrap_or_else(|_| texto.to_string());
            println!("{}", formatado);
        }
        Err(_) => {
            println!("{}", texto);
        }
    }
}

A formatação colore o código de status de acordo com a faixa HTTP: 2xx em verde (sucesso), 3xx em azul (redirecionamento), 4xx em amarelo (erro do cliente) e 5xx em vermelho (erro do servidor). Respostas JSON são automaticamente formatadas com indentação.

Passo 4: Integrando no main.rs

// src/main.rs
mod cli;
mod formatador;
mod requisicao;

use clap::Parser;
use cli::Cli;
use colored::*;

#[tokio::main]
async fn main() {
    let cli = Cli::parse();

    // Validar URL
    if !cli.url.starts_with("http://") && !cli.url.starts_with("https://") {
        eprintln!(
            "{} URL deve começar com http:// ou https://",
            "ERRO:".red().bold()
        );
        std::process::exit(1);
    }

    // Modo verbose: exibir detalhes da requisição
    if cli.verbose {
        let headers_debug: Vec<(String, String)> = cli
            .headers
            .iter()
            .filter_map(|h| {
                let partes: Vec<&str> = h.splitn(2, ':').collect();
                if partes.len() == 2 {
                    Some((partes[0].trim().to_string(), partes[1].trim().to_string()))
                } else {
                    None
                }
            })
            .collect();

        formatador::exibir_requisicao(cli.metodo.nome(), &cli.url, &headers_debug);
    }

    // Executar a requisição
    match requisicao::executar(&cli).await {
        Ok(resposta) => {
            formatador::exibir_resposta(
                &resposta,
                cli.mostrar_headers,
                cli.mostrar_corpo,
            );

            // Código de saída baseado no status HTTP
            if resposta.status >= 400 {
                std::process::exit(1);
            }
        }
        Err(e) => {
            eprintln!("{} {}", "ERRO:".red().bold(), e);
            std::process::exit(1);
        }
    }
}

O atributo #[tokio::main] transforma a função main em uma função assíncrona, inicializando o runtime tokio automaticamente. Isso permite usar await dentro do main.

Como Executar

cargo build --release

Exemplos de uso:

# Requisição GET simples
./target/release/httpc https://httpbin.org/get
# <<< Resposta
#   Status: 200 OK
#   Tempo:  234 ms
#   Corpo: { "args": {}, "url": "https://httpbin.org/get", ... }

# POST com corpo JSON
./target/release/httpc https://httpbin.org/post \
  -m post \
  -d '{"nome": "João", "cidade": "São Paulo"}'

# POST com arquivo JSON
./target/release/httpc https://api.exemplo.com/usuarios \
  -m post \
  -a dados.json

# Headers customizados
./target/release/httpc https://api.exemplo.com/dados \
  -H "Authorization: Bearer meu_token_123" \
  -H "Accept: application/json"

# PUT para atualizar recurso
./target/release/httpc https://api.exemplo.com/usuarios/42 \
  -m put \
  -d '{"nome": "Maria Silva"}' \
  -H "Content-Type: application/json"

# DELETE
./target/release/httpc https://api.exemplo.com/usuarios/42 -m delete

# Modo verbose com timeout curto
./target/release/httpc https://httpbin.org/delay/2 -v -t 5

# Apenas status e headers (sem corpo)
./target/release/httpc https://httpbin.org/get --mostrar-corpo false

# Aceitar certificados inválidos (desenvolvimento)
./target/release/httpc https://localhost:8443/api --inseguro

Desafios para Expandir

  1. Download de arquivos: Adicione um subcomando download que salve a resposta em um arquivo local com barra de progresso, suportando downloads de arquivos grandes com streaming.

  2. Coleções de requisições: Implemente suporte a arquivos .httpc que descrevem múltiplas requisições (como Postman collections), permitindo executar sequências de requisições em lote.

  3. Autenticação integrada: Adicione suporte nativo a Basic Auth (--auth usuario:senha), Bearer token (--bearer TOKEN) e OAuth2 com fluxo de autorização.

  4. Histórico de requisições: Salve automaticamente cada requisição e resposta em um arquivo de histórico, com um subcomando historico para revisitar e reexecutar requisições anteriores.

  5. Testes de carga simples: Adicione uma flag --repeticoes N --concorrencia M que envie múltiplas requisições simultâneas e exiba estatísticas de tempo (min, max, média, p99).

Veja Também