Construindo uma JSON REST API com Zig: CRUD Completo

Nos artigos anteriores, construímos um servidor HTTP e um sistema de roteamento. Agora vamos construir uma API REST completa com operações CRUD, serialização/deserialização JSON e tratamento de erros adequado.

Trabalhando com JSON em Zig

Zig inclui um parser JSON robusto na biblioteca padrão (std.json). Vamos explorar como usá-lo:

Deserializando JSON (Parse)

const std = @import("std");

const Usuario = struct {
    nome: []const u8,
    email: []const u8,
    idade: u32,
    ativo: bool = true,
};

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

    const json_str =
        \\{"nome": "Ana Silva", "email": "ana@exemplo.com", "idade": 28, "ativo": true}
    ;

    // Deserializar JSON para struct
    const parsed = try std.json.parseFromSlice(
        Usuario,
        allocator,
        json_str,
        .{},
    );
    defer parsed.deinit();

    const usuario = parsed.value;
    std.debug.print("Nome: {s}\n", .{usuario.nome});
    std.debug.print("Email: {s}\n", .{usuario.email});
    std.debug.print("Idade: {d}\n", .{usuario.idade});
    std.debug.print("Ativo: {}\n", .{usuario.ativo});
}

Serializando JSON (Stringify)

const std = @import("std");

const Usuario = struct {
    id: u32,
    nome: []const u8,
    email: []const u8,
    idade: u32,
};

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

    const usuario = Usuario{
        .id = 1,
        .nome = "Bruno Costa",
        .email = "bruno@exemplo.com",
        .idade = 32,
    };

    // Serializar struct para JSON
    var buffer = std.ArrayList(u8).init(allocator);
    defer buffer.deinit();

    try std.json.stringify(usuario, .{}, buffer.writer());

    std.debug.print("JSON: {s}\n", .{buffer.items});
}

O Modelo de Dados

Para nossa API, vamos criar um gerenciador de tarefas (To-Do):

const std = @import("std");

const Tarefa = struct {
    id: u32,
    titulo: []const u8,
    descricao: []const u8,
    concluida: bool,
    criada_em: i64,
};

const TarefaInput = struct {
    titulo: []const u8,
    descricao: []const u8 = "",
};

const TarefaStore = struct {
    tarefas: std.AutoHashMap(u32, Tarefa),
    proximo_id: u32,
    allocator: std.mem.Allocator,

    fn init(allocator: std.mem.Allocator) TarefaStore {
        return .{
            .tarefas = std.AutoHashMap(u32, Tarefa).init(allocator),
            .proximo_id = 1,
            .allocator = allocator,
        };
    }

    fn deinit(self: *TarefaStore) void {
        var it = self.tarefas.valueIterator();
        while (it.next()) |tarefa| {
            self.allocator.free(tarefa.titulo);
            if (tarefa.descricao.len > 0) {
                self.allocator.free(tarefa.descricao);
            }
        }
        self.tarefas.deinit();
    }

    fn criar(self: *TarefaStore, input: TarefaInput) !Tarefa {
        const id = self.proximo_id;
        self.proximo_id += 1;

        // Copiar strings para memória permanente
        const titulo = try self.allocator.alloc(u8, input.titulo.len);
        @memcpy(titulo, input.titulo);

        var descricao: []const u8 = "";
        if (input.descricao.len > 0) {
            const desc = try self.allocator.alloc(u8, input.descricao.len);
            @memcpy(desc, input.descricao);
            descricao = desc;
        }

        const tarefa = Tarefa{
            .id = id,
            .titulo = titulo,
            .descricao = descricao,
            .concluida = false,
            .criada_em = std.time.timestamp(),
        };

        try self.tarefas.put(id, tarefa);
        return tarefa;
    }

    fn obter(self: *TarefaStore, id: u32) ?Tarefa {
        return self.tarefas.get(id);
    }

    fn listar(self: *TarefaStore, allocator: std.mem.Allocator) ![]Tarefa {
        var lista = std.ArrayList(Tarefa).init(allocator);
        var it = self.tarefas.valueIterator();
        while (it.next()) |tarefa| {
            try lista.append(tarefa.*);
        }
        return lista.toOwnedSlice();
    }

    fn atualizar(self: *TarefaStore, id: u32, concluida: bool) bool {
        if (self.tarefas.getPtr(id)) |tarefa| {
            tarefa.concluida = concluida;
            return true;
        }
        return false;
    }

    fn remover(self: *TarefaStore, id: u32) bool {
        if (self.tarefas.fetchRemove(id)) |entry| {
            self.allocator.free(entry.value.titulo);
            if (entry.value.descricao.len > 0) {
                self.allocator.free(entry.value.descricao);
            }
            return true;
        }
        return false;
    }
};

Implementando os Endpoints CRUD

GET /api/tarefas — Listar Todas

fn listarTarefasHandler(ctx: *Context, store: *TarefaStore) !void {
    const tarefas = try store.listar(ctx.allocator);
    defer ctx.allocator.free(tarefas);

    var buffer = std.ArrayList(u8).init(ctx.allocator);
    defer buffer.deinit();

    const writer = buffer.writer();
    try writer.writeAll("{\"tarefas\": [");

    for (tarefas, 0..) |tarefa, i| {
        if (i > 0) try writer.writeAll(", ");
        try std.json.stringify(tarefa, .{}, writer);
    }

    try writer.writeAll("], \"total\": ");
    try std.fmt.formatInt(tarefas.len, 10, .lower, .{}, writer);
    try writer.writeAll("}");

    try ctx.respondJson(buffer.items);
}

