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
Download de arquivos: Adicione um subcomando
downloadque salve a resposta em um arquivo local com barra de progresso, suportando downloads de arquivos grandes com streaming.Coleções de requisições: Implemente suporte a arquivos
.httpcque descrevem múltiplas requisições (como Postman collections), permitindo executar sequências de requisições em lote.Autenticação integrada: Adicione suporte nativo a Basic Auth (
--auth usuario:senha), Bearer token (--bearer TOKEN) e OAuth2 com fluxo de autorização.Histórico de requisições: Salve automaticamente cada requisição e resposta em um arquivo de histórico, com um subcomando
historicopara revisitar e reexecutar requisições anteriores.Testes de carga simples: Adicione uma flag
--repeticoes N --concorrencia Mque envie múltiplas requisições simultâneas e exiba estatísticas de tempo (min, max, média, p99).
Veja Também
- Manipulação de Strings — operações com strings para headers e URLs
- Tipo Result — tratamento de erros idiomático
- Fazendo Requisições HTTP — receita prática com reqwest
- Serializar e Desserializar JSON — trabalhando com JSON
- Async/Await em Profundidade — entendendo programação assíncrona em Rust