Chapter 32Project Http Json Client

Project

Overview

This project chapter extends the networking primitives from 31 into a self-contained client that polls a service, parses JSON, and prints a health report. Whereas the prior chapter focused on raw socket handshakes and minimal HTTP examples, this one combines std.http.Client.fetch, std.json.parseFromSlice, and formatted terminal output to build a user-facing workflow (see Client.zig and static.zig).

The example intentionally stands up a local server inside the same process so the client can run offline and under test. That fixture makes it easy to iterate on request framing and parsing logic while using the safer Reader and Writer APIs introduced in Zig 0.15.2 (see v0.15.2).

Learning Goals

  • Launch a lightweight HTTP fixture with std.net.Address.listen and coordinate readiness with std.Thread.ResetEvent.
  • Capture and decode a JSON payload into typed Zig structs and tagged unions by layering a wire representation over std.json.parseFromSlice.
  • Present the results in a table, using the modern Writer API to manage buffers explicitly and highlight impacted services.

Each goal builds directly on the client primitives introduced in the previous chapter and the HTTP components provided in Zig’s standard library (see 31 and Server.zig).

Project architecture

We structure the program into three pieces: a local HTTP server that exposes a status endpoint, a decoding layer that models the response as typed data, and a presentation layer that prints a concise summary. This mirrors the "fetch → parse → report" workflow mentioned in the content plan while keeping the entire project inside a single Zig executable. link

Local service fixture

The fixture thread binds to 127.0.0.1, accepts a single client, and answers GET /api/status with a canned JSON document. It reuses the std.http.Server adapter from the previous chapter, so all TCP details remain within the standard library, and the rest of the program can treat the service as though it were running elsewhere (see net.zig).

Typed decoding strategy

The JSON document uses optional fields to describe different incident types, so the program first parses it into a "wire" struct that mirrors those optional fields, then promotes the data into a Zig union(enum) based on the kind property. This pattern keeps std.json parsing straightforward while still yielding an ergonomic domain model for downstream logic (see meta.zig).

Fetch, decode, and present

The full program below wires the fixture, decoder, and renderer together. It can be run directly with zig run, and it prints a service table followed by any active incidents.

Zig
const std = @import("std");

// Mock JSON response containing service health data for multiple regions.
// In a real application, this would come from an actual API endpoint.
const summary_payload =
    "{\n" ++ "  \"regions\": [\n" ++ "    {\n" ++ "      \"name\": \"us-east\",\n" ++ "      \"uptime\": 0.99983,\n" ++ "      \"services\": [\n" ++ "        {\"name\":\"auth\",\"state\":\"up\",\"latency_ms\":2.7},\n" ++ "        {\"name\":\"billing\",\"state\":\"degraded\",\"latency_ms\":184.0},\n" ++ "        {\"name\":\"search\",\"state\":\"up\",\"latency_ms\":5.1}\n" ++ "      ],\n" ++ "      \"incidents\": [\n" ++ "        {\"kind\":\"maintenance\",\"window_start\":\"2025-11-06T01:00Z\",\"expected_minutes\":45}\n" ++ "      ]\n" ++ "    },\n" ++ "    {\n" ++ "      \"name\": \"eu-central\",\n" ++ "      \"uptime\": 0.99841,\n" ++ "      \"services\": [\n" ++ "        {\"name\":\"auth\",\"state\":\"up\",\"latency_ms\":3.1},\n" ++ "        {\"name\":\"billing\",\"state\":\"outage\",\"latency_ms\":0.0}\n" ++ "      ],\n" ++ "      \"incidents\": [\n" ++ "        {\"kind\":\"outage\",\"started\":\"2025-11-05T08:12Z\",\"severity\":\"critical\"}\n" ++ "      ]\n" ++ "    }\n" ++ "  ]\n" ++ "}\n";

// Coordination structure for passing server state between threads.
// The ResetEvent enables the main thread to wait until the server is ready to accept connections.
const ServerTask = struct {
    server: *std.net.Server,
    ready: *std.Thread.ResetEvent,
};

