summaryrefslogtreecommitdiff
path: root/libpanto/src/http_helper.zig
blob: a075d727bd544f1a5745143f76e2f8c1f953b881 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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);
}