diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:48:43 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:48:43 -0300 |
| commit | 87166a5299aa551517a86379a0c05eb2110b52b0 (patch) | |
| tree | 3e77e7dd89cca736a22a5f2219451aa57dd99068 /src/client.zig | |
| download | zig-87166a5299aa551517a86379a0c05eb2110b52b0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src/client.zig')
| -rw-r--r-- | src/client.zig | 804 |
1 files changed, 804 insertions, 0 deletions
diff --git a/src/client.zig b/src/client.zig new file mode 100644 index 0000000..3a726e5 --- /dev/null +++ b/src/client.zig @@ -0,0 +1,804 @@ +//! The HTTP client, built on `std.http.Client` from the standard library. +//! +//! Retry policy: 429 and the transient 5xx in `retriable_status`, plus network +//! failures, are retried with exponentially backed-off, jittered waits. 400 and +//! 404 are deliberately absent from that set — both are definitive answers +//! about the request, so retrying returns the same thing more slowly while +//! burning someone else's quota. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const csv = @import("csv.zig"); +const errors = @import("errors.zig"); +const model = @import("model.zig"); +const retry = @import("retry.zig"); + +const Address = model.Address; +const Diagnostics = errors.Diagnostics; +const Error = errors.Error; +const Health = model.Health; +const note = errors.note; + +/// The version of this library, as sent in the `User-Agent`. +pub const version = "0.1.0"; + +/// Where the public API lives. +pub const default_url = "https://cep.is"; + +/// How many CEPs the batch endpoint accepts in one request. +pub const max_batch = 500; + +/// The statuses worth trying again: rate limiting and the transient 5xx. +pub const retriable_status = [_]u16{ 429, 500, 502, 503, 504 }; + +/// The `User-Agent` sent unless `Config.user_agent` overrides it. Identifying +/// the library and its version is what lets the server diagnose an old client +/// misbehaving. +pub const default_user_agent = "cepis-zig/" ++ version ++ " (+https://cep.is)"; + +const accept_header = "application/json, text/csv;q=0.9, */*;q=0.1"; + +/// Refuse to buffer more than this from one response. The largest legitimate +/// answer is a 500-row CSV, comfortably under a megabyte. +const max_body_bytes = 16 * 1024 * 1024; + +/// Everything that can go wrong building a `Client`. Distinct from `Error` +/// because a bad configuration is a programming mistake, not a runtime +/// condition to be retried. +pub const InitError = Allocator.Error || error{ + /// `max_attempts` is 0, or `timeout_ms` is 0 — either produces a client + /// that can never complete a call. + InvalidConfiguration, +}; + +/// How a `Client` behaves. Every field has a default; set only what differs. +pub const Config = struct { + /// Where the API lives. + /// + /// Point it at a local instance (the API repo brings one up on + /// `http://localhost:8080` with `just up`) or at a test server. It is the + /// only setting that usually changes between environments. A trailing slash + /// is stripped, so it cannot produce doubled separators that some proxies + /// normalise and others 404. Copied on `init`; the caller keeps ownership. + base_url: []const u8 = default_url, + + /// How long to wait for the TCP and TLS handshake, in milliseconds. + /// + /// This is **not** a cap on the whole call, and Zig 0.16's + /// `std.http.Client` exposes no per-read timeout, so a server that accepts + /// the connection and then stalls is bounded only by `total_timeout_ms`. + /// Set that one in code serving a web request. + timeout_ms: u64 = 10_000, + + /// How many attempts in total, counting the first. + /// + /// 1 disables retrying. The default of 3 covers the common case (one + /// transient failure) without turning a real outage into a long wait. + max_attempts: u32 = 3, + + /// Base of the exponential wait, in milliseconds. + /// + /// The actual wait is drawn from `[0, base * 2^(n-1)]` — *full jitter*. The + /// randomness is not decoration: without it, N clients that fail together + /// come back together and reproduce the spike that took the service down. + backoff_base_ms: u64 = 500, + + /// Cap on each individual wait, in milliseconds. + /// + /// It applies to a server-sent `Retry-After` too, or a `Retry-After: 3600` + /// from a misconfigured proxy would park the caller for an hour. + backoff_max_ms: u64 = 8_000, + + /// Cap on the whole call, attempts and waits included, in milliseconds. + /// + /// Null leaves `max_attempts` as the only bound. + total_timeout_ms: ?u64 = null, + + /// Overrides the `User-Agent`. Copied on `init`; the caller keeps + /// ownership. + user_agent: []const u8 = default_user_agent, + + /// Seed for the backoff jitter. Null draws one from the system, which is + /// what you want; set it only to make a test deterministic. + jitter_seed: ?u64 = null, +}; + +/// Per-call knobs shared by every method. +pub const CallOptions = struct { + /// Where to record the HTTP status, the `Retry-After` and the API's own + /// message when the call fails. Null discards them. + diagnostics: ?*Diagnostics = null, +}; + +/// Return the CEP's 8 digits, accepting the mask. +/// +/// `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`. +/// +/// Fails with `error.InvalidCep` unless exactly 8 digits remain. This runs +/// **before** any request: a 7-digit CEP is the caller's bug, and spending a +/// round trip for the server to say so wastes both sides' time. +/// +/// ## Ownership +/// +/// The result borrows `buffer`; it is valid for as long as `buffer` is. No +/// allocation happens. +pub fn normalizeCep(value: []const u8, buffer: *[8]u8) error{InvalidCep}![]const u8 { + var n: usize = 0; + for (std.mem.trim(u8, value, " \t\r\n")) |c| { + switch (c) { + '-', '.', ' ' => continue, + '0'...'9' => { + if (n == buffer.len) return error.InvalidCep; + buffer[n] = c; + n += 1; + }, + else => return error.InvalidCep, + } + } + if (n != buffer.len) return error.InvalidCep; + return buffer[0..]; +} + +/// Client for the cep.is API. +/// +/// Build one and reuse it: the configuration is immutable after `init`, no +/// method keeps state between calls, and the underlying connection pool is +/// shared — so a second lookup on the same host skips the TLS handshake. +/// Sharing one across threads is safe. +/// +/// ```zig +/// var threaded: std.Io.Threaded = .init(gpa, .{}); +/// defer threaded.deinit(); +/// +/// var client: cepis.Client = try .init(gpa, threaded.io(), .{}); +/// defer client.deinit(); +/// +/// var address = try client.lookup("01001-000", .{}); +/// defer address.deinit(gpa); +/// ``` +pub const Client = struct { + /// The allocator every returned value was built with. Pass it to + /// `Address.deinit`, `Health.deinit` and `freeAddresses`. + gpa: Allocator, + io: Io, + http: std.http.Client, + + /// The API URL in use, without a trailing slash. Owned by the client. + base_url: []u8, + /// The `User-Agent` sent with every request. Owned by the client. + user_agent: []u8, + + timeout_ms: u64, + max_attempts: u32, + backoff_base_ms: u64, + backoff_max_ms: u64, + total_timeout_ms: ?u64, + + prng: std.Random.DefaultPrng, + /// Guards `prng` and the lazy trust-store load, so one `Client` can be + /// shared across threads. + mutex: Io.Mutex, + + /// Build a client. `io` and `gpa` must outlive it. + /// + /// Fails with `error.InvalidConfiguration` when `max_attempts` is 0 or + /// `timeout_ms` is 0 — both describe a client that can never complete a + /// call. Call `deinit` when done. + pub fn init(gpa: Allocator, io: Io, config: Config) InitError!Client { + if (config.max_attempts < 1) return error.InvalidConfiguration; + if (config.timeout_ms == 0) return error.InvalidConfiguration; + + const trimmed = std.mem.trimEnd(u8, config.base_url, "/"); + const base_url = try gpa.dupe(u8, trimmed); + errdefer gpa.free(base_url); + + const user_agent = try gpa.dupe(u8, config.user_agent); + errdefer gpa.free(user_agent); + + const seed = config.jitter_seed orelse seed: { + const now = Io.Timestamp.now(io, .real); + break :seed @as(u64, @truncate(@as(u128, @bitCast(@as(i128, now.nanoseconds))))); + }; + + return .{ + .gpa = gpa, + .io = io, + .http = .{ .allocator = gpa, .io = io }, + .base_url = base_url, + .user_agent = user_agent, + .timeout_ms = config.timeout_ms, + .max_attempts = config.max_attempts, + .backoff_base_ms = config.backoff_base_ms, + .backoff_max_ms = config.backoff_max_ms, + .total_timeout_ms = config.total_timeout_ms, + .prng = .init(seed), + .mutex = .init, + }; + } + + /// Close every pooled connection and release the client's own memory. + /// + /// Values already returned by `lookup`, `lookupMany` and `health` are not + /// touched: they own their memory and outlive the client. + pub fn deinit(c: *Client) void { + c.http.deinit(); + c.gpa.free(c.base_url); + c.gpa.free(c.user_agent); + c.* = undefined; + } + + /// Look up a single CEP. + /// + /// The CEP may be masked or not. This is the only way to obtain a + /// coordinate — see `lookupMany` for why. + /// + /// A 400 from the API becomes `error.InvalidCep` with the API's own message + /// in `Diagnostics`: the format already passed local validation, so the + /// server knows something this client does not. + /// + /// Errors: `error.InvalidCep` (not 8 digits — no request is made), + /// `error.NotFound`, `error.RateLimited`, `error.ServerError`, + /// `error.NetworkError`, `error.InvalidResponse`, `error.OutOfMemory`. + /// + /// ## Ownership + /// + /// The returned `Address` owns its memory. Call `address.deinit(client.gpa)`. + pub fn lookup(c: *Client, cep: []const u8, options: CallOptions) Error!Address { + var digits: [8]u8 = undefined; + const number = normalizeCep(cep, &digits) catch { + note(options.diagnostics, "CEP must be 8 digits, got \"{s}\"", .{cep}); + return error.InvalidCep; + }; + + var path_buffer: [16]u8 = undefined; + const path = std.fmt.bufPrint(&path_buffer, "/{s}", .{number}) catch unreachable; + + const response = try c.request(path, null, &.{200}, options.diagnostics); + defer c.gpa.free(response.body); + + if (response.status == 404) { + note(options.diagnostics, "CEP {s} does not exist in the DNE database", .{number}); + return error.NotFound; + } + if (response.status == 400) { + noteApiMessage(options.diagnostics, response.body, "CEP {s} rejected", .{number}); + return error.InvalidCep; + } + + const parsed = try c.parseJsonObject(response.body, options.diagnostics); + defer parsed.deinit(); + + return Address.fromJson(c.gpa, parsed.value, response.body); + } + + /// Look up many CEPs in one batch. + /// + /// Returns a slice of **the same length and in the same order** as the + /// input. A CEP that does not exist becomes null at its position — it does + /// not vanish from the list, and it is not an error. That is the right + /// difference from `lookup`: in a batch of a thousand, one bad CEP must not + /// cost the other nine hundred and ninety-nine. + /// + /// A malformed CEP also becomes null, for the same reason. If you need to + /// know *which* one was malformed, validate first with `normalizeCep`. + /// + /// Input is chunked into blocks of `max_batch` automatically. Do not loop + /// over `lookup` instead: 500 calls pay 500 round trips, the batch pays one. + /// + /// **Batches carry no coordinate.** The endpoint behind them is CSV, and the + /// CSV has no `geo` column. Every `Address` returned here has `geo == null`, + /// including for CEPs that do have one. Need `geo`? Use `lookup`, one at a + /// time. + /// + /// Errors are transport failures only — `error.RateLimited`, + /// `error.ServerError`, `error.NetworkError`, `error.InvalidResponse`, + /// `error.OutOfMemory`. A bad CEP never fails a batch. + /// + /// ## Ownership + /// + /// The returned slice and every `Address` in it are owned by the caller. + /// Release the lot with `cepis.freeAddresses(client.gpa, result)`. + pub fn lookupMany(c: *Client, ceps: []const []const u8, options: CallOptions) Error![]?Address { + const result = try c.gpa.alloc(?Address, ceps.len); + errdefer freeAddresses(c.gpa, result); + @memset(result, null); + if (ceps.len == 0) return result; + + var start: usize = 0; + while (start < ceps.len) : (start += max_batch) { + const end = @min(start + max_batch, ceps.len); + try c.lookupChunk(ceps[start..end], result[start..end], options); + } + return result; + } + + /// Query `GET /health`: does the API answer, and with which database. + /// + /// An API reporting itself degraded is not an error — that arrives in + /// `Health.status`, and checking health is exactly when you want the + /// diagnosis rather than a failure. Network failures still fail. + /// + /// ## Ownership + /// + /// The returned `Health` owns its memory. Call `health.deinit(client.gpa)`. + pub fn health(c: *Client, options: CallOptions) Error!Health { + const response = try c.request("/health", null, &.{ 200, 503 }, options.diagnostics); + defer c.gpa.free(response.body); + + const parsed = try c.parseJsonObject(response.body, options.diagnostics); + defer parsed.deinit(); + + return Health.fromJson(c.gpa, parsed.value, response.body); + } + + /// One chunk of at most `max_batch` CEPs, in a single request, written into + /// `out` which is already null-filled and exactly as long as `ceps`. + /// + /// A blank CSV row means "not found": the API fills only the `cep` column + /// and leaves the rest empty, precisely so it does not misalign whoever is + /// consuming the batch in a spreadsheet. + /// + /// The API guarantees one row per requested CEP. Should that ever fail to + /// hold — a proxy truncating, a cut response — the extra positions stay null + /// rather than the result being returned short, because a shifted list + /// silently pairs the wrong address with the wrong CEP. + fn lookupChunk( + c: *Client, + ceps: []const []const u8, + out: []?Address, + options: CallOptions, + ) Error!void { + const query = try buildBatchQuery(c.gpa, ceps); + defer c.gpa.free(query); + + const response = try c.request("/csv", query, &.{200}, options.diagnostics); + defer c.gpa.free(response.body); + + const records = try csv.parse(c.gpa, response.body); + defer csv.free(c.gpa, records); + + if (records.len < 2) return; + const header = records[0]; + const localidade = columnIndex(header, "localidade"); + + for (records[1..], 0..) |record, i| { + if (i >= out.len) break; + const found = if (localidade) |column| + column < record.len and std.mem.trim(u8, record[column], " \t").len != 0 + else + false; + if (!found) continue; + out[i] = try Address.fromCsvRecord(c.gpa, header, record); + } + } + + /// One HTTP response, buffered. + const Response = struct { + status: u16, + /// Owned by `Client.gpa`; free it. + body: []u8, + retry_after_ms: ?u64, + }; + + /// Perform the GET, with retries. `query` is appended to `path` as-is and + /// must already be percent-encoded, or be null. + /// + /// *Definitive* error statuses (400, 404) come back normally, for the + /// caller to turn into the right error — only transient failures become + /// errors here, and only once the attempts are exhausted. `accepted` lists + /// the statuses to hand back even though they would otherwise be retried, + /// which is how `/health` gets its 503. + fn request( + c: *Client, + path: []const u8, + query: ?[]const u8, + accepted: []const u16, + diagnostics: ?*Diagnostics, + ) Error!Response { + const url = try std.fmt.allocPrint(c.gpa, "{s}{s}{s}{s}", .{ + c.base_url, + path, + if (query != null) "?" else "", + query orelse "", + }); + defer c.gpa.free(url); + + const started = Io.Timestamp.now(c.io, .awake); + + var last_status: ?u16 = null; + var last_retry_after: ?u64 = null; + var attempts: u32 = 0; + + var attempt: u32 = 1; + while (attempt <= c.max_attempts) : (attempt += 1) { + if (c.remainingMs(started)) |left| { + if (left == 0) break; + } + attempts = attempt; + + if (c.attemptOnce(url, diagnostics)) |response| { + const definitive = !isRetriable(response.status) or contains(accepted, response.status); + if (definitive) { + if (diagnostics) |d| { + d.status = response.status; + d.attempts = attempts; + } + return response; + } + c.gpa.free(response.body); + last_status = response.status; + last_retry_after = response.retry_after_ms; + } else |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidResponse => { + if (diagnostics) |d| d.attempts = attempts; + return error.InvalidResponse; + }, + error.NetworkError => { + last_status = null; + last_retry_after = null; + }, + } + + if (attempt == c.max_attempts) break; + + var wait = c.backoffMs(attempt, last_retry_after); + if (c.remainingMs(started)) |left| { + if (left == 0) break; + wait = @min(wait, left); + } + c.sleepMs(wait); + } + + if (diagnostics) |d| { + d.status = last_status; + d.retry_after_ms = last_retry_after; + d.attempts = attempts; + } + + if (last_status) |status| { + if (status == 429) { + note(diagnostics, "rate limit reached at {s} (after {d} attempt(s))", .{ url, attempts }); + return error.RateLimited; + } + note(diagnostics, "the API answered {d} at {s} (after {d} attempt(s))", .{ status, url, attempts }); + return error.ServerError; + } + + if (attempts == 0) { + note(diagnostics, "no attempt was made at {s} within the total timeout", .{url}); + } + return error.NetworkError; + } + + /// A single request/response round trip. `error.NetworkError` means the + /// exchange never completed and is worth retrying; the message explaining + /// which transport error it was is already in `diagnostics`. + fn attemptOnce( + c: *Client, + url: []const u8, + diagnostics: ?*Diagnostics, + ) (error{ NetworkError, InvalidResponse } || Allocator.Error)!Response { + const uri = std.Uri.parse(url) catch { + note(diagnostics, "{s} is not a usable URL", .{url}); + return error.NetworkError; + }; + + const connection = c.connectWithTimeout(uri) catch |err| { + note(diagnostics, "could not reach {s}: {t}", .{ url, err }); + return if (err == error.OutOfMemory) error.OutOfMemory else error.NetworkError; + }; + var req = c.http.request(.GET, uri, .{ + .connection = connection, + .keep_alive = true, + .headers = .{ + .user_agent = .{ .override = c.user_agent }, + .accept_encoding = .{ .override = "identity" }, + }, + .extra_headers = &.{.{ .name = "accept", .value = accept_header }}, + }) catch |err| { + c.http.connection_pool.release(connection, c.io); + note(diagnostics, "could not reach {s}: {t}", .{ url, err }); + return if (err == error.OutOfMemory) error.OutOfMemory else error.NetworkError; + }; + defer req.deinit(); + + req.sendBodiless() catch |err| { + note(diagnostics, "could not send the request to {s}: {t}", .{ url, err }); + return error.NetworkError; + }; + + var redirect_buffer: [8192]u8 = undefined; + var response = req.receiveHead(&redirect_buffer) catch |err| { + note(diagnostics, "no usable response from {s}: {t}", .{ url, err }); + return if (err == error.OutOfMemory) error.OutOfMemory else error.NetworkError; + }; + + const status: u16 = @intFromEnum(response.head.status); + const retry_after_ms = c.readRetryAfter(response.head); + + const decompress_buffer: []u8 = switch (response.head.content_encoding) { + .identity => &.{}, + .zstd => try c.gpa.alloc(u8, std.compress.zstd.default_window_len), + .deflate, .gzip => try c.gpa.alloc(u8, std.compress.flate.max_window_len), + .compress => { + note(diagnostics, "{s} answered with an unsupported content encoding", .{url}); + return error.InvalidResponse; + }, + }; + defer c.gpa.free(decompress_buffer); + + var transfer_buffer: [4096]u8 = undefined; + var decompress: std.http.Decompress = undefined; + const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer); + + const body = reader.allocRemaining(c.gpa, .limited(max_body_bytes)) catch |err| { + note(diagnostics, "could not read the response from {s}: {t}", .{ url, err }); + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.StreamTooLong => error.InvalidResponse, + else => error.NetworkError, + }; + }; + + return .{ .status = status, .body = body, .retry_after_ms = retry_after_ms }; + } + + /// Open (or reuse) a connection, giving the handshake at most + /// `timeout_ms`. + /// + /// `std.http.Client.request` connects with no timeout at all, so a host + /// that blackholes packets would hang the caller until the kernel gives + /// up. Connecting first and handing the connection over is how `timeout_ms` + /// is made to mean something. Proxy support is skipped along the way — this + /// library never configures one. + fn connectWithTimeout(c: *Client, uri: std.Uri) !*std.http.Client.Connection { + const protocol = std.http.Client.Protocol.fromUri(uri) orelse + return error.UnsupportedUriScheme; + if (protocol == .tls) try c.ensureTrustStore(); + var host_buffer: [std.Io.net.HostName.max_len]u8 = undefined; + const host = try uri.getHost(&host_buffer); + const port = uri.port orelse switch (protocol) { + .plain => @as(u16, 80), + .tls => @as(u16, 443), + }; + return c.http.connectTcpOptions(.{ + .host = host, + .port = port, + .protocol = protocol, + .timeout = .{ .duration = .{ + .raw = .fromNanoseconds(@as(i96, c.timeout_ms) * std.time.ns_per_ms), + .clock = .awake, + } }, + }); + } + + /// Load the system trust store into the underlying `std.http.Client`, once. + /// + /// `std.http.Client.request` does this lazily on the first HTTPS request; + /// `connectTcpOptions`, which is the only entry point that takes a timeout, + /// assumes it has already happened and panics otherwise. Doing it here is + /// what makes `timeout_ms` and `https://` coexist. + fn ensureTrustStore(c: *Client) !void { + if (!std.http.Client.disable_tls) { + c.mutex.lockUncancelable(c.io); + defer c.mutex.unlock(c.io); + if (c.http.now != null) return; + + const now = Io.Clock.real.now(c.io); + var bundle: std.crypto.Certificate.Bundle = .empty; + errdefer bundle.deinit(c.gpa); + bundle.rescan(c.gpa, c.io, now) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.CertificateBundleLoadFailure, + }; + c.http.ca_bundle = bundle; + c.http.now = now; + } + } + + fn readRetryAfter(c: *Client, head: std.http.Client.Response.Head) ?u64 { + var it = head.iterateHeaders(); + while (it.next()) |header| { + if (!std.ascii.eqlIgnoreCase(header.name, "retry-after")) continue; + const now = Io.Timestamp.now(c.io, .real); + const now_unix: i64 = @intCast(@divFloor(now.nanoseconds, std.time.ns_per_s)); + return retry.parseRetryAfter(header.value, now_unix); + } + return null; + } + + /// How long to wait before the next attempt. + /// + /// The server's `Retry-After` wins over the local calculation when present: + /// it knows when it will be back, we are guessing. It still goes through the + /// `backoff_max_ms` cap. Otherwise the wait is drawn from `[0, ceiling]` + /// rather than being the ceiling. + fn backoffMs(c: *Client, attempt: u32, retry_after_ms: ?u64) u64 { + if (retry_after_ms) |asked| return @min(asked, c.backoff_max_ms); + + const ceiling = retry.ceilingMs(attempt, c.backoff_base_ms, c.backoff_max_ms); + c.mutex.lockUncancelable(c.io); + defer c.mutex.unlock(c.io); + return retry.jitter(c.prng.random(), ceiling); + } + + /// Milliseconds left of `total_timeout_ms`, or null when there is no cap. + fn remainingMs(c: *Client, started: Io.Timestamp) ?u64 { + const cap = c.total_timeout_ms orelse return null; + const now = Io.Timestamp.now(c.io, .awake); + const elapsed_ns = started.durationTo(now).nanoseconds; + if (elapsed_ns <= 0) return cap; + const elapsed_ms: u128 = @intCast(@divFloor(elapsed_ns, std.time.ns_per_ms)); + if (elapsed_ms >= cap) return 0; + return cap - @as(u64, @intCast(elapsed_ms)); + } + + fn sleepMs(c: *Client, ms: u64) void { + if (ms == 0) return; + c.io.sleep(.fromNanoseconds(@as(i96, ms) * std.time.ns_per_ms), .awake) catch {}; + } + + fn parseJsonObject( + c: *Client, + body: []const u8, + diagnostics: ?*Diagnostics, + ) Error!std.json.Parsed(std.json.Value) { + const parsed = std.json.parseFromSlice(std.json.Value, c.gpa, body, .{}) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + note( + diagnostics, + "response is not valid JSON — something between you and the API is " ++ + "answering instead of it: \"{s}\"", + .{preview(body)}, + ); + return error.InvalidResponse; + }; + errdefer parsed.deinit(); + + switch (parsed.value) { + .object => return parsed, + else => { + note(diagnostics, "expected a JSON object, got \"{s}\"", .{preview(body)}); + return error.InvalidResponse; + }, + } + } +}; + +/// Release a slice returned by `Client.lookupMany`, and every `Address` in it. +pub fn freeAddresses(gpa: Allocator, list: []?Address) void { + for (list) |*slot| { + if (slot.*) |*address| address.deinit(gpa); + } + gpa.free(list); +} + +/// Build `ceps=a,b,c`, percent-encoding anything that is not safe in a query. +/// +/// The caller's CEPs are not validated here — a malformed one is answered with +/// an empty row and becomes null, which is the documented behaviour of +/// `lookupMany`. Encoding is still required so a stray `&` or space cannot +/// rewrite the query string. +/// +/// The result is owned by the caller. +fn buildBatchQuery(gpa: Allocator, ceps: []const []const u8) Allocator.Error![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + + try out.appendSlice(gpa, "ceps="); + for (ceps, 0..) |cep, i| { + if (i != 0) try out.appendSlice(gpa, ","); + for (cep) |char| switch (char) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => try out.append(gpa, char), + else => try out.print(gpa, "%{X:0>2}", .{char}), + }; + } + return out.toOwnedSlice(gpa); +} + +fn columnIndex(header: []const []const u8, name: []const u8) ?usize { + for (header, 0..) |column, i| { + if (std.mem.eql(u8, std.mem.trim(u8, column, " \t"), name)) return i; + } + return null; +} + +fn contains(haystack: []const u16, needle: u16) bool { + for (haystack) |value| { + if (value == needle) return true; + } + return false; +} + +fn isRetriable(status: u16) bool { + return contains(&retriable_status, status); +} + +/// Pull `mensagem` out of an error body into `diagnostics`, falling back to a +/// formatted default when the body says nothing useful. +fn noteApiMessage( + diagnostics: ?*Diagnostics, + body: []const u8, + comptime fallback: []const u8, + args: anytype, +) void { + const d = diagnostics orelse return; + + var buffer: [4096]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&buffer); + if (std.json.parseFromSlice(std.json.Value, fba.allocator(), body, .{})) |parsed| { + defer parsed.deinit(); + switch (parsed.value) { + .object => |object| if (object.get("mensagem")) |value| switch (value) { + .string => |text| if (text.len != 0) { + d.setMessage("{s}", .{text}); + return; + }, + else => {}, + }, + else => {}, + } + } else |_| {} + + d.setMessage(fallback, args); +} + +fn preview(body: []const u8) []const u8 { + return body[0..@min(body.len, 120)]; +} + +const testing = std.testing; + +test "normalizeCep accepts the mask" { + var buffer: [8]u8 = undefined; + for ([_][]const u8{ "01001000", "01001-000", " 01001-000 ", "01001.000" }) |value| { + try testing.expectEqualStrings("01001000", try normalizeCep(value, &buffer)); + } +} + +test "normalizeCep rejects anything else" { + var buffer: [8]u8 = undefined; + for ([_][]const u8{ "", "123", "0100100a", "010010000", "abcdefgh", "01001-0000" }) |value| { + try testing.expectError(error.InvalidCep, normalizeCep(value, &buffer)); + } +} + +test "buildBatchQuery joins and encodes" { + const query = try buildBatchQuery(testing.allocator, &.{ "01001000", "a b&c" }); + defer testing.allocator.free(query); + try testing.expectEqualStrings("ceps=01001000,a%20b%26c", query); +} + +test "buildBatchQuery on an empty list is just the key" { + const query = try buildBatchQuery(testing.allocator, &.{}); + defer testing.allocator.free(query); + try testing.expectEqualStrings("ceps=", query); +} + +test "columnIndex finds the column or nothing" { + const header = [_][]const u8{ "cep", "logradouro", "localidade" }; + try testing.expectEqual(@as(?usize, 2), columnIndex(&header, "localidade")); + try testing.expectEqual(@as(?usize, null), columnIndex(&header, "geo")); +} + +test "only transient statuses are retriable" { + for ([_]u16{ 429, 500, 502, 503, 504 }) |status| { + try testing.expect(isRetriable(status)); + } + for ([_]u16{ 200, 400, 404, 501 }) |status| { + try testing.expect(!isRetriable(status)); + } +} + +test "noteApiMessage prefers the API wording" { + var diag: Diagnostics = .{}; + noteApiMessage(&diag, "{\"erro\":\"cep_invalido\",\"mensagem\":\"rejected\"}", "fallback {s}", .{"x"}); + try testing.expectEqualStrings("rejected", diag.message()); + + diag.reset(); + noteApiMessage(&diag, "<html>", "fallback {s}", .{"x"}); + try testing.expectEqualStrings("fallback x", diag.message()); +} |