summaryrefslogtreecommitdiff
path: root/src/http_helper.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/http_helper.zig')
-rw-r--r--src/http_helper.zig160
1 files changed, 160 insertions, 0 deletions
diff --git a/src/http_helper.zig b/src/http_helper.zig
new file mode 100644
index 0000000..a075d72
--- /dev/null
+++ b/src/http_helper.zig
@@ -0,0 +1,160 @@
+//! Small non-streaming HTTP request/response helper over the process-global
+//! `std.http.Client`. The streaming providers deliberately avoid `fetch()`
+//! (it buffers); auth flows want the opposite — request a URL, read the whole
+//! response body into one buffer, inspect status + JSON. This is that.
+//!
+//! Used by `auth.zig` for OAuth device flows and token exchanges. Not part of
+//! the streaming hot path, so simplicity wins: one request, full-body read,
+//! owned result.
+
+const std = @import("std");
+const config_mod = @import("config.zig");
+
+pub const Header = config_mod.Header;
+pub const Method = std.http.Method;
+
+/// A fully-read HTTP response. `body` is owned by `allocator`.
+pub const Response = struct {
+ allocator: std.mem.Allocator,
+ status: u16,
+ body: []u8,
+
+ pub fn deinit(self: Response) void {
+ self.allocator.free(self.body);
+ }
+
+ /// True for a 2xx status.
+ pub fn ok(self: Response) bool {
+ return self.status >= 200 and self.status < 300;
+ }
+};
+
+pub const Options = struct {
+ /// Caller headers (auth bearer, identity headers, …). `accept` and
+ /// `content-type` are added separately from the fields below.
+ headers: []const Header = &.{},
+ /// Request body, or null for a bodiless request (e.g. a GET).
+ body: ?[]const u8 = null,
+ /// `content-type` header value when `body` is present.
+ content_type: ?[]const u8 = null,
+ /// `accept` header value. Defaults to JSON.
+ accept: ?[]const u8 = "application/json",
+ /// Upper bound on the response body read into memory.
+ max_body_bytes: usize = 1 << 20,
+};
+
+/// Perform one request and return the fully-read response. The caller owns
+/// `Response.body` and must `deinit` it. Redirects are surfaced as their 3xx
+/// status (not followed) so auth credentials never leak across a redirect.
+pub fn request(
+ allocator: std.mem.Allocator,
+ client: *std.http.Client,
+ method: Method,
+ url: []const u8,
+ opts: Options,
+) !Response {
+ const uri = try std.Uri.parse(url);
+
+ var hdrs: std.ArrayList(std.http.Header) = .empty;
+ defer hdrs.deinit(allocator);
+ if (opts.accept) |a| try hdrs.append(allocator, .{ .name = "accept", .value = a });
+ if (opts.content_type) |ct| {
+ if (opts.body != null) try hdrs.append(allocator, .{ .name = "content-type", .value = ct });
+ }
+ for (opts.headers) |h| try hdrs.append(allocator, .{ .name = h.name, .value = h.value });
+
+ var req = try client.request(method, uri, .{
+ .extra_headers = hdrs.items,
+ // Disable compression so we read the body without a decompressor.
+ .headers = .{ .accept_encoding = .{ .override = "identity" } },
+ .keep_alive = false,
+ // Surface 3xx to us rather than following with auth headers attached.
+ .redirect_behavior = .unhandled,
+ });
+ defer req.deinit();
+
+ if (opts.body) |body| {
+ req.transfer_encoding = .{ .content_length = body.len };
+ var send_buf: [4096]u8 = undefined;
+ var bw = try req.sendBodyUnflushed(&send_buf);
+ try bw.writer.writeAll(body);
+ try bw.end();
+ try req.connection.?.flush();
+ } else {
+ try req.sendBodiless();
+ }
+
+ var redirect_buf: [2048]u8 = undefined;
+ var response = try req.receiveHead(&redirect_buf);
+ const status: u16 = @intFromEnum(response.head.status);
+
+ var transfer_buf: [4096]u8 = undefined;
+ const body_reader = response.reader(&transfer_buf);
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(allocator);
+ var tmp: [4096]u8 = undefined;
+ while (true) {
+ const n = body_reader.readSliceShort(&tmp) catch break;
+ if (n == 0) break;
+ try out.appendSlice(allocator, tmp[0..n]);
+ if (out.items.len > opts.max_body_bytes) break;
+ }
+
+ return .{ .allocator = allocator, .status = status, .body = try out.toOwnedSlice(allocator) };
+}
+
+// ===========================================================================
+// JSON helpers (used by auth flows to read fields out of a response body)
+// ===========================================================================
+
+/// Read a string at a dotted JSON path (e.g. `endpoints.api`) from a parsed
+/// JSON value. Returns null if any segment is missing or the leaf is not a
+/// string. Borrows from `root`.
+pub fn jsonStringAtPath(root: std.json.Value, path: []const u8) ?[]const u8 {
+ const leaf = jsonAtPath(root, path) orelse return null;
+ return switch (leaf) {
+ .string => |s| s,
+ else => null,
+ };
+}
+
+/// Read an integer (unix-seconds expiry, etc.) at a dotted JSON path. Accepts
+/// JSON integers and integer-valued floats. Returns null otherwise.
+pub fn jsonIntAtPath(root: std.json.Value, path: []const u8) ?i64 {
+ const leaf = jsonAtPath(root, path) orelse return null;
+ return switch (leaf) {
+ .integer => |i| i,
+ .float => |f| @intFromFloat(f),
+ .number_string => |s| std.fmt.parseInt(i64, s, 10) catch null,
+ else => null,
+ };
+}
+
+/// Walk a dotted path through nested JSON objects. Returns the leaf value or
+/// null if any object segment is missing.
+pub fn jsonAtPath(root: std.json.Value, path: []const u8) ?std.json.Value {
+ var cur = root;
+ var it = std.mem.splitScalar(u8, path, '.');
+ while (it.next()) |seg| {
+ switch (cur) {
+ .object => |o| cur = o.get(seg) orelse return null,
+ else => return null,
+ }
+ }
+ return cur;
+}
+
+const t = std.testing;
+
+test "jsonAtPath: nested object string + int" {
+ const src =
+ \\{"token":"abc","expires_at":1700000000,"endpoints":{"api":"https://x"}}
+ ;
+ var parsed = try std.json.parseFromSlice(std.json.Value, t.allocator, src, .{});
+ defer parsed.deinit();
+ try t.expectEqualStrings("abc", jsonStringAtPath(parsed.value, "token").?);
+ try t.expectEqual(@as(i64, 1700000000), jsonIntAtPath(parsed.value, "expires_at").?);
+ try t.expectEqualStrings("https://x", jsonStringAtPath(parsed.value, "endpoints.api").?);
+ try t.expect(jsonStringAtPath(parsed.value, "endpoints.missing") == null);
+ try t.expect(jsonStringAtPath(parsed.value, "nope.deep") == null);
+}