diff options
Diffstat (limited to 'libpanto/src/http_helper.zig')
| -rw-r--r-- | libpanto/src/http_helper.zig | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/libpanto/src/http_helper.zig b/libpanto/src/http_helper.zig new file mode 100644 index 0000000..a075d72 --- /dev/null +++ b/libpanto/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); +} |
