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 | |
| download | zig-0.1.0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src')
| -rw-r--r-- | src/client.zig | 804 | ||||
| -rw-r--r-- | src/csv.zig | 207 | ||||
| -rw-r--r-- | src/errors.zig | 176 | ||||
| -rw-r--r-- | src/main.zig | 118 | ||||
| -rw-r--r-- | src/model.zig | 543 | ||||
| -rw-r--r-- | src/retry.zig | 152 | ||||
| -rw-r--r-- | src/root.zig | 90 |
7 files changed, 2090 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()); +} diff --git a/src/csv.zig b/src/csv.zig new file mode 100644 index 0000000..953e5eb --- /dev/null +++ b/src/csv.zig @@ -0,0 +1,207 @@ +//! A small RFC 4180 reader for the `/csv` batch endpoint. +//! +//! Values may contain commas and are then double-quoted with `""` escaping, +//! which is why the batch cannot be split on `,`. The reader is deliberately +//! lenient: blank lines are skipped and a stray quote inside an unquoted field +//! is kept as data, because a malformed byte in one row must not cost the whole +//! batch. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// One parsed row: a slice of owned field values. +pub const Record = [][]const u8; + +/// Split CSV text into records, honouring quoting, `""` escapes, embedded +/// commas and newlines, CRLF and LF line endings, and a leading BOM. +/// +/// ## Ownership +/// +/// Every field, every record and the outer slice are allocated from `gpa` and +/// owned by the caller. Release the lot with `free`; `input` is only borrowed +/// for the duration of the call. +pub fn parse(gpa: Allocator, input: []const u8) Allocator.Error![]Record { + const text = if (std.mem.startsWith(u8, input, "\xEF\xBB\xBF")) input[3..] else input; + + var records: std.ArrayList(Record) = .empty; + errdefer free(gpa, records.items); + errdefer records.deinit(gpa); + + var record: std.ArrayList([]const u8) = .empty; + errdefer freeRecord(gpa, record.items); + errdefer record.deinit(gpa); + + var field: std.ArrayList(u8) = .empty; + defer field.deinit(gpa); + + var quoted = false; + var i: usize = 0; + while (i < text.len) : (i += 1) { + const c = text[i]; + + if (quoted) { + if (c == '"') { + if (i + 1 < text.len and text[i + 1] == '"') { + try field.append(gpa, '"'); + i += 1; + } else { + quoted = false; + } + } else { + try field.append(gpa, c); + } + continue; + } + + switch (c) { + '"' => if (field.items.len == 0) { + quoted = true; + } else { + try field.append(gpa, c); + }, + ',' => try endField(gpa, &record, &field), + '\r', '\n' => { + if (c == '\r' and i + 1 < text.len and text[i + 1] == '\n') i += 1; + try endField(gpa, &record, &field); + try endRecord(gpa, &records, &record); + }, + else => try field.append(gpa, c), + } + } + + if (field.items.len != 0 or record.items.len != 0) { + try endField(gpa, &record, &field); + try endRecord(gpa, &records, &record); + } + + return records.toOwnedSlice(gpa); +} + +/// Release everything `parse` returned. +pub fn free(gpa: Allocator, records: []Record) void { + for (records) |record| freeRecord(gpa, record); + gpa.free(records); +} + +fn freeRecord(gpa: Allocator, record: []const []const u8) void { + for (record) |value| gpa.free(value); + gpa.free(record); +} + +fn endField( + gpa: Allocator, + record: *std.ArrayList([]const u8), + field: *std.ArrayList(u8), +) Allocator.Error!void { + const owned = try gpa.dupe(u8, field.items); + errdefer gpa.free(owned); + try record.append(gpa, owned); + field.clearRetainingCapacity(); +} + +/// Append the record unless every one of its fields is empty. A blank line +/// carries no data and would otherwise be mistaken for a row of empties, which +/// is exactly what the batch endpoint uses to mean "not found". +fn endRecord( + gpa: Allocator, + records: *std.ArrayList(Record), + record: *std.ArrayList([]const u8), +) Allocator.Error!void { + var any = false; + for (record.items) |value| { + if (value.len != 0) any = true; + } + if (!any) { + freeRecord(gpa, try record.toOwnedSlice(gpa)); + return; + } + const owned = try record.toOwnedSlice(gpa); + errdefer freeRecord(gpa, owned); + try records.append(gpa, owned); +} + +const testing = std.testing; + +fn expectRecords(input: []const u8, expected: []const []const []const u8) !void { + const records = try parse(testing.allocator, input); + defer free(testing.allocator, records); + + try testing.expectEqual(expected.len, records.len); + for (expected, records) |want_record, got_record| { + try testing.expectEqual(want_record.len, got_record.len); + for (want_record, got_record) |want, got| { + try testing.expectEqualStrings(want, got); + } + } +} + +test "splits plain rows" { + try expectRecords("a,b,c\n1,2,3\n", &.{ + &.{ "a", "b", "c" }, + &.{ "1", "2", "3" }, + }); +} + +test "keeps empty fields" { + const records = try parse(testing.allocator, "99999999,,,,,,,,,,,,\n"); + defer free(testing.allocator, records); + + try testing.expectEqual(@as(usize, 1), records.len); + try testing.expectEqual(@as(usize, 13), records[0].len); + try testing.expectEqualStrings("99999999", records[0][0]); + try testing.expectEqualStrings("", records[0][12]); +} + +test "reads a quoted field containing a comma" { + try expectRecords("cep,logradouro\n01001000,\"Rua Um, Dois\"\n", &.{ + &.{ "cep", "logradouro" }, + &.{ "01001000", "Rua Um, Dois" }, + }); +} + +test "unescapes doubled quotes" { + try expectRecords("a\n\"say \"\"hi\"\"\"\n", &.{ + &.{"a"}, + &.{"say \"hi\""}, + }); +} + +test "accepts an embedded newline and CRLF endings" { + try expectRecords("a,b\r\n\"line\nbreak\",2\r\n", &.{ + &.{ "a", "b" }, + &.{ "line\nbreak", "2" }, + }); +} + +test "ignores a BOM and blank lines" { + try expectRecords("\xEF\xBB\xBFa,b\n\n1,2", &.{ + &.{ "a", "b" }, + &.{ "1", "2" }, + }); +} + +test "a final row without a trailing newline is kept" { + try expectRecords("a,b\n1,2", &.{ + &.{ "a", "b" }, + &.{ "1", "2" }, + }); +} + +test "a stray quote inside an unquoted field is data" { + try expectRecords("a\n12\"34\n", &.{ + &.{"a"}, + &.{"12\"34"}, + }); +} + +test "empty input yields no records" { + try expectRecords("", &.{}); + try expectRecords("\n\n", &.{}); +} + +test "UTF-8 passes through untouched" { + try expectRecords("logradouro\nPraça da Sé\n", &.{ + &.{"logradouro"}, + &.{"Praça da Sé"}, + }); +} diff --git a/src/errors.zig b/src/errors.zig new file mode 100644 index 0000000..c46e266 --- /dev/null +++ b/src/errors.zig @@ -0,0 +1,176 @@ +//! The error set, and the diagnostics that go with it. +//! +//! Everything the library can fail with is one `Error`, so a caller who only +//! cares that *something* went wrong catches one set and is done. +//! +//! Below that, the split is by **the decision the caller has to make**, not by +//! the HTTP status that caused it: +//! +//! - `error.InvalidCep` and `error.NotFound` are problems with the *request*. +//! Retrying is pointless — the same CEP will fail again. +//! - `error.NetworkError`, `error.ServerError` and `error.RateLimited` are +//! problems with *transport*. Retrying may help, and the client already +//! retried on its own before returning any of them. +//! +//! That split is why `error.NotFound` is not "the HTTP 404 error": the client +//! also returns `error.InvalidCep` without making any request at all, when the +//! CEP is malformed before it ever leaves the machine. +//! +//! A Zig error carries no payload, so anything a caller may need beyond the +//! tag — the HTTP status, the `Retry-After` the server asked for, the API's own +//! message — travels in an optional `Diagnostics` the caller passes in. + +const std = @import("std"); + +/// Everything that can go wrong when talking to the API, minus allocation +/// failure. Use `Error` in signatures; use this when you want to match +/// exhaustively without `error.OutOfMemory` in the way. +pub const CepisError = error{ + /// The CEP is not 8 digits. + /// + /// Usually returned **without** a request ever being made — the client + /// checks the format before going to the network. It is also what a 400 + /// from the API becomes, in which case `Diagnostics.message` carries the + /// API's own wording. + InvalidCep, + + /// The CEP is well-formed but does not exist in the DNE database. + NotFound, + + /// The API (or the edge in front of it) answered 429 on every attempt. + /// + /// `Diagnostics.retry_after_ms` holds what the server asked for, when it + /// said. The client already honoured it across the attempts it made; it is + /// reported for callers who want to reschedule the work. + RateLimited, + + /// The API answered 5xx on every attempt. `Diagnostics.status` holds the + /// status seen on the last one. + ServerError, + + /// The request never completed: DNS, refused connection, timeout, TLS. + /// + /// Returned only after every configured attempt has failed. + NetworkError, + + /// A success status came back with a body that cannot be parsed. + /// + /// In practice this means something between you and the API is answering + /// instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error + /// page. Useful as a diagnosis: the problem is not in your code. + InvalidResponse, +}; + +/// The error set of every fallible operation in this library. +pub const Error = CepisError || std.mem.Allocator.Error; + +/// Whether trying the same call again later could plausibly succeed. +/// +/// True for `RateLimited`, `ServerError` and `NetworkError`; false for a bad or +/// unknown CEP, which fails the same way forever. `OutOfMemory` is not +/// retriable in any useful sense and reports false. +/// +/// The client has already retried internally by the time any of these are +/// returned, so "later" means later — not immediately. +pub fn isRetriable(err: Error) bool { + return switch (err) { + error.RateLimited, error.ServerError, error.NetworkError => true, + else => false, + }; +} + +/// Optional out-parameter carrying the detail a Zig error tag cannot. +/// +/// Declare one on the stack, pass a pointer, and read it after the call fails: +/// +/// ```zig +/// var diag: cepis.Diagnostics = .{}; +/// const address = client.lookup("01001000", .{ .diagnostics = &diag }) catch |err| switch (err) { +/// error.RateLimited => return retryIn(diag.retry_after_ms orelse 60_000), +/// else => return err, +/// }; +/// ``` +/// +/// It owns no memory and needs no `deinit`: the message lives in a fixed +/// buffer inside the struct, and is only valid as long as the `Diagnostics` +/// itself. Copy it if you need it to outlive the value. +/// +/// The fields describe the last response the call saw, so `status` is also +/// filled after a call that succeeded. They are only *meaningful* after a +/// failure. Reusing one `Diagnostics` across calls without `reset` can show +/// stale detail. +pub const Diagnostics = struct { + /// The HTTP status of the last attempt, when there was a response at all. + status: ?u16 = null, + + /// What the server asked for via `Retry-After`, in milliseconds, or null + /// if it did not say. Already capped at `Config.backoff_max_ms` by the + /// waits the client performed, but reported here uncapped. + retry_after_ms: ?u64 = null, + + /// How many attempts were made before giving up. + attempts: u32 = 0, + + /// Backing storage for `message`. Internal; read it through `message`. + message_buffer: [max_message_len]u8 = undefined, + + /// Length of the text in `message_buffer`. Internal. + message_len: usize = 0, + + /// The longest explanation a `Diagnostics` will keep. Longer text is + /// truncated rather than allocated. + pub const max_message_len = 512; + + /// A human-readable explanation of the failure, borrowed from the + /// `Diagnostics` and valid only as long as it is. Empty when none was set. + pub fn message(d: *const Diagnostics) []const u8 { + return d.message_buffer[0..d.message_len]; + } + + /// Clear every field, so the value can be reused for another call. + pub fn reset(d: *Diagnostics) void { + d.status = null; + d.retry_after_ms = null; + d.attempts = 0; + d.message_len = 0; + } + + /// Set the message, truncating at `max_message_len`. Internal. + pub fn setMessage(d: *Diagnostics, comptime fmt: []const u8, args: anytype) void { + var writer: std.Io.Writer = .fixed(&d.message_buffer); + writer.print(fmt, args) catch {}; + d.message_len = writer.end; + } +}; + +/// Write to `maybe_diagnostics` when the caller supplied one. Internal. +pub fn note(maybe_diagnostics: ?*Diagnostics, comptime fmt: []const u8, args: anytype) void { + const d = maybe_diagnostics orelse return; + d.setMessage(fmt, args); +} + +test "isRetriable splits by decision" { + try std.testing.expect(isRetriable(error.NetworkError)); + try std.testing.expect(isRetriable(error.ServerError)); + try std.testing.expect(isRetriable(error.RateLimited)); + try std.testing.expect(!isRetriable(error.NotFound)); + try std.testing.expect(!isRetriable(error.InvalidCep)); + try std.testing.expect(!isRetriable(error.InvalidResponse)); +} + +test "Diagnostics keeps a message without allocating" { + var diag: Diagnostics = .{}; + try std.testing.expectEqualStrings("", diag.message()); + + diag.setMessage("CEP {s} rejected", .{"00000000"}); + try std.testing.expectEqualStrings("CEP 00000000 rejected", diag.message()); + + diag.reset(); + try std.testing.expectEqualStrings("", diag.message()); +} + +test "Diagnostics truncates instead of overflowing" { + var diag: Diagnostics = .{}; + diag.setMessage("{s}", .{"x" ** (Diagnostics.max_message_len * 2)}); + try std.testing.expectEqual(Diagnostics.max_message_len, diag.message().len); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..6bb9777 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,118 @@ +//! A one-shot CLI over the library, mostly so `zig build run` can confirm that +//! an instance answers. +//! +//! cepis 01001000 +//! cepis --base-url http://localhost:8080 01001000 20040020 +//! cepis --health + +const std = @import("std"); +const cepis = @import("cepis"); + +const usage = + \\usage: cepis [--base-url URL] [--health] [CEP ...] + \\ + \\ --base-url URL where the API lives (default https://cep.is) + \\ --health query GET /health instead of looking anything up + \\ --help show this message + \\ +; + +pub fn main(init: std.process.Init) !u8 { + const gpa = init.gpa; + const io = init.io; + const arena = init.arena.allocator(); + + var stdout_buffer: [4096]u8 = undefined; + var stdout: std.Io.File.Writer = .init(.stdout(), io, &stdout_buffer); + const out = &stdout.interface; + defer out.flush() catch {}; + + const args = try init.minimal.args.toSlice(arena); + + var base_url: []const u8 = cepis.default_url; + var want_health = false; + var ceps: std.ArrayList([]const u8) = .empty; + defer ceps.deinit(gpa); + + var i: usize = 1; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { + try out.writeAll(usage); + return 0; + } else if (std.mem.eql(u8, arg, "--health")) { + want_health = true; + } else if (std.mem.eql(u8, arg, "--base-url")) { + i += 1; + if (i >= args.len) { + try out.writeAll("--base-url needs a value\n"); + return 2; + } + base_url = args[i]; + } else { + try ceps.append(gpa, arg); + } + } + + if (!want_health and ceps.items.len == 0) { + try out.writeAll(usage); + return 2; + } + + var client: cepis.Client = try .init(gpa, io, .{ .base_url = base_url }); + defer client.deinit(); + + var diag: cepis.Diagnostics = .{}; + + if (want_health) { + var health = client.health(.{ .diagnostics = &diag }) catch |err| { + try out.print("health failed: {t}: {s}\n", .{ err, diag.message() }); + return 1; + }; + defer health.deinit(gpa); + try out.print("{s} dne={s} ceps={d} geo={d} gerada_em={s}\n", .{ + health.status, health.dne, health.ceps, health.geo, health.base_gerada_em, + }); + return 0; + } + + if (ceps.items.len == 1) { + var address = client.lookup(ceps.items[0], .{ .diagnostics = &diag }) catch |err| { + try out.print("{s}: {t}: {s}\n", .{ ceps.items[0], err, diag.message() }); + return 1; + }; + defer address.deinit(gpa); + try printAddress(out, address); + return 0; + } + + const found = client.lookupMany(ceps.items, .{ .diagnostics = &diag }) catch |err| { + try out.print("batch failed: {t}: {s}\n", .{ err, diag.message() }); + return 1; + }; + defer cepis.freeAddresses(gpa, found); + + for (ceps.items, found) |requested, maybe_address| { + if (maybe_address) |address| { + try printAddress(out, address); + } else { + try out.print("{s}: not found\n", .{requested}); + } + } + return 0; +} + +fn printAddress(out: *std.Io.Writer, address: cepis.Address) !void { + var buffer: [16]u8 = undefined; + try out.print("{s} {s}, {s} — {s}/{s}", .{ + address.formattedCep(&buffer), + address.logradouro, + address.bairro, + address.localidade, + address.uf, + }); + if (address.geo) |geo| { + try out.print(" [{d:.6},{d:.6} ±{d}m {s}]", .{ geo.lat, geo.lon, geo.raio_m, geo.precisao }); + } + try out.writeAll("\n"); +} diff --git a/src/model.zig b/src/model.zig new file mode 100644 index 0000000..10ecbd3 --- /dev/null +++ b/src/model.zig @@ -0,0 +1,543 @@ +//! The data the API returns, as types. +//! +//! **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`, +//! `localidade`. Everything else in this library is English; the fields are the +//! one deliberate exception. +//! +//! The reason is fidelity: what you read in the JSON at <https://cep.is/01001000> +//! is exactly what you access on the value. Translating to `street`/`city` +//! would mean maintaining a field-by-field mapping and forcing anyone reading +//! the API docs alongside this library to translate back in their head. The +//! values are Portuguese either way — they come from the Correios database. +//! +//! ## Ownership +//! +//! `Address` and `Health` own their strings. Every one of their `[]const u8` +//! fields points into memory the value allocated, and `deinit` releases it. +//! Pass the same allocator the `Client` was built with, and treat every slice +//! as invalid afterwards. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// The CEP's coordinate, when the database has one. +/// +/// The point is the **median** of the coordinates of that CEP's addresses, not +/// the mean: one badly geocoded address 40 km away shifts a mean and does not +/// move a median. +/// +/// Always check `precisao` before using `lat`/`lon` for distance: +/// +/// - `"logradouro"`: the point is a stretch of street. `raio_m` lands around +/// 100–150 m. Good for maps and for distance. +/// - `"localidade"`: the CEP *is* the whole municipality (a city-wide general +/// CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km. +/// Computing shipping from this is wrong by tens of kilometres. +/// +/// `raio_m` is the radius containing 90% of that CEP's addresses. It exists +/// precisely so that error cannot pass unnoticed. +pub const Geo = struct { + /// Latitude in decimal degrees, WGS 84. + lat: f64, + /// Longitude in decimal degrees, WGS 84. + lon: f64, + /// Radius, in metres, containing 90% of the CEP's addresses. + raio_m: i64, + /// `"logradouro"` (a street) or `"localidade"` (a municipality centroid). + /// Borrowed from the owning `Address`; invalid once that is deinitialised. + precisao: []const u8, + + /// True when the coordinate points at a street, not a municipality. + /// + /// Shorthand for `precisao == "logradouro"`, which is the check to make + /// before using the point to measure distance. + pub fn isPrecise(g: Geo) bool { + return std.mem.eql(u8, g.precisao, "logradouro"); + } +}; + +/// The string fields of an `Address`, in the API's own order. +/// +/// Used to keep the field list, the JSON keys and the CSV columns in step: all +/// three are driven from this one declaration. +pub const address_fields = [_][]const u8{ + "cep", "logradouro", "complemento", "unidade", + "bairro", "localidade", "uf", "estado", + "regiao", "ibge", "gia", "ddd", + "siafi", +}; + +/// A CEP and everything the database knows about it. +/// +/// Fields the database does not have for that CEP come back as empty strings, +/// never absent — that is what the API returns, and changing it here would +/// create a difference between reading the JSON and reading this library. +/// +/// `geo` is the exception: it is null when there is no coordinate. There the +/// difference is real, because the API **omits** the whole field in that case +/// (about 43% of CEPs) rather than sending null. +/// +/// ## Ownership +/// +/// The value owns every byte its slices point at. Call `deinit` with the +/// allocator the `Client` was built with exactly once; all slices, including +/// `geo.precisao` and `raw`, are invalid from then on. +pub const Address = struct { + /// The CEP, always unmasked: `"01001000"`. + cep: []const u8 = "", + /// Street, avenue, square — the public way. + logradouro: []const u8 = "", + /// Range or side of the street the CEP covers, when it covers only part. + complemento: []const u8 = "", + /// Name of the large user or building the CEP belongs to, when it does. + unidade: []const u8 = "", + /// Neighbourhood. + bairro: []const u8 = "", + /// Municipality. + localidade: []const u8 = "", + /// Federative unit, two letters: `"SP"`. + uf: []const u8 = "", + /// Federative unit spelled out: `"São Paulo"`. + estado: []const u8 = "", + /// Region: `"Sudeste"`, `"Nordeste"`, and so on. + regiao: []const u8 = "", + /// IBGE municipality code — the key to join with public datasets. + ibge: []const u8 = "", + /// GIA code, São Paulo state tax administration only. + gia: []const u8 = "", + /// Area code for telephony: `"11"`. + ddd: []const u8 = "", + /// SIAFI municipality code, federal administration. + siafi: []const u8 = "", + + /// The coordinate, or null when the database has none for this CEP. + /// + /// Always null for an address that came from `Client.lookupMany` — batches + /// carry no coordinate at all. + geo: ?Geo = null, + + /// The response body exactly as it arrived, uninterpreted, so a field the + /// API grows before this library is updated is not lost. Parse it yourself + /// with `std.json` when you need one. + /// + /// Empty for an address that came from `Client.lookupMany`: the batch + /// endpoint answers in CSV, and a single row is not a document. + raw: []const u8 = "", + + /// Backing storage for every string field above. Internal — do not read it + /// and do not free it yourself; `deinit` does. + bytes: []u8 = &.{}, + + /// Release everything the address owns. Pass the allocator the `Client` + /// was built with. Every slice on the value is invalid afterwards. + pub fn deinit(a: *Address, gpa: Allocator) void { + gpa.free(a.bytes); + gpa.free(a.raw); + a.* = undefined; + } + + /// Write the CEP with its mask — `01001-000` — into `buffer`. + /// + /// The native contract always returns it unmasked; formatting is + /// presentation, and it lives here so it does not become a repeated + /// `bufPrint` in every project that uses this. A CEP that is not 8 digits + /// long is copied through unchanged. The result borrows `buffer`, which + /// must hold at least 9 bytes. + pub fn formattedCep(a: Address, buffer: []u8) []const u8 { + if (a.cep.len != 8 or buffer.len < 9) { + const n = @min(a.cep.len, buffer.len); + @memcpy(buffer[0..n], a.cep[0..n]); + return buffer[0..n]; + } + @memcpy(buffer[0..5], a.cep[0..5]); + buffer[5] = '-'; + @memcpy(buffer[6..9], a.cep[5..8]); + return buffer[0..9]; + } + + /// Build from the JSON object the API returned. + /// + /// Unknown fields are ignored during construction but preserved in `raw`. + /// A new field in the API therefore cannot break this library. + /// + /// A malformed `geo` block yields `geo = null` rather than failing the + /// whole value: it is not worth losing the address over, since the rest of + /// the response is still useful. + /// + /// `body` is duplicated into `raw`; the caller keeps ownership of what it + /// passed in. The returned `Address` owns its memory — call `deinit`. + pub fn fromJson(gpa: Allocator, value: std.json.Value, body: []const u8) Allocator.Error!Address { + var scratch: [address_fields.len][32]u8 = undefined; + var values: [address_fields.len][]const u8 = undefined; + inline for (address_fields, 0..) |name, i| { + values[i] = jsonText(value, name, &scratch[i]); + } + + const geo_source = geoFromJson(value); + return build(gpa, &values, geo_source, body); + } + + /// Build from one CSV record of the batch endpoint, given the header row. + /// + /// Columns the header does not mention are ignored; columns the header + /// mentions but the record lacks come out empty. There is no `geo` column + /// in the batch, so `geo` is always null and `raw` always empty. + /// + /// The returned `Address` owns its memory — call `deinit`. + pub fn fromCsvRecord( + gpa: Allocator, + header: []const []const u8, + record: []const []const u8, + ) Allocator.Error!Address { + var values: [address_fields.len][]const u8 = @splat(""); + for (header, 0..) |column, i| { + if (i >= record.len) break; + inline for (address_fields, 0..) |name, slot| { + if (std.mem.eql(u8, column, name)) values[slot] = record[i]; + } + } + return build(gpa, &values, null, ""); + } + + /// A `Geo` whose `precisao` still points at borrowed memory, as read out of + /// a JSON block but before the strings are packed. Internal. + const GeoSource = struct { lat: f64, lon: f64, raio_m: i64, precisao: []const u8 }; + + fn build( + gpa: Allocator, + values: *const [address_fields.len][]const u8, + geo_source: ?GeoSource, + body: []const u8, + ) Allocator.Error!Address { + var total: usize = 0; + for (values) |v| total += v.len; + if (geo_source) |g| total += g.precisao.len; + + const bytes = try gpa.alloc(u8, total); + errdefer gpa.free(bytes); + + const raw = try gpa.dupe(u8, body); + errdefer gpa.free(raw); + + var address: Address = .{ .bytes = bytes, .raw = raw }; + var cursor: usize = 0; + inline for (address_fields, 0..) |name, i| { + const v = values[i]; + @memcpy(bytes[cursor..][0..v.len], v); + @field(address, name) = bytes[cursor..][0..v.len]; + cursor += v.len; + } + + if (geo_source) |g| { + @memcpy(bytes[cursor..][0..g.precisao.len], g.precisao); + address.geo = .{ + .lat = g.lat, + .lon = g.lon, + .raio_m = g.raio_m, + .precisao = bytes[cursor..][0..g.precisao.len], + }; + cursor += g.precisao.len; + } + + return address; + } + + fn geoFromJson(value: std.json.Value) ?GeoSource { + const object = switch (value) { + .object => |o| o, + else => return null, + }; + const block = switch (object.get("geo") orelse return null) { + .object => |o| o, + else => return null, + }; + const precisao = switch (block.get("precisao") orelse return null) { + .string, .number_string => |s| s, + else => return null, + }; + return .{ + .lat = jsonFloat(block.get("lat")) orelse return null, + .lon = jsonFloat(block.get("lon")) orelse return null, + .raio_m = jsonInt(block.get("raio_m")) orelse return null, + .precisao = precisao, + }; + } +}; + +/// The response of `GET /health`. +/// +/// Good for two things: checking that the API answers, and knowing **which +/// version of the database** is live. The DNE database changes every two weeks; +/// `dne` identifies it. +/// +/// ## Ownership +/// +/// Owns its strings. Call `deinit` with the allocator the `Client` was built +/// with exactly once. +pub const Health = struct { + /// `"ok"` when the API considers itself healthy. + status: []const u8 = "", + /// Identifier of the fortnightly DNE database currently loaded. + dne: []const u8 = "", + /// How many CEPs the database holds. + ceps: u64 = 0, + /// How many of those carry a coordinate. + geo: u64 = 0, + /// When the database was generated, RFC 3339 in UTC. + base_gerada_em: []const u8 = "", + /// The response body exactly as it arrived. + raw: []const u8 = "", + /// Backing storage for the string fields. Internal. + bytes: []u8 = &.{}, + + /// Release everything the value owns. Pass the allocator the `Client` was + /// built with. + pub fn deinit(h: *Health, gpa: Allocator) void { + gpa.free(h.bytes); + gpa.free(h.raw); + h.* = undefined; + } + + /// True when the API reports itself healthy. + pub fn ok(h: Health) bool { + return std.mem.eql(u8, h.status, "ok"); + } + + /// Build from the JSON object the API returned. `body` is duplicated into + /// `raw`; the returned value owns its memory — call `deinit`. + pub fn fromJson(gpa: Allocator, value: std.json.Value, body: []const u8) Allocator.Error!Health { + var scratch: [3][32]u8 = undefined; + const status = jsonText(value, "status", &scratch[0]); + const dne = jsonText(value, "dne", &scratch[1]); + const base_gerada_em = jsonText(value, "base_gerada_em", &scratch[2]); + + const bytes = try gpa.alloc(u8, status.len + dne.len + base_gerada_em.len); + errdefer gpa.free(bytes); + const raw = try gpa.dupe(u8, body); + errdefer gpa.free(raw); + + @memcpy(bytes[0..status.len], status); + @memcpy(bytes[status.len..][0..dne.len], dne); + @memcpy(bytes[status.len + dne.len ..][0..base_gerada_em.len], base_gerada_em); + + const maybe_object: ?std.json.ObjectMap = switch (value) { + .object => |o| o, + else => null, + }; + + return .{ + .status = bytes[0..status.len], + .dne = bytes[status.len..][0..dne.len], + .base_gerada_em = bytes[status.len + dne.len ..][0..base_gerada_em.len], + .ceps = if (maybe_object) |o| jsonCount(o.get("ceps")) else 0, + .geo = if (maybe_object) |o| jsonCount(o.get("geo")) else 0, + .raw = raw, + .bytes = bytes, + }; + } +}; + +/// Read `key` off a JSON object as text. +/// +/// A string comes through as-is; a number is rendered into `scratch`; anything +/// else — including a missing key and an explicit null — is the empty string, +/// which is what the API sends for a field the database lacks. +fn jsonText(value: std.json.Value, key: []const u8, scratch: []u8) []const u8 { + const object = switch (value) { + .object => |o| o, + else => return "", + }; + return switch (object.get(key) orelse return "") { + .string, .number_string => |s| s, + .integer => |n| std.fmt.bufPrint(scratch, "{d}", .{n}) catch "", + .float => |n| std.fmt.bufPrint(scratch, "{d}", .{n}) catch "", + .bool => |b| if (b) "true" else "false", + else => "", + }; +} + +fn jsonFloat(maybe: ?std.json.Value) ?f64 { + return switch (maybe orelse return null) { + .float => |n| n, + .integer => |n| @floatFromInt(n), + .number_string, .string => |s| std.fmt.parseFloat(f64, s) catch null, + else => null, + }; +} + +fn jsonInt(maybe: ?std.json.Value) ?i64 { + return switch (maybe orelse return null) { + .integer => |n| n, + .float => |n| @intFromFloat(n), + .number_string, .string => |s| std.fmt.parseInt(i64, s, 10) catch null, + else => null, + }; +} + +fn jsonCount(maybe: ?std.json.Value) u64 { + return switch (maybe orelse return 0) { + .integer => |n| if (n > 0) @intCast(n) else 0, + .float => |n| if (n > 0) @intFromFloat(n) else 0, + .number_string, .string => |s| std.fmt.parseInt(u64, s, 10) catch 0, + else => 0, + }; +} + +const testing = std.testing; + +fn parseValue(body: []const u8) !std.json.Parsed(std.json.Value) { + return std.json.parseFromSlice(std.json.Value, testing.allocator, body, .{}); +} + +test "Address.fromJson reads every field and owns its strings" { + const body = + \\{"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar", + \\ "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP", + \\ "estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004", + \\ "ddd":"11","siafi":"7107"} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var address = try Address.fromJson(testing.allocator, parsed.value, body); + defer address.deinit(testing.allocator); + + try testing.expectEqualStrings("01001000", address.cep); + try testing.expectEqualStrings("Praça da Sé", address.logradouro); + try testing.expectEqualStrings("São Paulo", address.localidade); + try testing.expectEqualStrings("SP", address.uf); + try testing.expectEqualStrings("7107", address.siafi); + try testing.expectEqualStrings("", address.unidade); + try testing.expect(address.geo == null); + try testing.expectEqualStrings(body, address.raw); + + var buffer: [16]u8 = undefined; + try testing.expectEqualStrings("01001-000", address.formattedCep(&buffer)); +} + +test "Address.fromJson reads a geo block" { + const body = + \\{"cep":"01310100","geo":{"lat":-23.567202,"lon":-46.649659, + \\ "raio_m":1084,"precisao":"logradouro"}} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var address = try Address.fromJson(testing.allocator, parsed.value, body); + defer address.deinit(testing.allocator); + + const geo = address.geo.?; + try testing.expectApproxEqAbs(@as(f64, -23.567202), geo.lat, 1e-9); + try testing.expectApproxEqAbs(@as(f64, -46.649659), geo.lon, 1e-9); + try testing.expectEqual(@as(i64, 1084), geo.raio_m); + try testing.expect(geo.isPrecise()); +} + +test "a municipality coordinate is not precise" { + const body = + \\{"cep":"69900000","geo":{"lat":-9.9,"lon":-67.8,"raio_m":22000, + \\ "precisao":"localidade"}} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var address = try Address.fromJson(testing.allocator, parsed.value, body); + defer address.deinit(testing.allocator); + + try testing.expect(!address.geo.?.isPrecise()); +} + +test "a malformed geo block does not lose the address" { + const body = + \\{"cep":"01001000","localidade":"São Paulo","geo":{"lat":"not a number"}} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var address = try Address.fromJson(testing.allocator, parsed.value, body); + defer address.deinit(testing.allocator); + + try testing.expect(address.geo == null); + try testing.expectEqualStrings("São Paulo", address.localidade); +} + +test "an unknown field survives in raw and breaks nothing" { + const body = + \\{"cep":"01001000","localidade":"São Paulo","campo_do_futuro":"value"} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var address = try Address.fromJson(testing.allocator, parsed.value, body); + defer address.deinit(testing.allocator); + + try testing.expectEqualStrings("São Paulo", address.localidade); + try testing.expect(std.mem.indexOf(u8, address.raw, "campo_do_futuro") != null); +} + +test "a numeric field is rendered as text" { + const body = + \\{"cep":"01001000","ibge":3550308,"gia":null} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var address = try Address.fromJson(testing.allocator, parsed.value, body); + defer address.deinit(testing.allocator); + + try testing.expectEqualStrings("3550308", address.ibge); + try testing.expectEqualStrings("", address.gia); +} + +test "Address.fromCsvRecord maps by header and carries no geo" { + const header = [_][]const u8{ "cep", "logradouro", "localidade", "uf", "coluna_nova" }; + const record = [_][]const u8{ "01001000", "Praça da Sé", "São Paulo", "SP", "ignored" }; + + var address = try Address.fromCsvRecord(testing.allocator, &header, &record); + defer address.deinit(testing.allocator); + + try testing.expectEqualStrings("01001000", address.cep); + try testing.expectEqualStrings("Praça da Sé", address.logradouro); + try testing.expectEqualStrings("SP", address.uf); + try testing.expectEqualStrings("", address.siafi); + try testing.expect(address.geo == null); + try testing.expectEqualStrings("", address.raw); +} + +test "formattedCep passes a malformed CEP through" { + var address: Address = .{ .cep = "123" }; + var buffer: [16]u8 = undefined; + try testing.expectEqualStrings("123", address.formattedCep(&buffer)); +} + +test "Health.fromJson reads counts and status" { + const body = + \\{"status":"ok","dne":"26071","ceps":1596168,"geo":894489, + \\ "base_gerada_em":"2026-07-24T03:48:28Z"} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var health = try Health.fromJson(testing.allocator, parsed.value, body); + defer health.deinit(testing.allocator); + + try testing.expect(health.ok()); + try testing.expectEqualStrings("26071", health.dne); + try testing.expectEqual(@as(u64, 1_596_168), health.ceps); + try testing.expectEqual(@as(u64, 894_489), health.geo); + try testing.expectEqualStrings("2026-07-24T03:48:28Z", health.base_gerada_em); +} + +test "a degraded Health is still a Health" { + const body = + \\{"status":"degradado"} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var health = try Health.fromJson(testing.allocator, parsed.value, body); + defer health.deinit(testing.allocator); + + try testing.expect(!health.ok()); + try testing.expectEqual(@as(u64, 0), health.ceps); +} diff --git a/src/retry.zig b/src/retry.zig new file mode 100644 index 0000000..5caab09 --- /dev/null +++ b/src/retry.zig @@ -0,0 +1,152 @@ +//! Backoff arithmetic and `Retry-After` parsing. +//! +//! Everything here is pure: the clock and the randomness arrive as arguments, +//! which is what makes the policy testable without a network or a sleep. + +const std = @import("std"); + +/// A random wait in `[0, ceiling_ms]` — *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. +pub fn jitter(random: std.Random, ceiling_ms: u64) u64 { + if (ceiling_ms == 0) return 0; + return random.uintLessThan(u64, ceiling_ms + 1); +} + +/// The ceiling of the exponential wait for `attempt`, capped at `max_ms`. +/// +/// `attempt` counts from 1, so the first wait is `base_ms`, the second twice +/// that, and so on until the cap. +pub fn ceilingMs(attempt: u32, base_ms: u64, max_ms: u64) u64 { + if (base_ms == 0) return 0; + const shift: u6 = @intCast(@min(attempt -| 1, 63)); + const scaled = std.math.shlExact(u64, base_ms, shift) catch return max_ms; + return @min(scaled, max_ms); +} + +/// Parse the `Retry-After` header in both RFC 9110 forms, in milliseconds. +/// +/// It may arrive as a number of seconds (`"120"`) or as an HTTP date +/// (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the common +/// bug, and it makes a client ignore exactly the wait request that matters +/// most. +/// +/// `now_unix` is the current time in seconds since the Unix epoch, so a date +/// can be turned into a delay. A date already in the past yields 0; anything +/// unparseable yields null. +pub fn parseRetryAfter(value: []const u8, now_unix: i64) ?u64 { + const text = std.mem.trim(u8, value, " \t"); + if (text.len == 0) return null; + + if (std.fmt.parseFloat(f64, text)) |seconds| { + if (!std.math.isFinite(seconds)) return null; + if (seconds <= 0) return 0; + return @intFromFloat(@min(seconds * 1000.0, @as(f64, @floatFromInt(std.math.maxInt(u64))))); + } else |_| {} + + const target = httpDateToUnix(text) orelse return null; + if (target <= now_unix) return 0; + const delta: u64 = @intCast(target - now_unix); + return delta *| 1000; +} + +/// Seconds since the Unix epoch for an IMF-fixdate, the form HTTP servers must +/// send: `Wed, 21 Oct 2026 07:28:00 GMT`. The two obsolete formats RFC 9110 +/// still mentions are not accepted. +pub fn httpDateToUnix(value: []const u8) ?i64 { + const rest = if (std.mem.indexOf(u8, value, ", ")) |i| value[i + 2 ..] else value; + var parts = std.mem.tokenizeAny(u8, rest, " \t"); + + const day = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null; + const month = monthNumber(parts.next() orelse return null) orelse return null; + const year = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null; + + var clock = std.mem.splitScalar(u8, parts.next() orelse return null, ':'); + const hour = std.fmt.parseInt(i64, clock.next() orelse return null, 10) catch return null; + const minute = std.fmt.parseInt(i64, clock.next() orelse return null, 10) catch return null; + const second = std.fmt.parseInt(i64, clock.next() orelse return null, 10) catch return null; + + if (day < 1 or day > 31) return null; + if (hour < 0 or hour > 23) return null; + if (minute < 0 or minute > 59) return null; + if (second < 0 or second > 60) return null; + + return daysFromCivil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second; +} + +fn monthNumber(name: []const u8) ?i64 { + const months = [_][]const u8{ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + }; + for (months, 0..) |month, i| { + if (std.mem.eql(u8, month, name)) return @intCast(i + 1); + } + return null; +} + +/// Days between 1970-01-01 and the given proleptic Gregorian date. +fn daysFromCivil(year_in: i64, month: i64, day: i64) i64 { + const year = if (month <= 2) year_in - 1 else year_in; + const era = @divFloor(if (year >= 0) year else year - 399, 400); + const year_of_era = year - era * 400; + const month_shifted = @mod(month + 9, 12); + const day_of_year = @divTrunc(153 * month_shifted + 2, 5) + day - 1; + const day_of_era = year_of_era * 365 + @divTrunc(year_of_era, 4) - @divTrunc(year_of_era, 100) + day_of_year; + return era * 146_097 + day_of_era - 719_468; +} + +const testing = std.testing; + +test "reads plain seconds" { + try testing.expectEqual(@as(?u64, 7000), parseRetryAfter(" 7 ", 0)); + try testing.expectEqual(@as(?u64, 0), parseRetryAfter("0", 0)); +} + +test "negative seconds become zero" { + try testing.expectEqual(@as(?u64, 0), parseRetryAfter("-5", 0)); +} + +test "reads an HTTP date" { + try testing.expectEqual(@as(?i64, 1_792_567_680), httpDateToUnix("Wed, 21 Oct 2026 07:28:00 GMT")); +} + +test "a date in the past is zero" { + try testing.expectEqual(@as(?u64, 0), parseRetryAfter("Thu, 01 Jan 1970 00:00:00 GMT", 1_700_000_000)); +} + +test "a date in the future is a delay" { + const epoch = httpDateToUnix("Wed, 21 Oct 2026 07:28:00 GMT").?; + try testing.expectEqual(@as(?u64, 60_000), parseRetryAfter("Wed, 21 Oct 2026 07:28:00 GMT", epoch - 60)); +} + +test "rejects nonsense" { + try testing.expectEqual(@as(?u64, null), parseRetryAfter("soon", 0)); + try testing.expectEqual(@as(?u64, null), parseRetryAfter("", 0)); + try testing.expectEqual(@as(?u64, null), parseRetryAfter(" ", 0)); + try testing.expectEqual(@as(?u64, null), parseRetryAfter("Mon, 99 Xyz 2026 07:28:00 GMT", 0)); +} + +test "civil days match known dates" { + try testing.expectEqual(@as(i64, 0), daysFromCivil(1970, 1, 1)); + try testing.expectEqual(@as(i64, 11_017), daysFromCivil(2000, 3, 1)); + try testing.expectEqual(@as(i64, -719_468), daysFromCivil(0, 3, 1)); +} + +test "the ceiling doubles and then caps" { + try testing.expectEqual(@as(u64, 500), ceilingMs(1, 500, 8000)); + try testing.expectEqual(@as(u64, 1000), ceilingMs(2, 500, 8000)); + try testing.expectEqual(@as(u64, 2000), ceilingMs(3, 500, 8000)); + try testing.expectEqual(@as(u64, 8000), ceilingMs(9, 500, 8000)); + try testing.expectEqual(@as(u64, 8000), ceilingMs(999, 500, 8000)); +} + +test "jitter stays within the ceiling" { + var prng: std.Random.DefaultPrng = .init(1234); + const random = prng.random(); + for (0..1000) |_| { + try testing.expect(jitter(random, 50) <= 50); + } + try testing.expectEqual(@as(u64, 0), jitter(random, 0)); +} diff --git a/src/root.zig b/src/root.zig new file mode 100644 index 0000000..0a83c5c --- /dev/null +++ b/src/root.zig @@ -0,0 +1,90 @@ +//! Zig client for the public CEP API at [cep.is](https://cep.is). Official +//! Correios data (DNE), refreshed every two weeks. +//! +//! Zig has no ambient allocator and, since 0.16, no ambient I/O either, so +//! there is no module-level `lookup`: everything goes through a `Client` you +//! build with both. That is one extra line and it is the honest one. +//! +//! ```zig +//! const std = @import("std"); +//! const cepis = @import("cepis"); +//! +//! pub fn main() !void { +//! var debug: std.heap.DebugAllocator(.{}) = .init; +//! defer _ = debug.deinit(); +//! const gpa = debug.allocator(); +//! +//! 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); +//! +//! std.debug.print("{s} {s}\n", .{ address.localidade, address.uf }); +//! } +//! ``` +//! +//! Pointing at a local instance (the API repo brings one up with `just up`) +//! is a matter of `base_url`: +//! +//! ```zig +//! var client: cepis.Client = try .init(gpa, io, .{ .base_url = "http://localhost:8080" }); +//! ``` +//! +//! Errors are one set, split by decision — whether retrying helps or not: +//! +//! ```zig +//! var diag: cepis.Diagnostics = .{}; +//! const address = client.lookup(cep_from_form, .{ .diagnostics = &diag }) catch |err| switch (err) { +//! error.NotFound => return askForAnotherCep(), +//! else => if (cepis.isRetriable(err)) return reschedule() else return err, +//! }; +//! ``` +//! +//! Note on field names: the API surface is English, but the **data fields keep +//! the API's own Portuguese names** (`logradouro`, `bairro`, `localidade`). +//! What you read in the JSON is what you access on the value. +//! +//! Every value this library returns owns its memory and has a `deinit` taking +//! the allocator the `Client` was built with. Each type documents exactly what +//! it owns. + +const client = @import("client.zig"); +const errors = @import("errors.zig"); +const model = @import("model.zig"); + +pub const Client = client.Client; +pub const Config = client.Config; +pub const CallOptions = client.CallOptions; +pub const InitError = client.InitError; +pub const normalizeCep = client.normalizeCep; +pub const freeAddresses = client.freeAddresses; + +pub const default_url = client.default_url; +pub const default_user_agent = client.default_user_agent; +pub const max_batch = client.max_batch; +pub const retriable_status = client.retriable_status; + +/// The version of this library, as sent in the `User-Agent`. +pub const version = client.version; + +pub const Error = errors.Error; +pub const CepisError = errors.CepisError; +pub const Diagnostics = errors.Diagnostics; +pub const isRetriable = errors.isRetriable; + +pub const Address = model.Address; +pub const Geo = model.Geo; +pub const Health = model.Health; + +test { + @import("std").testing.refAllDecls(@This()); + _ = @import("csv.zig"); + _ = @import("retry.zig"); + _ = client; + _ = errors; + _ = model; +} |