// Runs a minimal HTTP server fixture on a background thread.
// Responds to /api/status with the canned JSON payload above,
// and returns 404 for all other paths.
fn serveStatus(task: ServerTask) void {
    // Signal to the main thread that the server is listening and ready.
    task.ready.set();

    const connection = task.server.accept() catch |err| {
        std.log.err("accept failed: {s}", .{@errorName(err)});
        return;
    };
    defer connection.stream.close();

    // Allocate fixed buffers for HTTP protocol I/O.
    // The Reader and Writer interfaces wrap these buffers to manage state.
    var recv_buf: [4096]u8 = undefined;
    var send_buf: [4096]u8 = undefined;
    var reader = connection.stream.reader(&recv_buf);
    var writer = connection.stream.writer(&send_buf);
    var server = std.http.Server.init(reader.interface(), &writer.interface);

    // Handle incoming requests until the connection closes.
    while (server.reader.state == .ready) {
        var request = server.receiveHead() catch |err| switch (err) {
            error.HttpConnectionClosing => return,
            else => {
                std.log.err("receive head failed: {s}", .{@errorName(err)});
                return;
            },
        };

        // Route based on request target (path).
        if (std.mem.eql(u8, request.head.target, "/api/status")) {
            request.respond(summary_payload, .{
                .extra_headers = &.{
                    .{ .name = "content-type", .value = "application/json" },
                },
            }) catch |err| {
                std.log.err("respond failed: {s}", .{@errorName(err)});
                return;
            };
        } else {
            request.respond("not found\n", .{
                .status = .not_found,
                .extra_headers = &.{
                    .{ .name = "content-type", .value = "text/plain" },
                },
            }) catch |err| {
                std.log.err("respond failed: {s}", .{@errorName(err)});
                return;
            };
        }
    }
}

// Domain model representing the final, typed structure of the service health data.
// All slices are owned by an arena allocator tied to the request lifetime.
const Summary = struct {
    regions: []Region,
};

const Region = struct {
    name: []const u8,
    uptime: f64,
    services: []Service,
    incidents: []Incident,
};

const Service = struct {
    name: []const u8,
    state: ServiceState,
    latency_ms: f64,
};

const ServiceState = enum { up, degraded, outage };

// Tagged union modeling the two kinds of incidents.
// Each variant carries its own payload structure.
const Incident = union(enum) {
    maintenance: Maintenance,
    outage: Outage,
};

const Maintenance = struct {
    window_start: []const u8,
    expected_minutes: u32,
};

const Outage = struct {
    started: []const u8,
    severity: Severity,
};

const Severity = enum { info, warning, critical };

// Wire format structures mirror the JSON shape exactly.
// All fields are optional to match the loose JSON schema;
// we promote them to the typed domain model after validation.
const SummaryWire = struct {
    regions: []RegionWire,
};

const RegionWire = struct {
    name: []const u8,
    uptime: f64,
    services: []ServiceWire,
    incidents: []IncidentWire,
};

const ServiceWire = struct {
    name: []const u8,
    state: []const u8,
    latency_ms: f64,
};

// All incident fields are optional because different incident kinds use different fields.
const IncidentWire = struct {
    kind: []const u8,
    window_start: ?[]const u8 = null,
    expected_minutes: ?u32 = null,
    started: ?[]const u8 = null,
    severity: ?[]const u8 = null,
};

// Custom error set for decoding and validation failures.
const DecodeError = error{
    UnknownServiceState,
    UnknownIncidentKind,
    UnknownSeverity,
    MissingField,
};

// Allocates a copy of the input slice in the target allocator.
// Used to transfer ownership of JSON strings from the parser's temporary buffers
// into the arena allocator so they remain valid after parsing completes.
fn dupeSlice(allocator: std.mem.Allocator, bytes: []const u8) ![]const u8 {
    const copy = try allocator.alloc(u8, bytes.len);
    @memcpy(copy, bytes);
    return copy;
}

// Maps a service state string to the corresponding enum variant.
// Case-insensitive to handle variations in JSON formatting.
fn parseServiceState(text: []const u8) DecodeError!ServiceState {
    if (std.ascii.eqlIgnoreCase(text, "up")) return .up;
    if (std.ascii.eqlIgnoreCase(text, "degraded")) return .degraded;
    if (std.ascii.eqlIgnoreCase(text, "outage")) return .outage;
    return error.UnknownServiceState;
}

// Parses severity strings into the Severity enum.
fn parseSeverity(text: []const u8) DecodeError!Severity {
    if (std.ascii.eqlIgnoreCase(text, "info")) return .info;
    if (std.ascii.eqlIgnoreCase(text, "warning")) return .warning;
    if (std.ascii.eqlIgnoreCase(text, "critical")) return .critical;
    return error.UnknownSeverity;
}

