//! 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, "", "fallback {s}", .{"x"}); try testing.expectEqualStrings("fallback x", diag.message()); }