Para fazer upload de arquivos em Zig para uma API que aceita multipart/form-data, você precisa montar um corpo HTTP com um boundary, adicionar um bloco para cada campo ou arquivo e enviar o resultado com std.http.Client. A biblioteca padrão fornece o cliente HTTP, mas o formato multipart continua explícito: seu programa controla cabeçalhos, bytes, nomes de campos e limites de memória.
Este guia implementa um upload com um campo de texto e um arquivo, mostra como testar a requisição localmente e explica quando deixar de montar todo o corpo na RAM. Como as APIs de std.http ainda podem mudar antes do Zig 1.0, rode zig version e confira a assinatura de Client.fetch na versão instalada caso um nome de opção seja diferente.
O que é multipart/form-data?
APIs usam multipart/form-data quando precisam receber dados de naturezas diferentes na mesma requisição. Um formulário pode carregar, por exemplo:
- o campo textual
descricao; - o arquivo
relatorio.csv; - um identificador de projeto;
- uma imagem com seu tipo MIME.
O corpo é dividido por uma sequência chamada boundary. Cada parte tem cabeçalhos próprios e termina com \r\n. Uma requisição simplificada se parece com isto:
--zig-boundary-7f3a
Content-Disposition: form-data; name="descricao"
Relatório diário
--zig-boundary-7f3a
Content-Disposition: form-data; name="arquivo"; filename="relatorio.csv"
Content-Type: text/csv
id,valor
1,42
--zig-boundary-7f3a--
O cabeçalho HTTP deve repetir exatamente o mesmo boundary:
Content-Type: multipart/form-data; boundary=zig-boundary-7f3a
Se o valor no cabeçalho e o delimitador do corpo forem diferentes, o servidor não conseguirá separar as partes.
Exemplo completo: campo de texto mais arquivo
O exemplo abaixo monta o payload em memória. Esse desenho é adequado para arquivos pequenos, testes, CLIs internas e integrações em que você controla o limite máximo de entrada.
const std = @import("std");
fn appendTextField(
body: *std.ArrayList(u8),
boundary: []const u8,
name: []const u8,
value: []const u8,
) !void {
try body.writer().print(
"--{s}\r\n" ++
"Content-Disposition: form-data; name=\"{s}\"\r\n" ++
"\r\n" ++
"{s}\r\n",
.{ boundary, name, value },
);
}
fn appendFileField(
body: *std.ArrayList(u8),
boundary: []const u8,
field_name: []const u8,
filename: []const u8,
content_type: []const u8,
bytes: []const u8,
) !void {
try body.writer().print(
"--{s}\r\n" ++
"Content-Disposition: form-data; name=\"{s}\"; filename=\"{s}\"\r\n" ++
"Content-Type: {s}\r\n" ++
"\r\n",
.{ boundary, field_name, filename, content_type },
);
try body.appendSlice(bytes);
try body.appendSlice("\r\n");
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const path = "relatorio.csv";
const max_file_size = 5 * 1024 * 1024;
const file_bytes = try std.fs.cwd().readFileAlloc(
allocator,
path,
max_file_size,
);
defer allocator.free(file_bytes);
const boundary = "zig-boundary-7f3a9c21";
var body = std.ArrayList(u8).init(allocator);
defer body.deinit();
try appendTextField(
&body,
boundary,
"descricao",
"Relatório gerado pela CLI Zig",
);
try appendFileField(
&body,
boundary,
"arquivo",
"relatorio.csv",
"text/csv",
file_bytes,
);
try body.writer().print("--{s}--\r\n", .{boundary});
var client = std.http.Client{ .allocator = allocator };
defer client.deinit();
var response_body = std.ArrayList(u8).init(allocator);
defer response_body.deinit();
const content_type = try std.fmt.allocPrint(
allocator,
"multipart/form-data; boundary={s}",
.{boundary},
);
defer allocator.free(content_type);
const result = try client.fetch(.{
.location = .{ .url = "http://127.0.0.1:8080/upload" },
.method = .POST,
.payload = body.items,
.extra_headers = &.{
.{ .name = "Content-Type", .value = content_type },
.{ .name = "Accept", .value = "application/json" },
},
.response_storage = .{ .dynamic = &response_body },
});
std.debug.print("Status: {d}\n", .{@intFromEnum(result.status)});
std.debug.print("Resposta: {s}\n", .{response_body.items});
}
A parte mais importante não é um método específico do cliente. É o contrato do payload:
- cada parte começa com
--boundarye\r\n; Content-Dispositioninforma o nome do campo;- arquivos também recebem
filenamee, idealmente,Content-Type; - existe uma linha vazia entre os cabeçalhos da parte e seu conteúdo;
- o corpo termina com
--boundary--.
Como escolher um boundary seguro
O boundary não deve aparecer dentro dos bytes enviados. Uma string fixa curta funciona em uma demonstração, mas uma aplicação real deve gerar um valor imprevisível e suficientemente longo por requisição.
Uma abordagem prática é gerar bytes aleatórios e convertê-los para hexadecimal:
var random_bytes: [16]u8 = undefined;
std.crypto.random.bytes(&random_bytes);
var boundary_buffer: [32]u8 = undefined;
const boundary = try std.fmt.bufPrint(
&boundary_buffer,
"{x}",
.{random_bytes},
);
Você também pode adicionar um prefixo legível, desde que preserve espaço suficiente no buffer. Não aceite um boundary fornecido diretamente por usuário e não use quebra de linha dentro dele.
Não confie no nome original do arquivo
filename é metadado enviado ao servidor. Se o nome vier de entrada externa, elimine aspas, \r, \n, barras e sequências como ../ antes de colocá-lo em Content-Disposition. Caso contrário, você pode criar um cabeçalho malformado ou transferir um nome perigoso para uma aplicação que salva arquivos sem normalização.
No lado receptor, a regra continua sendo: gere um nome interno, armazene o nome original apenas como metadado e nunca use esse valor como caminho sem validação.
Também não trate Content-Type como prova do formato. O cliente pode declarar image/png e enviar qualquer sequência de bytes. A API receptora precisa validar tamanho, extensão quando relevante e assinatura real do arquivo.
Autenticação Bearer e API key
Headers de autenticação pertencem à requisição HTTP, não às partes multipart. Para um token Bearer:
const authorization = try std.fmt.allocPrint(
allocator,
"Bearer {s}",
.{token},
);
defer allocator.free(authorization);
const result = try client.fetch(.{
.location = .{ .url = upload_url },
.method = .POST,
.payload = body.items,
.extra_headers = &.{
.{ .name = "Content-Type", .value = content_type },
.{ .name = "Authorization", .value = authorization },
},
.response_storage = .{ .dynamic = &response_body },
});
Para uma API key, use o nome exigido pelo provedor, como X-API-Key. Nunca registre o token completo em logs e não o inclua no nome do arquivo, boundary ou corpo por conveniência.
Controle de memória: quando esse exemplo deixa de servir
No exemplo, o arquivo é carregado em file_bytes e depois copiado para body. Um arquivo de 5 MB pode ocupar aproximadamente 10 MB, além da capacidade extra reservada pelo ArrayList e da resposta HTTP. Para uma CLI isso pode ser aceitável. Para vídeos, backups ou múltiplos uploads concorrentes, não é.
Use esta regra de decisão:
| Situação | Estratégia recomendada |
|---|---|
| Arquivo pequeno e limite conhecido | Montar o corpo em memória |
| Arquivo médio em uma CLI | Reservar capacidade e impor limite rígido |
| Arquivo grande | Fazer streaming do arquivo para a conexão |
| Tamanho desconhecido | Streaming com limite contabilizado |
| Upload concorrente no servidor | Streaming, backpressure e cota por usuário |
No modo streaming, escreva o preâmbulo multipart, copie o arquivo em blocos e finalize com o boundary de fechamento. Dependendo da versão de std.http.Client, isso pode exigir a API de requisição de nível mais baixo em vez de fetch. Não misture exemplos de releases diferentes: confira a documentação instalada e mantenha cliente, request, writer e leitura da resposta no mesmo conjunto de APIs.
Se o servidor exigir Content-Length, calcule a soma dos bytes de todos os preâmbulos, conteúdos, \r\n e fechamento antes de enviar. Se aceitar Transfer-Encoding: chunked, o cliente pode transmitir sem conhecer o tamanho final, conforme o suporte da versão utilizada.
Como testar sem depender da API real
Antes de enviar dados para produção, compare sua requisição com um curl equivalente:
curl -v \
-F 'descricao=Relatório gerado pela CLI Zig' \
-F '[email protected];type=text/csv' \
http://127.0.0.1:8080/upload
Você pode subir um receptor temporário com um framework conhecido, um mock HTTP ou um serviço de inspeção controlado pela equipe. Evite enviar arquivos confidenciais para sites públicos de eco de requisições.
Durante o diagnóstico, verifique:
- se o
Content-Typecontém o boundary correto; - se todas as linhas multipart usam
\r\n; - se existe linha vazia antes dos bytes do arquivo;
- se o fechamento tem dois hífens extras:
--boundary--; - se o campo se chama exatamente como a API espera;
- se o status retornado é
200,201,202ou outro documentado; - se respostas
400,413,415e422são registradas com contexto seguro.
O status 413 Payload Too Large normalmente indica limite no servidor ou proxy. 415 Unsupported Media Type aponta para Content-Type ausente ou incompatível. 422 Unprocessable Content costuma significar que o multipart foi entendido, mas algum campo não passou pela validação da API.
Erros que aparecem com frequência
“Missing boundary”
O corpo tem delimitadores, mas o cabeçalho foi enviado apenas como multipart/form-data. Acrescente ; boundary=... e use o mesmo valor no payload.
O arquivo chega com bytes extras
Revise os \r\n. Eles separam partes, mas não devem ser inseridos dentro do conteúdo por engano. Escreva os bytes com appendSlice, não com formatação de texto.
Funciona com texto, mas falha com imagem
Não use funções que esperam string terminada em zero nem tente interpretar os bytes como UTF-8. O conteúdo do arquivo é uma fatia binária e deve ser copiado sem transformação.
O servidor recebe um campo vazio
Confirme name="arquivo". O nome do campo e o nome do arquivo são valores diferentes: name identifica o parâmetro esperado pela API; filename descreve o arquivo enviado.
O código compila em um projeto antigo e falha no Zig atual
A biblioteca padrão evolui antes do Zig 1.0. Não tente corrigir apenas uma chamada isolada. Compare zig version, leia a definição de std.http.Client instalada e migre o fluxo completo. Nosso guia de std.http.Client, GET, POST e headers explica esse cuidado com mais detalhes.
Checklist para upload em produção
Antes do deploy, confirme:
- o arquivo tem tamanho máximo definido antes da alocação;
- o boundary é longo, válido e não controlado pelo usuário;
- nomes de campo e arquivo não permitem injeção de cabeçalho;
- tokens não aparecem em logs;
- o cliente trata status HTTP fora da faixa de sucesso;
- há timeout, retry apenas quando seguro e limite de tentativas;
- uploads grandes usam streaming em vez de duplicar o arquivo na RAM;
- o receptor valida conteúdo, não apenas extensão e tipo MIME declarado;
- testes cobrem arquivo vazio, limite excedido e resposta interrompida;
- métricas distinguem sucesso, falha, bytes enviados e latência.
Retry merece atenção especial: repetir automaticamente um POST pode criar duas cópias do mesmo arquivo. Prefira uma chave de idempotência quando a API oferecer esse recurso ou consulte o estado da operação antes de reenviar. Para uma política completa de falhas transitórias, leia circuit breaker, timeout e retry em Zig.
Próximos passos
Montar multipart/form-data manualmente combina bem com a filosofia do Zig: o formato fica visível, os limites são explícitos e nenhuma alocação aparece por surpresa. Para arquivos pequenos, um ArrayList(u8) e std.http.Client.fetch resolvem a integração com poucas peças. Quando o volume cresce, preserve o mesmo formato, mas troque a montagem integral por streaming e backpressure.
Continue pelo tutorial de cliente HTTP em Zig, pela referência de std.http.Client e pelo guia de TLS, HTTPS e certificados em Zig se o upload atravessar redes externas. Em produção, o objetivo não é apenas enviar o arquivo: é limitar memória, autenticar sem vazar segredos, interpretar o status corretamente e tornar uma repetição segura.