HTTP client

CLIs talk to APIs, and hand-rolled HTTP code tends to skip the unglamorous parts — timeouts, response limits, redirect hygiene. zcli.http.Client wraps std.http with safe defaults you can’t accidentally lose:

  • TLS verification is mandatory. There is no knob to disable it.
  • Every request is HTTPS. A plain http:// URL is refused before a connection is opened (error.InsecureTransport, or error.InsecureCredentialTransport when the request carried a credential header), and a redirect to one is refused too (error.InsecureRedirect). Loopback (127.0.0.0/8, ::1, localhost) is the sole carve-out, so local dev servers still work.
  • Requests time out — 30 seconds by default, configurable per client or per request.
  • Response bodies are bounded — 10 MiB by default (error.ResponseTooLarge beyond it), so a misbehaving server can’t balloon your process.
  • Redirects are bounded (3) and only followed for bodyless requests — and Authorization, Cookie, and Proxy-Authorization headers are stripped on cross-origin redirects, so a redirect can’t exfiltrate a token.
  • Responses decompress (gzip/deflate) within the same bounds.

Using it

Construct a client from the command’s arena and io — no plugin needed, it’s part of the core:

var client = zcli.http.Client.init(context.allocator, context.io, .{});
defer client.deinit();

var response = try client.request(.GET, url, .{
    .headers = &.{
        .{ .name = "User-Agent", .value = "myapp" },
        .{ .name = "Accept", .value = "application/vnd.github+json" },
    },
});
defer response.deinit();

if (response.status != .ok) return context.fail("request failed: {d}", .{@intFromEnum(response.status)});

Shorthands cover the common verbs:

var res = try client.get(url);                 // GET
var res2 = try client.post(url, .{ .body = payload, .content_type = "text/plain" });
var res3 = try client.postJson(url, .{ .name = "box", .count = 3 });  // serializes for you

Typed JSON responses

Response.json parses the body straight into your struct, ignoring unknown fields — declare only what you use:

const Repo = struct {
    full_name: []const u8,
    stargazers_count: u64 = 0,
};

const parsed = try response.json(Repo, context.allocator);
try context.stdout().print("{s}: {d} stars\n", .{ parsed.value.full_name, parsed.value.stargazers_count });

The Parsed(T) owns everything reachable from .value, strings included — nothing points back into response.body. So the parsed value keeps working after the response is released, and you never need to copy strings out of it:

const parsed = try response.json(Repo, context.allocator);
response.deinit();                  // the body is gone…
try context.stdout().print("{s}\n", .{parsed.value.full_name});  // …the value is not

Release the parse itself with parsed.deinit(). Under the per-command arena you can skip both: the arena reclaims them when the command returns.

The examples/repostat example is this exact shape end to end — a GitHub stats command in one file.

Tuning the bounds

Both knobs widen (or tighten) per client, and the timeout also per request:

var client = zcli.http.Client.init(context.allocator, context.io, .{
    .max_response_bytes = 64 * 1024 * 1024,
    .timeout = .fromSeconds(120),
});

// or for one slow call only:
var res = try client.request(.GET, url, .{ .timeout = .{ .after = .fromSeconds(300) } });

Failures surface as typed errors — error.Timeout, error.ResponseTooLarge, error.TooManyRedirects — so a command can turn each into a clean user-facing message.