// Promotes wire format data into the typed domain model.
// Validates required fields, parses enums, and copies strings into the arena.
// All allocations use the arena so cleanup is automatic when the arena is freed.
fn buildSummary(
    arena: std.mem.Allocator,
    parsed: SummaryWire,
) (DecodeError || std.mem.Allocator.Error)!Summary {
    const regions = try arena.alloc(Region, parsed.regions.len);
    for (parsed.regions, regions) |wire, *region| {
        region.name = try dupeSlice(arena, wire.name);
        region.uptime = wire.uptime;

        // Convert each service from wire format to typed model.
        region.services = try arena.alloc(Service, wire.services.len);
        for (wire.services, region.services) |service_wire, *service| {
            service.name = try dupeSlice(arena, service_wire.name);
            service.state = try parseServiceState(service_wire.state);
            service.latency_ms = service_wire.latency_ms;
        }

        // Promote incidents into the tagged union based on the `kind` field.
        region.incidents = try arena.alloc(Incident, wire.incidents.len);
        for (wire.incidents, region.incidents) |incident_wire, *incident| {
            if (std.ascii.eqlIgnoreCase(incident_wire.kind, "maintenance")) {
                const window_start = incident_wire.window_start orelse return error.MissingField;
                const expected = incident_wire.expected_minutes orelse return error.MissingField;
                incident.* = .{ .maintenance = .{
                    .window_start = try dupeSlice(arena, window_start),
                    .expected_minutes = expected,
                } };
            } else if (std.ascii.eqlIgnoreCase(incident_wire.kind, "outage")) {
                const started = incident_wire.started orelse return error.MissingField;
                const severity_text = incident_wire.severity orelse return error.MissingField;
                const severity = try parseSeverity(severity_text);
                incident.* = .{ .outage = .{
                    .started = try dupeSlice(arena, started),
                    .severity = severity,
                } };
            } else {
                return error.UnknownIncidentKind;
            }
        }
    }

    return .{ .regions = regions };
}

// Fetches the status endpoint via HTTP and decodes the JSON response into a Summary.
// Uses a fixed buffer for the HTTP response; for larger payloads, switch to a streaming approach.
fn fetchSummary(arena: std.mem.Allocator, client: *std.http.Client, url: []const u8) !Summary {
    var response_buffer: [4096]u8 = undefined;
    var response_writer = std.Io.Writer.fixed(response_buffer[0..]);

    // Perform the HTTP fetch with a custom User-Agent header.
    const result = try client.fetch(.{
        .location = .{ .url = url },
        .response_writer = &response_writer,
        .headers = .{
            .user_agent = .{ .override = "zigbook-http-json-client/0.1" },
        },
    });
    _ = result;

    // Extract the response body from the fixed writer's buffer.
    const body = response_writer.buffer[0..response_writer.end];
    
    // Parse JSON into the wire format structures.
    var parsed = try std.json.parseFromSlice(SummaryWire, arena, body, .{});
    defer parsed.deinit();

    // Promote wire format to typed domain model.
    return buildSummary(arena, parsed.value);
}

// Renders the service summary as a formatted table followed by an incident list.
// Uses a buffered writer for efficient output to stdout.
fn renderSummary(summary: Summary) !void {
    var stdout_buffer: [1024]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const out = &stdout_writer.interface;

    // Print service table header.
    try out.writeAll("SERVICE SUMMARY\n");
    try out.writeAll("Region        Service        State       Latency (ms)\n");
    try out.writeAll("-----------------------------------------------------\n");
    
    // Print each service, grouped by region.
    for (summary.regions) |region| {
        for (region.services) |service| {
            try out.print("{s:<13}{s:<14}{s:<12}{d:7.1}\n", .{
                region.name,
                service.name,
                @tagName(service.state),
                service.latency_ms,
            });
        }
    }

    // Print incident section header.
    try out.writeAll("\nACTIVE INCIDENTS\n");
    var incident_count: usize = 0;
    
    // Iterate all incidents across all regions and format based on kind.
    for (summary.regions) |region| {
        for (region.incidents) |incident| {
            incident_count += 1;
            switch (incident) {
                .maintenance => |m| try out.print("- {s}: maintenance window starts {s}, {d} min\n", .{
                    region.name,
                    m.window_start,
                    m.expected_minutes,
                }),
                .outage => |o| try out.print("- {s}: outage since {s} (severity: {s})\n", .{
                    region.name,
                    o.started,
                    @tagName(o.severity),
                }),
            }
        }
    }

    if (incident_count == 0) {
        try out.writeAll("- No active incidents reported.\n");
    }

    try out.writeAll("\n");
    try out.flush();
}

