move the curl fallback mess into http.zig

This commit is contained in:
Emil Lerch 2026-03-06 16:29:33 -08:00
parent f87021b57e
commit 0f09ef5cff
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 84 additions and 108 deletions

View file

@ -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;

View file

@ -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: <key>"
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;
headers_buf[n_headers] = .{ .name = "X-OPENFIGI-APIKEY", .value = key };
n_headers += 1;
}
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);
var client = http.Client.init(allocator);
defer client.deinit();
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);
}
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);