diff --git a/src/net/http.zig b/src/net/http.zig index f45e5d5..67860dc 100644 --- a/src/net/http.zig +++ b/src/net/http.zig @@ -40,9 +40,18 @@ pub const Client = struct { /// Perform a GET request with automatic retries on transient errors. pub fn get(self: *Client, url: []const u8) HttpError!Response { + return self.request(.GET, url, null, &.{}); + } + + /// Perform a POST request with automatic retries on transient errors. + pub fn post(self: *Client, url: []const u8, body: []const u8, extra_headers: []const std.http.Header) HttpError!Response { + return self.request(.POST, url, body, extra_headers); + } + + fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response { var attempt: u8 = 0; while (true) : (attempt += 1) { - if (self.doGet(url)) |response| { + if (self.doRequest(method, url, body, extra_headers)) |response| { return classifyResponse(response); } else |_| { if (attempt >= self.max_retries) return HttpError.RequestFailed; @@ -52,30 +61,33 @@ pub const Client = struct { } } - fn doGet(self: *Client, url: []const u8) HttpError!Response { + fn doRequest(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response { var aw: std.Io.Writer.Allocating = .init(self.allocator); const result = self.http_client.fetch(.{ .location = .{ .url = url }, + .method = method, + .payload = body, + .extra_headers = extra_headers, .response_writer = &aw.writer, }) catch |err| { aw.deinit(); // TLS 1.2-only hosts (e.g., finnhub.io) fail with Zig's TLS 1.3-only client. // Fall back to system curl for these cases. if (err == error.TlsInitializationFailed) { - return curlGet(self.allocator, url); + return curlRequest(self.allocator, method, url, body, extra_headers); } return HttpError.RequestFailed; }; - const body = aw.toOwnedSlice() catch { + const resp_body = aw.toOwnedSlice() catch { aw.deinit(); return HttpError.OutOfMemory; }; return .{ .status = result.status, - .body = body, + .body = resp_body, .allocator = self.allocator, }; } @@ -92,11 +104,48 @@ pub const Client = struct { } }; -/// Fallback HTTP GET using system curl for TLS 1.2 hosts. -fn curlGet(allocator: std.mem.Allocator, url: []const u8) HttpError!Response { +/// Fallback HTTP request using system curl for TLS 1.2 hosts. +fn curlRequest( + allocator: std.mem.Allocator, + method: std.http.Method, + url: []const u8, + body: ?[]const u8, + extra_headers: []const std.http.Header, +) HttpError!Response { + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(allocator); + + // Heap-allocated strings that need freeing after Child.run + var to_free: std.ArrayList([]const u8) = .empty; + defer { + for (to_free.items) |s| allocator.free(s); + to_free.deinit(allocator); + } + + argv.appendSlice(allocator, &.{ "curl", "-sS", "-f", "-L", "--max-time", "30" }) catch + return HttpError.OutOfMemory; + + if (method != .GET) { + argv.appendSlice(allocator, &.{ "-X", @tagName(method) }) catch + return HttpError.OutOfMemory; + } + + for (extra_headers) |hdr| { + const val = std.fmt.allocPrint(allocator, "{s}: {s}", .{ hdr.name, hdr.value }) catch + return HttpError.OutOfMemory; + to_free.append(allocator, val) catch return HttpError.OutOfMemory; + argv.appendSlice(allocator, &.{ "-H", val }) catch return HttpError.OutOfMemory; + } + + if (body) |b| { + argv.appendSlice(allocator, &.{ "-d", b }) catch return HttpError.OutOfMemory; + } + + argv.append(allocator, url) catch return HttpError.OutOfMemory; + const result = std.process.Child.run(.{ .allocator = allocator, - .argv = &.{ "curl", "-sS", "-f", "-L", "--max-time", "30", url }, + .argv = argv.items, .max_output_bytes = 10 * 1024 * 1024, }) catch return HttpError.RequestFailed; diff --git a/src/providers/openfigi.zig b/src/providers/openfigi.zig index 9eb3681..75f1398 100644 --- a/src/providers/openfigi.zig +++ b/src/providers/openfigi.zig @@ -8,6 +8,7 @@ //! classes). For those, use the `ticker::` alias field in the portfolio SRF file. const std = @import("std"); +const http = @import("../net/http.zig"); const api_url = "https://api.openfigi.com/v3/mapping"; @@ -21,7 +22,7 @@ pub const FigiResult = struct { }; /// Look up a single CUSIP via OpenFIGI. Caller must free returned strings. -/// Returns null ticker if not found. Uses system curl for the POST request. +/// Returns null ticker if not found. pub fn lookupCusip( allocator: std.mem.Allocator, cusip: []const u8, @@ -70,99 +71,26 @@ pub fn lookupCusips( } try body_buf.append(allocator, ']'); - const body_str = body_buf.items; - - // Build curl command - var argv_buf: [12][]const u8 = undefined; - var argc: usize = 0; - argv_buf[argc] = "curl"; - argc += 1; - argv_buf[argc] = "-sS"; - argc += 1; - argv_buf[argc] = "-f"; - argc += 1; - argv_buf[argc] = "--max-time"; - argc += 1; - argv_buf[argc] = "30"; - argc += 1; - argv_buf[argc] = "-X"; - argc += 1; - argv_buf[argc] = "POST"; - argc += 1; - argv_buf[argc] = "-H"; - argc += 1; - argv_buf[argc] = "Content-Type: application/json"; - argc += 1; + // Build headers + var headers_buf: [2]std.http.Header = undefined; + var n_headers: usize = 0; + headers_buf[n_headers] = .{ .name = "Content-Type", .value = "application/json" }; + n_headers += 1; if (api_key) |key| { - // Build header string: "X-OPENFIGI-APIKEY: " - const hdr = try std.fmt.allocPrint(allocator, "X-OPENFIGI-APIKEY: {s}", .{key}); - defer allocator.free(hdr); - argv_buf[argc] = "-H"; - argc += 1; - argv_buf[argc] = hdr; - argc += 1; - argv_buf[argc] = "-d"; - argc += 1; - - // Need to add body and URL — but we've used all slots for the -H key header. - // Restructure: use a dynamic list. - var argv_list: std.ArrayList([]const u8) = .empty; - defer argv_list.deinit(allocator); - try argv_list.appendSlice(allocator, &.{ - "curl", "-sS", "--max-time", "30", - "-X", "POST", "-H", "Content-Type: application/json", - "-H", hdr, "-d", body_str, - api_url, - }); - - const result = std.process.Child.run(.{ - .allocator = allocator, - .argv = argv_list.items, - .max_output_bytes = 1 * 1024 * 1024, - }) catch return error.RequestFailed; - defer allocator.free(result.stderr); - - const success = switch (result.term) { - .Exited => |code| code == 0, - else => false, - }; - if (!success) { - allocator.free(result.stdout); - return error.RequestFailed; - } - return parseResponse(allocator, result.stdout, cusips.len); - } else { - // No API key - var argv_list: std.ArrayList([]const u8) = .empty; - defer argv_list.deinit(allocator); - try argv_list.appendSlice(allocator, &.{ - "curl", "-sS", "--max-time", "30", - "-X", "POST", "-H", "Content-Type: application/json", - "-d", body_str, api_url, - }); - - const result = std.process.Child.run(.{ - .allocator = allocator, - .argv = argv_list.items, - .max_output_bytes = 1 * 1024 * 1024, - }) catch return error.RequestFailed; - defer allocator.free(result.stderr); - - const success = switch (result.term) { - .Exited => |code| code == 0, - else => false, - }; - if (!success) { - allocator.free(result.stdout); - return error.RequestFailed; - } - return parseResponse(allocator, result.stdout, cusips.len); + headers_buf[n_headers] = .{ .name = "X-OPENFIGI-APIKEY", .value = key }; + n_headers += 1; } + + var client = http.Client.init(allocator); + defer client.deinit(); + + var response = try client.post(api_url, body_buf.items, headers_buf[0..n_headers]); + defer response.deinit(); + + return parseResponse(allocator, response.body, cusips.len); } fn parseResponse(allocator: std.mem.Allocator, body: []const u8, expected_count: usize) ![]FigiResult { - defer allocator.free(body); - const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return error.ParseError; defer parsed.deinit(); @@ -246,8 +174,7 @@ fn parseResponse(allocator: std.mem.Allocator, body: []const u8, expected_count: test "parseResponse basic single CUSIP" { const allocator = std.testing.allocator; - // parseResponse takes ownership of body (frees it), so we must dupe - const body = try allocator.dupe(u8, + const body = \\[ \\ { \\ "data": [ @@ -255,7 +182,7 @@ test "parseResponse basic single CUSIP" { \\ ] \\ } \\] - ); + ; const results = try parseResponse(allocator, body, 1); defer { @@ -277,7 +204,7 @@ test "parseResponse basic single CUSIP" { test "parseResponse prefers US exchange" { const allocator = std.testing.allocator; - const body = try allocator.dupe(u8, + const body = \\[ \\ { \\ "data": [ @@ -286,7 +213,7 @@ test "parseResponse prefers US exchange" { \\ ] \\ } \\] - ); + ; const results = try parseResponse(allocator, body, 1); defer { @@ -304,13 +231,13 @@ test "parseResponse prefers US exchange" { test "parseResponse warning (no match)" { const allocator = std.testing.allocator; - const body = try allocator.dupe(u8, + const body = \\[ \\ { \\ "warning": "No identifier found." \\ } \\] - ); + ; const results = try parseResponse(allocator, body, 1); defer allocator.free(results); @@ -323,7 +250,7 @@ test "parseResponse warning (no match)" { test "parseResponse multiple CUSIPs" { const allocator = std.testing.allocator; - const body = try allocator.dupe(u8, + const body = \\[ \\ { \\ "data": [ @@ -339,7 +266,7 @@ test "parseResponse multiple CUSIPs" { \\ ] \\ } \\] - ); + ; const results = try parseResponse(allocator, body, 3); defer { @@ -369,13 +296,13 @@ test "parseResponse multiple CUSIPs" { test "parseResponse empty data array" { const allocator = std.testing.allocator; - const body = try allocator.dupe(u8, + const body = \\[ \\ { \\ "data": [] \\ } \\] - ); + ; const results = try parseResponse(allocator, body, 1); defer allocator.free(results);