//! 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); }