pub fn main() !void {
    // Set up a general-purpose allocator for long-lived allocations (client, server).
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Bind to localhost on an OS-assigned port (port 0 → automatic selection).
    const address = try std.net.Address.parseIp("127.0.0.1", 0);
    var server = try address.listen(.{ .reuse_address = true });
    defer server.deinit();

    // Spin up the server fixture on a background thread.
    var ready = std.Thread.ResetEvent{};
    const server_thread = try std.Thread.spawn(.{}, serveStatus, .{ServerTask{
        .server = &server,
        .ready = &ready,
    }});
    defer server_thread.join();

    // Wait for the server thread to signal that it's ready to accept connections.
    ready.wait();

    // Retrieve the actual port chosen by the OS.
    const port = server.listen_address.in.getPort();

    // Initialize the HTTP client with the main allocator.
    var client = std.http.Client{ .allocator = allocator };
    defer client.deinit();

    // Create an arena allocator for all parsed data.
    // The arena owns all slices in the Summary; they're freed when the arena is destroyed.
    var arena_inst = std.heap.ArenaAllocator.init(allocator);
    defer arena_inst.deinit();
    const arena = arena_inst.allocator();

    // Set up buffered stdout for logging.
    var stdout_buffer: [256]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const log_out = &stdout_writer.interface;

    // Construct the full URL with the dynamically assigned port.
    var url_buffer: [128]u8 = undefined;
    const url = try std.fmt.bufPrint(&url_buffer, "http://127.0.0.1:{d}/api/status", .{port});
    try log_out.print("Fetching {s}...\n", .{url});

    // Fetch and decode the status endpoint.
    const summary = try fetchSummary(arena, &client, url);
    try log_out.print("Parsed {d} regions.\n\n", .{summary.regions.len});
    try log_out.flush();

    // Render the final report to stdout.
    try renderSummary(summary);
}

This program relies on the modern Reader/Writer APIs and the HTTP client components introduced in Zig 0.15.2 (see Writer.zig).

Run
Shell
$ zig run main.zig
Output
Shell
Fetching http://127.0.0.1:46211/api/status...
Parsed 2 regions.

SERVICE SUMMARY
Region        Service        State       Latency (ms)
-----------------------------------------------------
us-east      auth          up              2.7
us-east      billing       degraded      184.0
us-east      search        up              5.1
eu-central   auth          up              3.1
eu-central   billing       outage          0.0

ACTIVE INCIDENTS
- us-east: maintenance window starts 2025-11-06T01:00Z, 45 min
- eu-central: outage since 2025-11-05T08:12Z (severity: critical)

Your port number will change each run because the server listens on 0 and lets the OS choose a free socket. The client constructs the URL dynamically from server.listen_address.in.getPort().

Walkthrough

  1. Server bootstrap.serveStatus spins up std.http.Server on an accepted TCP stream, compares the request target, and responds with JSON or a 404. The summary payload lives in a multiline string, but you could just as easily emit it through std.json.Stringify.

  2. Wire decoding and promotion. After fetching, the client parses it into SummaryWire, a structure of slices and optionals that reflect the JSON shape. buildSummary then allocates typed slices inside an arena and maps incident kind strings to union variants. Both the arena and fixed writer leverage the post-Writergate I/O APIs to control allocation explicitly.

  3. Rendering.renderSummary prints the service table via Writer.print and iterates incidents, surfacing severity and scheduling details for each region.

Notes & Caveats

  • std.http.Client.fetch buffers the entire response into the fixed writer; for larger payloads, swap in an arena-backed builder or stream tokens with std.json.Scanner (see Scanner.zig).
  • The decoding logic assumes incident objects include the fields required for their kind. Validation failures bubble out as error.MissingField; adjust the error handling to downgrade or log if you expect partially populated data.
  • The arena allocator keeps all decoded slices alive for the lifetime of the report. If you need long-lived ownership, replace the arena with a longer-lived allocator and free slices manually when the report expires. arena_allocator.zig

Exercises

  • Add a --region flag that filters the printed table to a specific region. Reuse the argument-parsing patterns from earlier CLI chapters before the networking section (see 05).
  • Extend the JSON payload with historical latency percentiles and draw a textual sparkline or a min/median/max summary. Consult std.fmt for formatting helpers (see fmt.zig).
  • Replace the canned data with a live endpoint of your choosing, but wrap it with a timeout and fall back to the fixture to keep tests deterministic.

Caveats, alternatives, edge cases

  • If the response grows beyond the response_buffer size, client.fetch reports error.WriteFailed. Handle that case by retrying with a heap-backed writer or by streaming the body to disk.
  • For union promotion, consider storing the original SummaryWire alongside your typed data so you can expose raw JSON fields in diagnostics without re-parsing.
  • In production code, you may want to reuse a single std.http.Client across multiple fetches; this example drops it after one request, but the API exposes a connection pool ready for reuse.

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.