add server api key support

This commit is contained in:
Emil Lerch 2026-06-29 15:34:48 -07:00
parent 027f3b67ab
commit a978b2352e
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 41 additions and 2 deletions

View file

@ -49,6 +49,7 @@ free-tier limits.
| Variable | Purpose |
|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ZFIN_SERVER` | Optional URL of a remote [zfin-server](https://git.lerch.org/lobo/zfin-server) instance: a shared cache tier queried between the local cache and the providers. No-ops when unset. See [server sync](../../explanation/caching.md#server-sync-zfin_server). |
| `ZFIN_SERVER_API_KEY` | Optional API key sent as the `X-API-Key` header on every `ZFIN_SERVER` request. Set it when the server requires auth for cache endpoints; leave unset for an open server. No-ops when `ZFIN_SERVER` is unset. |
## Output

View file

@ -44,6 +44,13 @@ openfigi_key: ?[]const u8 = null,
user_email: ?[]const u8 = null,
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
server_url: ?[]const u8 = null,
/// Optional API key for the `server_url` instance above. Sent as the
/// `X-API-Key` header on every server-sync request. Sourced from
/// `ZFIN_SERVER_API_KEY` - the same variable the server reads to require
/// the key, so a host running both just sets it once. Null when unset:
/// requests then go out unauthenticated, which is correct against an open
/// server or one still in its pre-enforcement soft cutover.
server_api_key: ?[]const u8 = null,
cache_dir: []const u8,
cache_dir_owned: bool = false, // true when cache_dir was allocated via path.join
zfin_home: ?[]const u8 = null,
@ -97,6 +104,7 @@ pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
self.user_email = self.resolve("ZFIN_USER_EMAIL");
self.server_url = self.resolve("ZFIN_SERVER");
self.server_api_key = self.resolve("ZFIN_SERVER_API_KEY");
const env_cache = self.resolve("ZFIN_CACHE_DIR");
self.cache_dir = env_cache orelse blk: {

View file

@ -2710,6 +2710,18 @@ pub const DataService = struct {
}
}
/// Build the auth header slice for a server-sync request. Writes the
/// `X-API-Key` header into `buf` (caller-owned; must outlive the
/// request) and returns a one-element slice of it, or an empty slice
/// when no key is configured. The key points into `Config`
/// (process-lifetime), so the value slice stays valid. Free function
/// (not a method) so it's unit-testable without a `DataService`.
fn serverAuthHeaders(server_api_key: ?[]const u8, buf: *[1]std.http.Header) []const std.http.Header {
const key = server_api_key orelse return &.{};
buf[0] = .{ .name = "X-API-Key", .value = key };
return buf[0..1];
}
/// L2 seam: fetch the whole CUSIP->ticker map from the server via
/// `GET {server}/cusips`. Returns the raw SRF body (caller frees
/// with `self.allocator`) or null on any failure. Best-effort: no
@ -2723,7 +2735,9 @@ pub const DataService = struct {
var client = http.Client.init(self.io, self.allocator);
defer client.deinit();
var response = client.get(url) catch |err| {
var hdr_buf: [1]std.http.Header = .{.{ .name = "", .value = "" }};
const extra_headers = serverAuthHeaders(self.config.server_api_key, &hdr_buf);
var response = client.request(.GET, url, null, extra_headers) catch |err| {
log.debug("cusips server sync failed: {s}", .{@errorName(err)});
return null;
};
@ -2878,7 +2892,9 @@ pub const DataService = struct {
var client = http.Client.init(self.io, self.allocator);
defer client.deinit();
var response = client.get(full_url) catch |err| {
var hdr_buf: [1]std.http.Header = .{.{ .name = "", .value = "" }};
const extra_headers = serverAuthHeaders(self.config.server_api_key, &hdr_buf);
var response = client.request(.GET, full_url, null, extra_headers) catch |err| {
const elapsed_ms = @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms);
// Operator-visible: surfaces meaningful failures
// (`NoAddressReturned`, `ConnectionRefused`,
@ -3034,6 +3050,20 @@ pub const DataService = struct {
// Tests
test "serverAuthHeaders: key present yields one X-API-Key header" {
var buf: [1]std.http.Header = .{.{ .name = "", .value = "" }};
const h = DataService.serverAuthHeaders("s3cret", &buf);
try std.testing.expectEqual(@as(usize, 1), h.len);
try std.testing.expectEqualStrings("X-API-Key", h[0].name);
try std.testing.expectEqualStrings("s3cret", h[0].value);
}
test "serverAuthHeaders: null key yields no headers" {
var buf: [1]std.http.Header = .{.{ .name = "", .value = "" }};
const h = DataService.serverAuthHeaders(null, &buf);
try std.testing.expectEqual(@as(usize, 0), h.len);
}
test "isPermanentProviderFailure: NotFound is permanent" {
try std.testing.expect(isPermanentProviderFailure(error.NotFound));
}