638 lines
29 KiB
Zig
638 lines
29 KiB
Zig
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
|
|
const log = std.log.scoped(.http);
|
|
|
|
/// Public error set for the HTTP client wrapper. Merges the stdlib
|
|
/// transport error sets directly so that callers see meaningful Zig
|
|
/// error names (`NoAddressReturned`, `ConnectionRefused`,
|
|
/// `EndOfStream`, `TlsInitializationFailed`, ...) at the boundary
|
|
/// instead of a single opaque `RequestFailed`.
|
|
///
|
|
/// Why this shape, not `anyerror` and not a hand-rolled flat set:
|
|
/// - Callers get exhaustive-switch checking from the compiler when
|
|
/// they need to discriminate between, say, DNS failures and
|
|
/// connection failures.
|
|
/// - The error names propagate through `@errorName(err)` to log
|
|
/// lines without per-stage translation tables. An operator
|
|
/// reading "DnsLookupFailed" or "ConnectFailed" learns less
|
|
/// than one reading "NoAddressReturned" - the stdlib variant
|
|
/// is the truth, ours would be a lossy summary.
|
|
/// - The set is the same shape as stdlib's own
|
|
/// `http.Client.ConnectError = ConnectTcpError || RequestError`
|
|
/// pattern. Zig dedupes shared variants automatically.
|
|
///
|
|
/// HTTP-status classifications (`RateLimited`, `NotFound`, etc.) live
|
|
/// alongside the transport variants because callers consume both via
|
|
/// the same `try` sites and the same error switches.
|
|
pub const HttpError = std.Uri.ParseError ||
|
|
std.http.Client.RequestError ||
|
|
std.http.Client.Request.ReceiveHeadError ||
|
|
std.Io.Writer.Error ||
|
|
std.Io.Reader.Error ||
|
|
std.mem.Allocator.Error ||
|
|
error{
|
|
/// Last-resort transport failure. Used only when we cannot
|
|
/// recover the underlying stdlib error (e.g., across the
|
|
/// retry boundary in `request()` after multiple distinct
|
|
/// failures collapsed into a single retry exhaustion).
|
|
/// Adding new uses of this variant is a smell - prefer
|
|
/// preserving the real error.
|
|
RequestFailed,
|
|
// ── HTTP status classifications (no stdlib equivalent) ──
|
|
RateLimited,
|
|
Unauthorized,
|
|
NotFound,
|
|
/// HTTP 402 Payment Required - used by FMP to mark symbols (mainly ETFs,
|
|
/// mutual funds, CUSIPs, and some dual-class shares) that aren't covered
|
|
/// by the caller's current plan. Providers should translate this into
|
|
/// "no data" rather than a hard failure.
|
|
PaymentRequired,
|
|
/// HTTP 409 Conflict - the request cannot run because an equivalent one
|
|
/// is already in flight server-side. Distinct from `ServerError` because
|
|
/// it must NOT be retried by the transport: the correct response is to
|
|
/// wait for the in-flight operation, and a retry would only be refused
|
|
/// again. `zfin-server`'s `POST /refresh` returns this while a refresh is
|
|
/// running, and collapsing it into `InvalidResponse` reported a
|
|
/// wait-and-retry condition as a malformed request.
|
|
Conflict,
|
|
ServerError,
|
|
InvalidResponse,
|
|
};
|
|
|
|
pub const Response = struct {
|
|
status: std.http.Status,
|
|
body: []const u8,
|
|
/// Raw `ETag` header value from the server, if present. Owned by the
|
|
/// same allocator as `body`. Captured verbatim (including quotes and
|
|
/// any `sha256:` scheme prefix) so diagnostic archival can record
|
|
/// it as-is.
|
|
etag: ?[]const u8,
|
|
allocator: std.mem.Allocator,
|
|
|
|
pub fn deinit(self: *Response) void {
|
|
self.allocator.free(self.body);
|
|
if (self.etag) |e| self.allocator.free(e);
|
|
}
|
|
|
|
/// Integrity check outcome.
|
|
pub const IntegrityResult = union(enum) {
|
|
/// No ETag present, or the ETag wasn't a recognized sha256
|
|
/// shape. Verification is skipped - the caller should treat
|
|
/// this the same as a successful verification.
|
|
not_applicable,
|
|
/// Server's advertised sha256 matches the body's actual
|
|
/// sha256. The body is byte-exact with what the server sent.
|
|
ok,
|
|
/// Mismatch between server's advertised sha256 and the body's
|
|
/// actual sha256. Indicates truncation or corruption in
|
|
/// transit. `expected_hex` and `actual_hex` are each 64 chars
|
|
/// of lowercase hex; they reference internal buffers of the
|
|
/// result and are valid for the lifetime of this struct.
|
|
mismatch: struct {
|
|
expected_hex: [64]u8,
|
|
actual_hex: [64]u8,
|
|
},
|
|
};
|
|
|
|
/// Verify the body's sha256 against the server's `ETag` header.
|
|
///
|
|
/// Recognizes `ETag: "sha256:<64-hex>"` (quoted or unquoted, prefix
|
|
/// is case-insensitive). Other ETag shapes - weak etags, md5, etc.
|
|
/// - return `.not_applicable` so deployments with non-sha256 etags
|
|
/// don't get their requests rejected.
|
|
pub fn verifyIntegrity(self: *const Response) IntegrityResult {
|
|
const etag = self.etag orelse return .not_applicable;
|
|
const expected_hex = parseSha256Etag(etag) orelse return .not_applicable;
|
|
|
|
var actual: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
|
std.crypto.hash.sha2.Sha256.hash(self.body, &actual, .{});
|
|
var actual_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
|
|
// SAFETY: actual_hex is exactly digest_length*2 bytes, which is
|
|
// exactly what "{x}" writes for a digest_length-byte input.
|
|
_ = std.fmt.bufPrint(&actual_hex, "{x}", .{&actual}) catch &actual_hex;
|
|
|
|
if (std.ascii.eqlIgnoreCase(&actual_hex, expected_hex)) return .ok;
|
|
|
|
var result: IntegrityResult = .{
|
|
.mismatch = .{
|
|
// SAFETY: both fields are populated by the loop and @memcpy below.
|
|
.expected_hex = undefined,
|
|
// SAFETY: see above.
|
|
.actual_hex = undefined,
|
|
},
|
|
};
|
|
// expected_hex may be uppercase depending on server - copy as
|
|
// lowercase for stable comparison downstream.
|
|
for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c);
|
|
@memcpy(&result.mismatch.actual_hex, &actual_hex);
|
|
return result;
|
|
}
|
|
};
|
|
|
|
/// Extract the hex portion of a `"sha256:<hex>"` ETag. Accepts both
|
|
/// quoted and unquoted forms (both are commonly written in the wild),
|
|
/// and the `sha256:` prefix is case-insensitive. Returns null for any
|
|
/// other shape - callers should then skip the integrity check rather
|
|
/// than failing the request.
|
|
fn parseSha256Etag(etag: []const u8) ?[]const u8 {
|
|
var v = etag;
|
|
if (v.len >= 2 and v[0] == '"' and v[v.len - 1] == '"') v = v[1 .. v.len - 1];
|
|
const prefix = "sha256:";
|
|
if (v.len <= prefix.len) return null;
|
|
if (!std.ascii.eqlIgnoreCase(v[0..prefix.len], prefix)) return null;
|
|
const hex = v[prefix.len..];
|
|
if (hex.len != std.crypto.hash.sha2.Sha256.digest_length * 2) return null;
|
|
for (hex) |c| if (!std.ascii.isHex(c)) return null;
|
|
return hex;
|
|
}
|
|
|
|
/// Thin HTTP client wrapper with retry and error classification.
|
|
pub const Client = struct {
|
|
io: std.Io,
|
|
allocator: std.mem.Allocator,
|
|
http_client: std.http.Client,
|
|
max_retries: u8 = 3,
|
|
base_backoff_ms: u64 = 500,
|
|
|
|
pub fn init(io: std.Io, allocator: std.mem.Allocator) Client {
|
|
return .{
|
|
.io = io,
|
|
.allocator = allocator,
|
|
.http_client = std.http.Client{ .allocator = allocator, .io = io },
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Client) void {
|
|
self.http_client.deinit();
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
pub fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
|
|
// Preserves the last underlying error across retry attempts.
|
|
// Without this, the retry loop used to collapse every attempt's
|
|
// distinct failure into a single `HttpError.RequestFailed`,
|
|
// which is exactly the kind of opaque error that hides root
|
|
// causes. A DNS-truncation bug surfaced for hours as bare
|
|
// "RequestFailed" because the per-attempt error was discarded
|
|
// by `catch {}`. Now the caller's `@errorName(err)` reports
|
|
// the real cause (e.g., `NoAddressReturned`).
|
|
var attempt: u8 = 0;
|
|
var last_err: HttpError = HttpError.RequestFailed;
|
|
while (true) : (attempt += 1) {
|
|
const response = self.doRequest(method, url, body, extra_headers) catch |err| {
|
|
last_err = err;
|
|
if (attempt >= self.max_retries) return last_err;
|
|
self.backoffSleep(attempt);
|
|
continue;
|
|
};
|
|
return classifyResponse(response) catch |err| {
|
|
if (err == HttpError.ServerError and attempt < self.max_retries) {
|
|
last_err = err;
|
|
self.backoffSleep(attempt);
|
|
continue;
|
|
}
|
|
return err;
|
|
};
|
|
}
|
|
}
|
|
|
|
fn backoffSleep(self: *Client, attempt: u8) void {
|
|
const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt);
|
|
std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(@intCast(backoff)), .awake) catch |err| std.log.debug("backoff sleep interrupted: {t}", .{err});
|
|
}
|
|
|
|
fn doRequest(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
|
|
// Per-stage timing for diagnosing where transport-level stalls
|
|
// occur. Zig 0.16's `std.http.Client` exposes no per-request
|
|
// receive timeout (only `connectTcpOptions.timeout`), so a
|
|
// request can hang forever in `receiveHead` or `streamRemaining`
|
|
// with no upstream visibility. The per-stage logging lets the
|
|
// operator pinpoint exactly which network operation wedged
|
|
// once they Ctrl-C, even though no `catch` will fire on a
|
|
// truly infinite stall.
|
|
//
|
|
// Each stage that may fail logs the underlying stdlib error
|
|
// name verbatim before propagating it through `HttpError`.
|
|
// The error returned to the caller IS the underlying stdlib
|
|
// error (`NoAddressReturned`, `ConnectionRefused`,
|
|
// `EndOfStream`, `TlsInitializationFailed`, ...) - `HttpError`
|
|
// is a merged superset of the relevant stdlib error sets, so
|
|
// `try` here propagates the original error verbatim. Earlier
|
|
// versions of this function caught every error and rethrew as
|
|
// a single `HttpError.RequestFailed`, which collapsed six
|
|
// distinct failure modes into one and made operator
|
|
// diagnosis impossible (this cost hours of debugging at
|
|
// least once when an intermittent DNS-truncation bug
|
|
// surfaced as bare "RequestFailed").
|
|
//
|
|
// wall-clock required: per-stage transport timing. Uses
|
|
// `.awake` (monotonic) so a system clock jump mid-request
|
|
// doesn't produce nonsense elapsed values.
|
|
const t_start = std.Io.Timestamp.now(self.io, .awake).nanoseconds;
|
|
var t_stage = t_start;
|
|
const stageElapsedMs = struct {
|
|
fn f(prev: *i96, io: std.Io) i64 {
|
|
const now = std.Io.Timestamp.now(io, .awake).nanoseconds;
|
|
const delta_ns = now - prev.*;
|
|
prev.* = now;
|
|
return @intCast(@divTrunc(delta_ns, std.time.ns_per_ms));
|
|
}
|
|
}.f;
|
|
|
|
const uri = std.Uri.parse(url) catch |err| {
|
|
log.warn("http {s}: stage=uri_parse err={s} url={s}", .{ @tagName(method), @errorName(err), url });
|
|
return err;
|
|
};
|
|
const ms_uri_parse = stageElapsedMs(&t_stage, self.io);
|
|
|
|
// If the caller supplied a `User-Agent` in extra_headers,
|
|
// route it to `headers.user_agent.override` so it REPLACES
|
|
// Zig's default "zig/0.x.y (std.http)" UA rather than
|
|
// sitting alongside it. Some servers (notably SEC EDGAR)
|
|
// reject requests where a default-library UA is present
|
|
// even when a descriptive UA is also provided. Same logic
|
|
// applies to other "default-then-override" stdlib headers
|
|
// (Host, Accept-Encoding, Connection, Content-Type) but
|
|
// User-Agent is the only one the EDGAR/Wikidata politeness
|
|
// contract cares about today.
|
|
var std_headers: std.http.Client.Request.Headers = .{};
|
|
var filtered: std.ArrayList(std.http.Header) = .empty;
|
|
defer filtered.deinit(self.allocator);
|
|
for (extra_headers) |h| {
|
|
if (std.ascii.eqlIgnoreCase(h.name, "user-agent"))
|
|
std_headers.user_agent = .{ .override = h.value }
|
|
else
|
|
filtered.append(self.allocator, h) catch return error.OutOfMemory;
|
|
}
|
|
|
|
var req = self.http_client.request(method, uri, .{
|
|
.redirect_behavior = @enumFromInt(3),
|
|
.headers = std_headers,
|
|
.extra_headers = filtered.items,
|
|
}) catch |err| {
|
|
// The connect stage covers DNS lookup, TCP connect, and
|
|
// TLS handshake. Logging at warn level (rather than debug)
|
|
// because DNS / connectivity failures are exactly what
|
|
// operators need to see immediately.
|
|
log.warn("http {s}: stage=connect err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
|
|
return err;
|
|
};
|
|
defer req.deinit();
|
|
const ms_connect = stageElapsedMs(&t_stage, self.io);
|
|
|
|
if (body) |payload| {
|
|
var send_buf: [4096]u8 = undefined;
|
|
req.transfer_encoding = .{ .content_length = payload.len };
|
|
var bw = req.sendBodyUnflushed(&send_buf) catch |err| {
|
|
log.warn("http {s}: stage=send_body_open err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
|
|
return err;
|
|
};
|
|
bw.writer.writeAll(payload) catch |err| {
|
|
log.warn("http {s}: stage=send_body_write err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
|
|
return err;
|
|
};
|
|
bw.end() catch |err| {
|
|
log.warn("http {s}: stage=send_body_end err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
|
|
return err;
|
|
};
|
|
req.connection.?.flush() catch |err| {
|
|
log.warn("http {s}: stage=send_body_flush err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
|
|
return err;
|
|
};
|
|
} else {
|
|
req.sendBodiless() catch |err| {
|
|
log.warn("http {s}: stage=send_bodiless err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
|
|
return err;
|
|
};
|
|
}
|
|
const ms_send = stageElapsedMs(&t_stage, self.io);
|
|
|
|
// Matches the default redirect capacity in std.http.Client.fetch.
|
|
var redirect_buffer: [8 * 1024]u8 = undefined;
|
|
var response = req.receiveHead(&redirect_buffer) catch |err| {
|
|
log.warn("http {s}: stage=receive_head err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
|
|
return err;
|
|
};
|
|
const ms_receive_head = stageElapsedMs(&t_stage, self.io);
|
|
|
|
// Capture the ETag (if any) from the response head BEFORE
|
|
// draining the body. `Response.reader()` invalidates
|
|
// `head.bytes`, and `iterateHeaders` reads from that slice, so
|
|
// anything we want must be duplicated now.
|
|
const etag_owned: ?[]const u8 = blk: {
|
|
var it = response.head.iterateHeaders();
|
|
while (it.next()) |h| {
|
|
if (std.ascii.eqlIgnoreCase(h.name, "etag")) {
|
|
const dup = try self.allocator.dupe(u8, h.value);
|
|
break :blk dup;
|
|
}
|
|
}
|
|
break :blk null;
|
|
};
|
|
errdefer if (etag_owned) |e| self.allocator.free(e);
|
|
|
|
// Drain the body. `readerDecompressing` is adaptive: for
|
|
// identity-encoded responses (the zfin server's default) it
|
|
// hands back the transfer reader unchanged - zero-cost. For
|
|
// gzip/deflate/zstd it wraps the transfer reader with the
|
|
// appropriate decompressor. The decompress buffer is only
|
|
// touched on the compressed paths; sized at 64 KiB as a
|
|
// reasonable default for the unlikely case a provider endpoint
|
|
// starts sending compressed SRF/JSON.
|
|
var aw: std.Io.Writer.Allocating = .init(self.allocator);
|
|
errdefer aw.deinit();
|
|
|
|
var transfer_buffer: [4096]u8 = undefined;
|
|
// SAFETY: `decompress` is initialized by `readerDecompressing`
|
|
// before any read. Same pattern as `transfer_buffer`/`decompress_buffer`.
|
|
var decompress: std.http.Decompress = undefined;
|
|
var decompress_buffer: [64 * 1024]u8 = undefined;
|
|
const reader = response.readerDecompressing(&transfer_buffer, &decompress, &decompress_buffer);
|
|
_ = reader.streamRemaining(&aw.writer) catch |err| {
|
|
log.warn("http {s}: stage=stream_body err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
|
|
return err;
|
|
};
|
|
const ms_body = stageElapsedMs(&t_stage, self.io);
|
|
|
|
const resp_body = try aw.toOwnedSlice();
|
|
|
|
const total_ms = @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms);
|
|
log.debug(
|
|
"http {s}: ok status={d} bytes={d} total_ms={d} (uri_parse={d} connect={d} send={d} receive_head={d} body={d}) url={s}",
|
|
.{
|
|
@tagName(method),
|
|
@intFromEnum(response.head.status),
|
|
resp_body.len,
|
|
total_ms,
|
|
ms_uri_parse,
|
|
ms_connect,
|
|
ms_send,
|
|
ms_receive_head,
|
|
ms_body,
|
|
url,
|
|
},
|
|
);
|
|
|
|
return .{
|
|
.status = response.head.status,
|
|
.body = resp_body,
|
|
.etag = etag_owned,
|
|
.allocator = self.allocator,
|
|
};
|
|
}
|
|
|
|
fn classifyResponse(response: Response) HttpError!Response {
|
|
switch (response.status) {
|
|
.ok => return response,
|
|
else => {
|
|
// Surface the rejection body - many providers
|
|
// ship actionable diagnostic text in non-2xx
|
|
// bodies (Akamai/SEC's "Request Rate Threshold
|
|
// Exceeded" page, Polygon's "free tier exceeded
|
|
// 5 calls/min" hints, Wikidata's SPARQL syntax
|
|
// errors, etc.). Without this, the caller only
|
|
// sees the mapped HttpError variant
|
|
// (`Unauthorized`, `RateLimited`, ...) and has no
|
|
// path back to the upstream's reason.
|
|
//
|
|
// 404s are noisy in normal operation: callers
|
|
// routinely encounter "no data" 404s for symbols
|
|
// EDGAR doesn't track, money-market funds with no
|
|
// NPORT-P, ETFs with no shares-outstanding XBRL,
|
|
// etc. The body of a 404 is usually a generic
|
|
// "NoSuchKey" / "Not Found" XML/HTML page that
|
|
// tells the operator nothing actionable. Demote
|
|
// those to debug so the warn-level log stream
|
|
// stays focused on cases the operator can act on
|
|
// (auth, rate, server outages).
|
|
//
|
|
// Skipped entirely under `zig build test`: the
|
|
// error-classification tests intentionally drive
|
|
// non-2xx statuses through here, and their warn
|
|
// output would otherwise pollute the test stream.
|
|
if (!builtin.is_test) {
|
|
if (response.status == .not_found) {
|
|
log.debug("http rejection body status=404 body={s}", .{response.body});
|
|
} else {
|
|
log.warn("http rejection body status={d} body={s}", .{ @intFromEnum(response.status), response.body });
|
|
}
|
|
}
|
|
response.allocator.free(response.body);
|
|
if (response.etag) |e| response.allocator.free(e);
|
|
return switch (response.status) {
|
|
.too_many_requests => HttpError.RateLimited,
|
|
.unauthorized, .forbidden => HttpError.Unauthorized,
|
|
.payment_required => HttpError.PaymentRequired,
|
|
.not_found => HttpError.NotFound,
|
|
.conflict => HttpError.Conflict,
|
|
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
|
|
else => HttpError.InvalidResponse,
|
|
};
|
|
},
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Build a URL with query parameters. Values are percent-encoded per RFC 3986.
|
|
pub fn buildUrl(
|
|
allocator: std.mem.Allocator,
|
|
base: []const u8,
|
|
params: []const [2][]const u8,
|
|
) ![]const u8 {
|
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
|
errdefer aw.deinit();
|
|
|
|
try aw.writer.writeAll(base);
|
|
for (params, 0..) |param, i| {
|
|
try aw.writer.writeByte(if (i == 0) '?' else '&');
|
|
try aw.writer.writeAll(param[0]);
|
|
try aw.writer.writeByte('=');
|
|
try std.Uri.Component.percentEncode(&aw.writer, param[1], isQueryValueChar);
|
|
}
|
|
|
|
return aw.toOwnedSlice();
|
|
}
|
|
|
|
/// RFC 3986 query-safe characters, excluding '&' and '=' which delimit
|
|
/// key=value pairs within the query string.
|
|
fn isQueryValueChar(c: u8) bool {
|
|
return switch (c) {
|
|
// Unreserved characters (RFC 3986 section 2.3)
|
|
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
|
|
// Sub-delimiters safe in query values (excludes '&' and '=')
|
|
'!', '$', '\'', '(', ')', '*', '+', ',', ';' => true,
|
|
// Additional query/path characters
|
|
':', '@', '/', '?' => true,
|
|
else => false,
|
|
};
|
|
}
|
|
|
|
test "buildUrl" {
|
|
const allocator = std.testing.allocator;
|
|
const url = try buildUrl(allocator, "https://api.example.com/v1/data", &.{
|
|
.{ "symbol", "AAPL" },
|
|
.{ "apikey", "test123" },
|
|
});
|
|
defer allocator.free(url);
|
|
try std.testing.expectEqualStrings("https://api.example.com/v1/data?symbol=AAPL&apikey=test123", url);
|
|
}
|
|
|
|
test "buildUrl percent-encodes reserved characters in values" {
|
|
const allocator = std.testing.allocator;
|
|
// Value contains a space, '&', '=' (all must be encoded) and '/'
|
|
// (allowed in query values, so left as-is).
|
|
const url = try buildUrl(allocator, "https://api.example.com/q", &.{
|
|
.{ "name", "a b&c=d/e" },
|
|
});
|
|
defer allocator.free(url);
|
|
try std.testing.expect(std.mem.indexOf(u8, url, "%20") != null); // space
|
|
try std.testing.expect(std.mem.indexOf(u8, url, "%26") != null); // &
|
|
try std.testing.expect(std.mem.indexOf(u8, url, "%3D") != null or std.mem.indexOf(u8, url, "%3d") != null); // =
|
|
try std.testing.expect(std.mem.indexOf(u8, url, "d/e") != null); // '/' preserved
|
|
}
|
|
|
|
test "classifyResponse maps each HTTP status to its HttpError" {
|
|
const allocator = std.testing.allocator;
|
|
const Case = struct { status: std.http.Status, expected: HttpError };
|
|
const cases = [_]Case{
|
|
.{ .status = .too_many_requests, .expected = HttpError.RateLimited },
|
|
.{ .status = .unauthorized, .expected = HttpError.Unauthorized },
|
|
.{ .status = .forbidden, .expected = HttpError.Unauthorized },
|
|
.{ .status = .payment_required, .expected = HttpError.PaymentRequired },
|
|
.{ .status = .not_found, .expected = HttpError.NotFound },
|
|
.{ .status = .conflict, .expected = HttpError.Conflict },
|
|
.{ .status = .internal_server_error, .expected = HttpError.ServerError },
|
|
.{ .status = .bad_gateway, .expected = HttpError.ServerError },
|
|
.{ .status = .service_unavailable, .expected = HttpError.ServerError },
|
|
.{ .status = .gateway_timeout, .expected = HttpError.ServerError },
|
|
.{ .status = .bad_request, .expected = HttpError.InvalidResponse },
|
|
};
|
|
for (cases) |c| {
|
|
// On the non-ok path classifyResponse frees body + etag itself,
|
|
// so the test must not free them again.
|
|
const resp = Response{
|
|
.status = c.status,
|
|
.body = try allocator.dupe(u8, "rejection body"),
|
|
.etag = try allocator.dupe(u8, "\"etag\""),
|
|
.allocator = allocator,
|
|
};
|
|
try std.testing.expectError(c.expected, Client.classifyResponse(resp));
|
|
}
|
|
}
|
|
|
|
test "classifyResponse passes 200 OK through unchanged" {
|
|
const allocator = std.testing.allocator;
|
|
const resp = Response{
|
|
.status = .ok,
|
|
.body = try allocator.dupe(u8, "payload"),
|
|
.etag = null,
|
|
.allocator = allocator,
|
|
};
|
|
// On the ok path the body is not freed - the caller still owns it.
|
|
var out = try Client.classifyResponse(resp);
|
|
defer out.deinit();
|
|
try std.testing.expectEqual(std.http.Status.ok, out.status);
|
|
try std.testing.expectEqualStrings("payload", out.body);
|
|
}
|
|
|
|
test "parseSha256Etag: quoted form" {
|
|
const hex = parseSha256Etag("\"sha256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\"") orelse unreachable;
|
|
try std.testing.expectEqualStrings("0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9", hex);
|
|
}
|
|
|
|
test "parseSha256Etag: unquoted form" {
|
|
const hex = parseSha256Etag("sha256:deadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafe1234") orelse unreachable;
|
|
try std.testing.expectEqualStrings("deadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafe1234", hex);
|
|
}
|
|
|
|
test "parseSha256Etag: case-insensitive prefix" {
|
|
const hex = parseSha256Etag("\"SHA256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\"") orelse unreachable;
|
|
try std.testing.expectEqualStrings("0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9", hex);
|
|
}
|
|
|
|
test "parseSha256Etag: wrong scheme returns null" {
|
|
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"md5:deadbeef\""));
|
|
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("W/\"weak-etag\""));
|
|
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag(""));
|
|
}
|
|
|
|
test "parseSha256Etag: wrong hex length returns null" {
|
|
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"sha256:deadbeef\""));
|
|
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"sha256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9aa\""));
|
|
}
|
|
|
|
test "parseSha256Etag: non-hex character returns null" {
|
|
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"sha256:ZZ02d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\""));
|
|
}
|
|
|
|
test "Response.verifyIntegrity: no etag returns not_applicable" {
|
|
var body_buf = [_]u8{ 'h', 'i' };
|
|
var response = Response{
|
|
.status = .ok,
|
|
.body = &body_buf,
|
|
.etag = null,
|
|
.allocator = std.testing.allocator,
|
|
};
|
|
const result = response.verifyIntegrity();
|
|
try std.testing.expect(result == .not_applicable);
|
|
}
|
|
|
|
test "Response.verifyIntegrity: non-sha256 etag returns not_applicable" {
|
|
var body_buf = [_]u8{ 'h', 'i' };
|
|
var response = Response{
|
|
.status = .ok,
|
|
.body = &body_buf,
|
|
.etag = "W/\"weak-etag\"",
|
|
.allocator = std.testing.allocator,
|
|
};
|
|
const result = response.verifyIntegrity();
|
|
try std.testing.expect(result == .not_applicable);
|
|
}
|
|
|
|
test "Response.verifyIntegrity: matching sha256 returns ok" {
|
|
// sha256("hello world") = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
|
|
const body = "hello world";
|
|
var response = Response{
|
|
.status = .ok,
|
|
.body = body,
|
|
.etag = "\"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9\"",
|
|
.allocator = std.testing.allocator,
|
|
};
|
|
const result = response.verifyIntegrity();
|
|
try std.testing.expect(result == .ok);
|
|
}
|
|
|
|
test "Response.verifyIntegrity: mismatched sha256 returns mismatch" {
|
|
const body = "hello world";
|
|
// Intentionally wrong digest.
|
|
var response = Response{
|
|
.status = .ok,
|
|
.body = body,
|
|
.etag = "\"sha256:0000000000000000000000000000000000000000000000000000000000000000\"",
|
|
.allocator = std.testing.allocator,
|
|
};
|
|
const result = response.verifyIntegrity();
|
|
switch (result) {
|
|
.mismatch => |m| {
|
|
try std.testing.expectEqualStrings(
|
|
"0000000000000000000000000000000000000000000000000000000000000000",
|
|
&m.expected_hex,
|
|
);
|
|
try std.testing.expectEqualStrings(
|
|
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
|
|
&m.actual_hex,
|
|
);
|
|
},
|
|
else => try std.testing.expect(false),
|
|
}
|
|
}
|