Construir uma API REST em Zig é diferente de repetir o modelo de um framework web pronto. Zig não tenta esconder o custo de cada alocação, cópia ou bloqueio; por isso, uma API bem escrita tende a ser pequena, previsível e fácil de empacotar. A troca é clara: você escreve mais infraestrutura explícita, mas ganha controle sobre memória, erros, limites e formato do binário.
Este guia mostra um caminho realista para uma API REST em Zig em 2026: estrutura de projeto, roteamento, JSON, validação, tratamento de erros, limites de body, health checks, testes, deploy em container e observabilidade. Ele complementa os guias de servidor HTTP em Zig, servidor HTTP em produção, integração com bancos de dados, OpenTelemetry e configuração segura com variáveis de ambiente.
Quando faz sentido usar Zig para uma API REST
Zig faz mais sentido quando a API precisa de uma destas características:
- binário estático pequeno, fácil de distribuir em container mínimo;
- startup rápido para serviços pequenos, workers ou edge-like deployments;
- uso de memória previsível, sem garbage collector;
- integração com bibliotecas C ou código de sistemas;
- controle explícito de parsing, limites e erros;
- baixa dependência de framework.
Ele faz menos sentido quando o produto precisa de um ecossistema web gigantesco agora: ORM maduro, autenticação social pronta, OpenAPI gerado automaticamente, middlewares de terceiros e centenas de integrações. Nesses casos, Go, Kotlin, Node.js ou Python podem entregar mais rápido. Uma boa comparação prática é olhar como Go estrutura APIs REST idiomáticas e como Kotlin organiza APIs REST na JVM.
Estrutura mínima de projeto
Uma API pequena pode começar com poucos arquivos e crescer por módulos. Evite criar camadas abstratas antes de ter rotas reais.
minha-api/
├── build.zig
├── build.zig.zon
└── src/
├── main.zig
├── config.zig
├── router.zig
├── http_error.zig
├── handlers/
│ ├── health.zig
│ └── usuarios.zig
└── models/
└── usuario.zig
A separação importante é simples: main.zig inicializa configuração, allocator e servidor; router.zig decide qual handler atende a rota; handlers leem entrada, validam e devolvem resposta; modelos representam dados estáveis. Se o projeto crescer, adicione módulos por domínio antes de inventar um framework interno.
Modelo de dados e serialização JSON
Zig não obriga você a usar reflexão dinâmica. Para respostas pequenas, um std.json.stringify ou writer explícito resolve. Para APIs públicas, prefira um contrato estável e teste a saída.
const std = @import("std");
pub const Usuario = struct {
id: u64,
nome: []const u8,
email: []const u8,
ativo: bool,
};
pub fn escreverUsuarioJson(writer: anytype, usuario: Usuario) !void {
try std.json.stringify(usuario, .{}, writer);
}
Para entrada, parseie para uma struct pequena que represente o payload aceito, não diretamente para o modelo final. Isso deixa validação e defaults mais claros.
const CriarUsuarioInput = struct {
nome: []const u8,
email: []const u8,
};
fn validarInput(input: CriarUsuarioInput) !void {
if (input.nome.len < 2) return error.NomeMuitoCurto;
if (input.email.len > 320) return error.EmailMuitoLongo;
if (std.mem.indexOfScalar(u8, input.email, '@') == null) return error.EmailInvalido;
}
O detalhe de produção é o allocator: valores parseados por std.json.parseFromSlice podem apontar para memória administrada pelo parse result. Use defer parsed.deinit() e copie strings se elas precisarem sobreviver depois da requisição.
Roteamento explícito
Um roteador simples pode ser uma lista de rotas estáticas. Em muitos serviços internos, isso é suficiente e mais previsível do que uma árvore dinâmica.
const std = @import("std");
const Method = enum { GET, POST, PUT, DELETE };
const Request = struct {
method: Method,
path: []const u8,
body: []const u8,
};
const Response = struct {
status: u16,
body: []const u8,
content_type: []const u8 = "application/json; charset=utf-8",
};
const Handler = *const fn (std.mem.Allocator, Request) anyerror!Response;
const Route = struct {
method: Method,
path: []const u8,
handler: Handler,
};
pub const Router = struct {
routes: []const Route,
pub fn dispatch(self: Router, allocator: std.mem.Allocator, req: Request) !Response {
for (self.routes) |route| {
if (route.method == req.method and std.mem.eql(u8, route.path, req.path)) {
return route.handler(allocator, req);
}
}
return Response{ .status = 404, .body = "{\"erro\":\"rota nao encontrada\"}" };
}
};
Esse exemplo separa Request e Response do tipo concreto de std.http.Server. Na prática, isso facilita testes: você consegue chamar handlers sem abrir socket. Depois, um adaptador fino traduz a requisição HTTP real para essa estrutura.
Handler de criação com limite de body
APIs em produção precisam de limite explícito de payload. Sem isso, uma rota simples pode virar consumo excessivo de memória.
const std = @import("std");
const Usuario = @import("models/usuario.zig").Usuario;
const MAX_BODY = 16 * 1024;
pub fn criarUsuario(allocator: std.mem.Allocator, req: Request) !Response {
if (req.body.len == 0) return jsonErro(400, "body obrigatório");
if (req.body.len > MAX_BODY) return jsonErro(413, "body muito grande");
const parsed = std.json.parseFromSlice(
CriarUsuarioInput,
allocator,
req.body,
.{ .ignore_unknown_fields = true },
) catch return jsonErro(400, "JSON inválido");
defer parsed.deinit();
validarInput(parsed.value) catch |err| switch (err) {
error.NomeMuitoCurto => return jsonErro(422, "nome muito curto"),
error.EmailMuitoLongo => return jsonErro(422, "email muito longo"),
error.EmailInvalido => return jsonErro(422, "email inválido"),
else => return err,
};
const usuario = Usuario{
.id = 1,
.nome = parsed.value.nome,
.email = parsed.value.email,
.ativo = true,
};
var out = std.ArrayList(u8).init(allocator);
try std.json.stringify(usuario, .{}, out.writer());
return Response{ .status = 201, .body = try out.toOwnedSlice() };
}
fn jsonErro(status: u16, msg: []const u8) Response {
_ = msg; // Em produção, escape a mensagem ou use stringify.
return Response{ .status = status, .body = "{\"erro\":\"requisição inválida\"}" };
}
O exemplo acima é propositalmente compacto. Em uma API real, jsonErro deve serializar a mensagem com escaping correto, incluir um código estável (VALIDATION_ERROR, NOT_FOUND, CONFLICT) e talvez um trace_id para suporte.
Adaptador para std.http.Server
A API de std.http.Server muda entre versões do Zig, então trate o adaptador HTTP como borda do sistema. O domínio da aplicação deve continuar testável mesmo se você ajustar detalhes do servidor.
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const routes = [_]Route{
.{ .method = .GET, .path = "/health", .handler = health },
.{ .method = .POST, .path = "/api/v1/usuarios", .handler = criarUsuario },
};
const router = Router{ .routes = &routes };
// Pseudocódigo de borda: adapte conforme a versão do Zig usada no projeto.
// 1. escute 0.0.0.0:8080;
// 2. leia método, path e body com limite;
// 3. chame router.dispatch;
// 4. escreva status, Content-Type e body.
_ = router;
_ = allocator;
}
A regra operacional é: fixe a versão do Zig no CI e documente a API do servidor usada. Zig ainda caminha para 1.0; exemplos copiados de versões diferentes podem quebrar. Veja também o guia de Zig em 2026 para entender estabilidade e roadmap.
Erros HTTP e contrato de resposta
Uma API REST confiável não deve vazar stack trace, @errorName interno ou detalhes de banco. Mapeie erros do domínio para respostas estáveis.
| Caso | Status | Corpo sugerido |
|---|---|---|
| JSON malformado | 400 | {"error":"BAD_JSON"} |
| Validação falhou | 422 | {"error":"VALIDATION_ERROR","field":"email"} |
| Recurso não encontrado | 404 | {"error":"NOT_FOUND"} |
| Conflito de unicidade | 409 | {"error":"CONFLICT"} |
| Limite de body | 413 | {"error":"PAYLOAD_TOO_LARGE"} |
| Falha interna | 500 | {"error":"INTERNAL_ERROR","trace_id":"..."} |
O usuário da API precisa de previsibilidade, não de uma frase nova a cada deploy. Logs internos podem carregar o erro detalhado; a resposta pública deve ser estável.
Configuração, segredos e health checks
Carregue configuração uma vez no boot. Porta, ambiente, string de conexão, nível de log e limites devem vir de variáveis de ambiente ou arquivo de config controlado. Segredos não devem entrar em build.zig, logs ou HTML de erro.
Health checks merecem duas rotas diferentes quando o serviço roda em orquestrador:
/health/live: o processo está vivo e consegue responder;/health/ready: dependências mínimas estão prontas, como banco ou fila.
Isso evita matar um processo saudável apenas porque uma dependência oscilou por segundos. Para mais detalhes, veja configuração segura em Zig e observabilidade em Zig.
Testes de handler sem abrir socket
A vantagem de separar roteador e adaptador HTTP aparece nos testes. Você pode validar status e corpo chamando a função diretamente.
test "POST /usuarios rejeita email inválido" {
const allocator = std.testing.allocator;
const req = Request{
.method = .POST,
.path = "/api/v1/usuarios",
.body = "{\"nome\":\"Ana\",\"email\":\"sem-arroba\"}",
};
const resp = try criarUsuario(allocator, req);
try std.testing.expectEqual(@as(u16, 422), resp.status);
}
Além de testes unitários, mantenha um smoke test de processo: sobe o binário em porta temporária, chama /health, faz um POST válido e um inválido, depois encerra. Esse teste pega erro de adaptador, cabeçalhos, parsing real e empacotamento.
Deploy: container pequeno, usuário não-root e limites
A API REST em Zig combina bem com container mínimo, mas não transforme “binário pequeno” em desculpa para pular segurança operacional.
Checklist de deploy:
- compilar com versão fixa de Zig;
- rodar
zig fmt --checkezig build testno CI; - usar
ReleaseSafepara produção padrão; - rodar como usuário não-root;
- definir
readinesselivenesschecks; - limitar body, timeout e conexões;
- registrar logs em stdout com contexto;
- expor métricas ou pelo menos contadores básicos;
- documentar variáveis de ambiente obrigatórias.
Se o projeto usa Docker, leia também Zig e Docker em 2026 e GitHub Actions para releases multiplataforma.
Armadilhas comuns
- Copiar exemplo antigo de
std.http.Server: fixe a versão do Zig e ajuste a API de servidor ao release usado. - Parsear JSON sem limite de body: sempre imponha tamanho máximo antes do parse.
- Guardar slices do parser depois do
deinit: copie dados persistentes. - Responder erro interno ao cliente: mapeie erros para códigos públicos estáveis.
- Criar framework antes da segunda rota: comece explícito; abstraia depois.
- Ignorar observabilidade: logs, health e métricas básicas entram no primeiro deploy, não no incidente.
Conclusão
Uma API REST em Zig pode ser simples, rápida e operacionalmente limpa quando você aceita o estilo da linguagem: controle explícito, contratos pequenos e poucas mágicas. O caminho recomendado é separar domínio e adaptador HTTP, impor limites de payload, tratar JSON com structs claras, mapear erros para respostas estáveis, testar handlers sem socket e empacotar o serviço com health checks e logs úteis.
Zig ainda não oferece o mesmo conforto de frameworks web maduros, mas isso é parte do ponto. Para APIs pequenas, serviços internos, gateways de performance, ferramentas locais e sistemas que precisam de binário previsível, ele é uma escolha séria. Para produtos que dependem de ecossistema web amplo, use Zig nos pontos onde o controle realmente paga — e deixe o restante em uma stack mais produtiva.
Conteúdo relacionado
- Servidor HTTP em Zig — base do servidor e request/response.
- Servidor HTTP em produção — proxy, logs, limites e health checks.
- Integrando com bancos de dados — SQLite, PostgreSQL, Redis e pooling.
- Microserviços com Zig — quando usar Zig em arquitetura distribuída.
- gRPC e Protocol Buffers com Zig — alternativa binária para comunicação interna.