POST /api/tarefas — Criar Nova

fn criarTarefaHandler(ctx: *Context, store: *TarefaStore) !void {
    // Ler corpo da requisição
    var reader = try ctx.request.reader();
    const corpo = try reader.readAllAlloc(ctx.allocator, 64 * 1024);
    defer ctx.allocator.free(corpo);

    // Deserializar JSON
    const parsed = std.json.parseFromSlice(
        TarefaInput,
        ctx.allocator,
        corpo,
        .{},
    ) catch {
        try ctx.respond(
            "{\"erro\": \"JSON inválido\"}",
            .{ .status = .bad_request },
        );
        return;
    };
    defer parsed.deinit();

    // Validar
    if (parsed.value.titulo.len == 0) {
        try ctx.respond(
            "{\"erro\": \"Título é obrigatório\"}",
            .{ .status = .bad_request },
        );
        return;
    }

    // Criar tarefa
    const tarefa = try store.criar(parsed.value);

    // Serializar resposta
    var buffer = std.ArrayList(u8).init(ctx.allocator);
    defer buffer.deinit();
    try std.json.stringify(tarefa, .{}, buffer.writer());

    try ctx.respondJson(buffer.items);
}

GET /api/tarefas/:id — Obter Uma

fn obterTarefaHandler(ctx: *Context, store: *TarefaStore) !void {
    const id_str = ctx.params.get("id") orelse {
        try ctx.respond("{\"erro\": \"ID não fornecido\"}", .{ .status = .bad_request });
        return;
    };

    const id = std.fmt.parseInt(u32, id_str, 10) catch {
        try ctx.respond("{\"erro\": \"ID inválido\"}", .{ .status = .bad_request });
        return;
    };

    if (store.obter(id)) |tarefa| {
        var buffer = std.ArrayList(u8).init(ctx.allocator);
        defer buffer.deinit();
        try std.json.stringify(tarefa, .{}, buffer.writer());
        try ctx.respondJson(buffer.items);
    } else {
        try ctx.respond(
            "{\"erro\": \"Tarefa não encontrada\"}",
            .{ .status = .not_found },
        );
    }
}

DELETE /api/tarefas/:id — Remover

fn removerTarefaHandler(ctx: *Context, store: *TarefaStore) !void {
    const id_str = ctx.params.get("id") orelse {
        try ctx.respond("{\"erro\": \"ID não fornecido\"}", .{ .status = .bad_request });
        return;
    };

    const id = std.fmt.parseInt(u32, id_str, 10) catch {
        try ctx.respond("{\"erro\": \"ID inválido\"}", .{ .status = .bad_request });
        return;
    };

    if (store.remover(id)) {
        try ctx.respondJson("{\"removido\": true}");
    } else {
        try ctx.respond(
            "{\"erro\": \"Tarefa não encontrada\"}",
            .{ .status = .not_found },
        );
    }
}

Tratamento de Erros Padronizado

Uma boa API retorna erros consistentes:

const ApiError = struct {
    erro: []const u8,
    codigo: u16,
    detalhes: ?[]const u8 = null,

    fn toJson(self: ApiError, allocator: std.mem.Allocator) ![]u8 {
        var buffer = std.ArrayList(u8).init(allocator);
        const writer = buffer.writer();

        try writer.writeAll("{\"erro\": \"");
        try writer.writeAll(self.erro);
        try writer.writeAll("\", \"codigo\": ");
        try std.fmt.formatInt(self.codigo, 10, .lower, .{}, writer);

        if (self.detalhes) |det| {
            try writer.writeAll(", \"detalhes\": \"");
            try writer.writeAll(det);
            try writer.writeAll("\"");
        }

        try writer.writeAll("}");
        return buffer.toOwnedSlice();
    }
};

fn responderErro(ctx: *Context, err: ApiError) !void {
    const json = try err.toJson(ctx.allocator);
    defer ctx.allocator.free(json);

    const status: std.http.Status = @enumFromInt(err.codigo);
    try ctx.request.respond(json, .{
        .status = status,
        .extra_headers = &.{
            .{ .name = "content-type", .value = "application/json" },
        },
    });
}

Testando a API

Com a API rodando, teste com curl:

# Listar tarefas (inicialmente vazio)
curl http://localhost:8080/api/tarefas

# Criar tarefa
curl -X POST http://localhost:8080/api/tarefas \
  -H "Content-Type: application/json" \
  -d '{"titulo": "Aprender Zig", "descricao": "Completar a série de tutoriais"}'

# Obter tarefa específica
curl http://localhost:8080/api/tarefas/1

# Remover tarefa
curl -X DELETE http://localhost:8080/api/tarefas/1

Próximos Passos

No próximo artigo, vamos implementar o padrão Middleware para adicionar logging, autenticação, CORS e rate limiting de forma modular.

Referências

Continue aprendendo Zig

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