From a978b2352e083f19879c14b2b851de803c0e7d66 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 29 Jun 2026 15:34:48 -0700 Subject: [PATCH] add server api key support --- docs/reference/config/environment.md | 1 + src/Config.zig | 8 +++++++ src/service.zig | 34 ++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/reference/config/environment.md b/docs/reference/config/environment.md index 05bf079..4e06bba 100644 --- a/docs/reference/config/environment.md +++ b/docs/reference/config/environment.md @@ -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 diff --git a/src/Config.zig b/src/Config.zig index 0832908..22b3ba7 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -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: { diff --git a/src/service.zig b/src/service.zig index 41ff1f6..dc82b75 100644 --- a/src/service.zig +++ b/src/service.zig @@ -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)); }