summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:48:43 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:48:43 -0300
commit87166a5299aa551517a86379a0c05eb2110b52b0 (patch)
tree3e77e7dd89cca736a22a5f2219451aa57dd99068
downloadzig-0.1.0.tar.gz
Initial importv0.1.0
-rw-r--r--.gitignore5
-rw-r--r--LICENSE15
-rw-r--r--Makefile55
-rw-r--r--README.md251
-rw-r--r--build.zig51
-rw-r--r--build.zig.zon15
-rw-r--r--src/client.zig804
-rw-r--r--src/csv.zig207
-rw-r--r--src/errors.zig176
-rw-r--r--src/main.zig118
-rw-r--r--src/model.zig543
-rw-r--r--src/retry.zig152
-rw-r--r--src/root.zig90
-rw-r--r--test/api.zig898
14 files changed, 3380 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1af7340
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+zig-out/
+.zig-cache/
+zig-cache/
+*.o
+*.a
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..23f9fa3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,55 @@
+# Development tasks. Everything runs through `zig`, so there is nothing to
+# install first.
+#
+# make list the targets
+# make test run the suite
+# make lint formatting check + full type check, no files touched
+# make build release build of the library and the CLI
+
+ZIG ?= zig
+
+# Passed to `zig build` for the targets that produce binaries.
+OPTIMIZE ?= ReleaseSafe
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build check clean docs run
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the unit and integration tests'
+ @echo ' lint zig fmt --check + zig build check (read-only)'
+ @echo ' format apply zig fmt (writes)'
+ @echo ' build build the library and the cepis CLI (OPTIMIZE=$(OPTIMIZE))'
+ @echo ' check lint + test + build, in that order'
+ @echo ' docs generate the API documentation into zig-out/docs'
+ @echo ' run build and run the CLI: make run ARGS="01001000"'
+ @echo ' clean remove build artefacts and the local cache'
+
+test:
+ $(ZIG) build test --summary all
+
+# Read-only by design, so it is safe in CI and in a pre-commit hook: it reports
+# what `make format` would change without changing anything, and type-checks
+# every module including the tests.
+lint:
+ $(ZIG) fmt --check build.zig src test
+ $(ZIG) build check
+
+format:
+ $(ZIG) fmt build.zig src test
+
+build:
+ $(ZIG) build -Doptimize=$(OPTIMIZE)
+
+docs:
+ $(ZIG) build-lib src/root.zig -femit-docs=zig-out/docs -fno-emit-bin
+
+run:
+ $(ZIG) build run -- $(ARGS)
+
+# The order matters: lint is the fastest to fail, build the slowest, so a broken
+# run reports the cheapest problem first.
+check: lint test build
+
+clean:
+ rm -rf zig-out .zig-cache
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..26e957d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,251 @@
+# cepis
+
+Zig client for the [public CEP API at cep.is](https://cep.is). Official Correios
+data (DNE), refreshed every two weeks.
+
+```zig
+var client: cepis.Client = try .init(gpa, io, .{});
+defer client.deinit();
+
+var address = try client.lookup("01001-000", .{});
+defer address.deinit(gpa);
+
+std.debug.print("{s} {s} {s}\n", .{ address.logradouro, address.localidade, address.uf });
+// Praça da Sé São Paulo SP
+```
+
+## Installation
+
+```sh
+zig fetch --save git+https://git.cep.is/zig.git
+```
+
+Then, in `build.zig`:
+
+```zig
+const cepis = b.dependency("cepis", .{ .target = target, .optimize = optimize });
+exe.root_module.addImport("cepis", cepis.module("cepis"));
+```
+
+Requires Zig 0.16.0. No dependencies: HTTP, TLS, JSON and the CSV reader all
+come from the standard library or from this package.
+
+## Usage
+
+Everything goes through a `Client`, built with an allocator and an `std.Io`.
+Zig has no ambient allocator and, since 0.16, no ambient I/O, so there is no
+module-level `lookup` — one extra line, and it is the honest one:
+
+```zig
+const std = @import("std");
+const cepis = @import("cepis");
+
+pub fn main(init: std.process.Init) !void {
+ var client: cepis.Client = try .init(init.gpa, init.io, .{});
+ defer client.deinit();
+
+ var address = try client.lookup("01310-100", .{});
+ defer address.deinit(init.gpa);
+}
+```
+
+### A single lookup
+
+`lookup` accepts the CEP masked or unmasked and returns an `Address`:
+
+```zig
+var a = try client.lookup("01310-100", .{});
+defer a.deinit(gpa);
+
+a.logradouro; // "Avenida Paulista"
+a.localidade; // "São Paulo"
+a.uf; // "SP"
+a.ibge; // "3550308"
+a.cep; // "01310100" (always unmasked)
+
+var buffer: [16]u8 = undefined;
+a.formattedCep(&buffer); // "01310-100"
+```
+
+Fields the database does not have for that CEP come back as empty strings.
+`a.raw` holds the response body exactly as it arrived, so a field the API grows
+before this package is updated is not lost.
+
+Every returned value owns its memory and has a `deinit` taking the allocator the
+client was built with. `client.gpa` is that allocator, if it is easier to reach.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is null:
+
+```zig
+if (a.geo) |geo| {
+ geo.lat; // -23.567202
+ geo.lon; // -46.649659
+ geo.raio_m; // 1084
+ geo.precisao; // "logradouro"
+}
+```
+
+**Check `precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`geo.isPrecise()` is the shorthand for `precisao == "logradouro"`:
+
+```zig
+const cost = if (a.geo != null and a.geo.?.isPrecise())
+ shippingFrom(a.geo.?.lat, a.geo.?.lon)
+else
+ shippingFromMunicipality(a.ibge);
+```
+
+### Many CEPs at once
+
+Do not loop over `lookup`. Use `lookupMany`, which resolves up to 500 CEPs per
+request:
+
+```zig
+const ceps = [_][]const u8{ "01001000", "99999999", "20040020" };
+
+const found = try client.lookupMany(&ceps, .{});
+defer cepis.freeAddresses(gpa, found);
+
+for (ceps, found) |requested, maybe_address| {
+ if (maybe_address) |a| {
+ std.debug.print("{s}: {s}\n", .{ requested, a.localidade });
+ } else {
+ std.debug.print("{s}: not found\n", .{requested});
+ }
+}
+```
+
+The returned slice has **the same length and the same order** as the input. A
+CEP that does not exist, or is malformed, becomes null at its position — it does
+not vanish, and it is not an error.
+
+Inputs longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV
+> has no `geo` column. Every `Address` from `lookupMany` has `geo == null`,
+> including for CEPs that do have one. Need `geo`? Use `lookup`, one at a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version of
+the database** is live:
+
+```zig
+var h = try client.health(.{});
+defer h.deinit(gpa);
+
+h.ok(); // true
+h.dne; // "26071" identifier of the fortnightly database
+h.ceps; // 1596168
+h.base_gerada_em; // "2026-07-24T03:48:28Z"
+```
+
+An API reporting itself degraded is not an error — that arrives in `h.status`.
+
+## Configuration
+
+Everything beyond the defaults goes in the `Config` passed to `init`. Build one
+client and reuse it: it keeps no state between calls, pools connections, and is
+safe to share across threads.
+
+```zig
+var client: cepis.Client = try .init(gpa, io, .{
+ .base_url = "https://cep.is", // point at another instance
+ .timeout_ms = 10_000, // connect and TLS handshake
+ .max_attempts = 3, // counting the first; 1 disables retrying
+ .backoff_base_ms = 500, // base of the exponential wait
+ .backoff_max_ms = 8_000, // cap on each wait
+ .total_timeout_ms = 30_000, // cap on the whole call
+ .user_agent = "my-app/1.0",
+});
+defer client.deinit();
+```
+
+Retries cover 429, 5xx and network failures, with exponential backoff and
+jitter; 400 and 404 are never retried.
+
+`timeout_ms` bounds the connection handshake. Zig 0.16's `std.http.Client`
+exposes no per-read timeout, so a server that accepts and then stalls is bounded
+only by `total_timeout_ms` — set that one in code serving a web request.
+
+### Pointing at a local instance
+
+`base_url` is the only setting that usually changes between environments. The
+API repo brings a local instance up with `just up`:
+
+```zig
+var client: cepis.Client = try .init(gpa, io, .{ .base_url = "http://localhost:8080" });
+```
+
+From the command line, to confirm quickly that it answers:
+
+```sh
+make run ARGS="--base-url http://localhost:8080 01001000"
+```
+
+## Errors
+
+Everything fails with one error set. The tags split by **the decision you have
+to make**, not by the HTTP status that caused it:
+
+| error | when | does retrying help? |
+| --- | --- | --- |
+| `error.InvalidCep` | not 8 digits | no |
+| `error.NotFound` | well-formed, not in the database | no |
+| `error.RateLimited` | 429; `retry_after_ms` in the diagnostics | yes, later |
+| `error.ServerError` | 5xx; `status` in the diagnostics | yes, later |
+| `error.NetworkError` | DNS, connection, timeout, TLS | yes, later |
+| `error.InvalidResponse` | unintelligible response | probably not |
+| `error.OutOfMemory` | allocation failed | no |
+
+A Zig error carries no payload, so the detail travels in an optional
+`Diagnostics` you pass in. It allocates nothing and needs no `deinit`:
+
+```zig
+var diag: cepis.Diagnostics = .{};
+
+const address = client.lookup(cep_from_form, .{ .diagnostics = &diag }) catch |err| switch (err) {
+ error.NotFound => return askForAnotherCep(),
+ error.RateLimited => return rescheduleIn(diag.retry_after_ms orelse 60_000),
+ else => {
+ if (cepis.isRetriable(err)) return reschedule();
+ std.log.err("{t}: {s}", .{ err, diag.message() });
+ return err;
+ },
+};
+```
+
+`error.InvalidResponse` almost always 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.
+
+## Development
+
+Everything runs through `make`, which shells out to `zig`:
+
+```sh
+make # list the targets
+make test # unit and integration tests
+make lint # zig fmt --check + zig build check, touches nothing
+make format # apply zig fmt (writes)
+make build # release build of the library and the CLI
+make check # lint, then test, then build
+make docs # API documentation into zig-out/docs
+```
+
+The integration tests stand up a real HTTP server on loopback and point the
+client at it via `base_url`. They allocate through `std.testing.allocator`, so a
+leak on either side fails the test.
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/build.zig b/build.zig
new file mode 100644
index 0000000..c4b58ec
--- /dev/null
+++ b/build.zig
@@ -0,0 +1,51 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const mod = b.addModule("cepis", .{
+ .root_source_file = b.path("src/root.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+
+ const exe = b.addExecutable(.{
+ .name = "cepis",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{.{ .name = "cepis", .module = mod }},
+ }),
+ });
+ b.installArtifact(exe);
+
+ const run_cmd = b.addRunArtifact(exe);
+ run_cmd.step.dependOn(b.getInstallStep());
+ if (b.args) |args| run_cmd.addArgs(args);
+ const run_step = b.step("run", "Run the cepis CLI");
+ run_step.dependOn(&run_cmd.step);
+
+ const unit_tests = b.addTest(.{ .root_module = mod });
+ const run_unit_tests = b.addRunArtifact(unit_tests);
+
+ const api_tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("test/api.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{.{ .name = "cepis", .module = mod }},
+ }),
+ });
+ const run_api_tests = b.addRunArtifact(api_tests);
+
+ const test_step = b.step("test", "Run the unit and integration tests");
+ test_step.dependOn(&run_unit_tests.step);
+ test_step.dependOn(&run_api_tests.step);
+
+ const check_step = b.step("check", "Type-check everything without emitting binaries");
+ check_step.dependOn(&unit_tests.step);
+ check_step.dependOn(&api_tests.step);
+ check_step.dependOn(&exe.step);
+}
diff --git a/build.zig.zon b/build.zig.zon
new file mode 100644
index 0000000..3d0c3e3
--- /dev/null
+++ b/build.zig.zon
@@ -0,0 +1,15 @@
+.{
+ .name = .cepis,
+ .version = "0.1.0",
+ .fingerprint = 0x3d8a6589b96b9a53, // Changing this has security and trust implications.
+ .minimum_zig_version = "0.16.0",
+ .dependencies = .{},
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "src",
+ "test",
+ "LICENSE",
+ "README.md",
+ },
+}
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;
+}
diff --git a/test/api.zig b/test/api.zig
new file mode 100644
index 0000000..fcdb956
--- /dev/null
+++ b/test/api.zig
@@ -0,0 +1,898 @@
+//! Integration tests.
+//!
+//! These bring up a real HTTP server on loopback and point the client at it via
+//! `base_url`. No test touches the network and no test fakes the HTTP layer:
+//! the code path exercised is the one that runs in production, request
+//! construction and error body reading included.
+//!
+//! It doubles as the test that `base_url` works — if it did not, none of this
+//! would run.
+//!
+//! Every test allocates through `std.testing.allocator`, including the server,
+//! so a leak anywhere on either side fails the test rather than passing
+//! quietly.
+//!
+//! The fixture field names stay Portuguese because they mirror the API's JSON.
+
+const std = @import("std");
+const cepis = @import("cepis");
+
+const net = std.Io.net;
+const testing = std.testing;
+
+const se_address =
+ \\{"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 csv_header =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi";
+
+/// One canned response: status, body and extra headers.
+const Reply = struct {
+ status: u16 = 200,
+ body: []const u8 = "",
+ headers: []const std.http.Header = &.{},
+};
+
+const Route = struct {
+ prefix: []const u8,
+ replies: []const Reply,
+ served: usize = 0,
+};
+
+/// A throwaway HTTP server on a random loopback port.
+///
+/// Routes are matched by longest prefix, so `/csv` catches `/csv?ceps=...`. A
+/// route may hold several replies: each request consumes the next one, which is
+/// how retrying is tested, and the last one repeats once the list runs out.
+///
+/// Connections are answered one request at a time and then closed, so a client
+/// that retries always finds the accept loop ready for it.
+const Server = struct {
+ gpa: std.mem.Allocator,
+ io: std.Io,
+ listener: net.Server,
+ address: net.IpAddress,
+ url: []u8,
+ thread: std.Thread,
+
+ mutex: std.Io.Mutex = .init,
+ routes: std.ArrayList(Route) = .empty,
+ calls: std.ArrayList([]u8) = .empty,
+ stopping: bool = false,
+
+ fn start(gpa: std.mem.Allocator, io: std.Io) !*Server {
+ const self = try gpa.create(Server);
+ errdefer gpa.destroy(self);
+
+ var wildcard: net.IpAddress = .{ .ip4 = .loopback(0) };
+ var listener = try wildcard.listen(io, .{ .reuse_address = true });
+ errdefer listener.deinit(io);
+
+ const address = listener.socket.address;
+ const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}", .{address.getPort()});
+ errdefer gpa.free(url);
+
+ self.* = .{
+ .gpa = gpa,
+ .io = io,
+ .listener = listener,
+ .address = address,
+ .url = url,
+ .thread = undefined,
+ };
+ self.thread = try std.Thread.spawn(.{}, run, .{self});
+ return self;
+ }
+
+ fn deinit(self: *Server) void {
+ {
+ self.mutex.lockUncancelable(self.io);
+ defer self.mutex.unlock(self.io);
+ self.stopping = true;
+ }
+ self.wake();
+ self.thread.join();
+ self.listener.deinit(self.io);
+
+ const gpa = self.gpa;
+ for (self.calls.items) |call| gpa.free(call);
+ self.calls.deinit(gpa);
+ self.routes.deinit(gpa);
+ gpa.free(self.url);
+ gpa.destroy(self);
+ }
+
+ /// Unblock the accept loop so the thread can notice `stopping`.
+ fn wake(self: *Server) void {
+ const stream = self.address.connect(self.io, .{ .mode = .stream }) catch return;
+ stream.close(self.io);
+ }
+
+ fn respond(self: *Server, prefix: []const u8, replies: []const Reply) !void {
+ self.mutex.lockUncancelable(self.io);
+ defer self.mutex.unlock(self.io);
+ try self.routes.append(self.gpa, .{ .prefix = prefix, .replies = replies });
+ }
+
+ /// A client pointed at this server, with the backoff shortened so a test
+ /// that exhausts its attempts finishes in milliseconds.
+ fn client(self: *Server, config: cepis.Config) !cepis.Client {
+ const defaults: cepis.Config = .{};
+ var full = config;
+ full.base_url = self.url;
+ if (full.backoff_base_ms == defaults.backoff_base_ms) full.backoff_base_ms = 1;
+ if (full.backoff_max_ms == defaults.backoff_max_ms) full.backoff_max_ms = 10;
+ if (full.timeout_ms == defaults.timeout_ms) full.timeout_ms = 5_000;
+ return cepis.Client.init(self.gpa, self.io, full);
+ }
+
+ fn callCount(self: *Server) usize {
+ self.mutex.lockUncancelable(self.io);
+ defer self.mutex.unlock(self.io);
+ return self.calls.items.len;
+ }
+
+ fn countPrefix(self: *Server, prefix: []const u8) usize {
+ self.mutex.lockUncancelable(self.io);
+ defer self.mutex.unlock(self.io);
+ var n: usize = 0;
+ for (self.calls.items) |call| {
+ if (std.mem.startsWith(u8, call, prefix)) n += 1;
+ }
+ return n;
+ }
+
+ fn lastCall(self: *Server) ?[]const u8 {
+ self.mutex.lockUncancelable(self.io);
+ defer self.mutex.unlock(self.io);
+ if (self.calls.items.len == 0) return null;
+ return self.calls.items[self.calls.items.len - 1];
+ }
+
+ fn run(self: *Server) void {
+ while (true) {
+ const stream = self.listener.accept(self.io) catch break;
+ defer stream.close(self.io);
+ {
+ self.mutex.lockUncancelable(self.io);
+ const stop = self.stopping;
+ self.mutex.unlock(self.io);
+ if (stop) break;
+ }
+ self.handle(stream) catch {};
+ }
+ }
+
+ fn handle(self: *Server, stream: net.Stream) !void {
+ var read_buffer: [16 * 1024]u8 = undefined;
+ var write_buffer: [16 * 1024]u8 = undefined;
+ var reader = stream.reader(self.io, &read_buffer);
+ var writer = stream.writer(self.io, &write_buffer);
+
+ var http_server = std.http.Server.init(&reader.interface, &writer.interface);
+ var request = try http_server.receiveHead();
+
+ const reply = try self.record(request.head.target);
+ try request.respond(reply.body, .{
+ .status = @enumFromInt(reply.status),
+ .extra_headers = reply.headers,
+ .keep_alive = false,
+ });
+ }
+
+ /// Log the call and pick the reply for it, under the lock.
+ fn record(self: *Server, target: []const u8) !Reply {
+ self.mutex.lockUncancelable(self.io);
+ defer self.mutex.unlock(self.io);
+
+ const call = try self.gpa.dupe(u8, target);
+ errdefer self.gpa.free(call);
+ try self.calls.append(self.gpa, call);
+
+ var best: ?*Route = null;
+ for (self.routes.items) |*route| {
+ if (!std.mem.startsWith(u8, target, route.prefix)) continue;
+ if (best == null or route.prefix.len > best.?.prefix.len) best = route;
+ }
+ const route = best orelse return .{
+ .status = 404,
+ .body =
+ \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}
+ ,
+ };
+ const index = @min(route.served, route.replies.len - 1);
+ route.served += 1;
+ return route.replies[index];
+ }
+};
+
+/// Everything a test needs: an `Io` and a server on loopback, both allocating
+/// through `std.testing.allocator`.
+const Fixture = struct {
+ threaded: std.Io.Threaded,
+ server: *Server,
+
+ fn init(self: *Fixture) !void {
+ self.threaded = .init(testing.allocator, .{});
+ errdefer self.threaded.deinit();
+ self.server = try Server.start(testing.allocator, self.threaded.io());
+ }
+
+ fn deinit(self: *Fixture) void {
+ self.server.deinit();
+ self.threaded.deinit();
+ }
+};
+
+/// Milliseconds since `started`, on the monotonic clock.
+fn elapsedMs(io: std.Io, started: std.Io.Timestamp) u64 {
+ const now = std.Io.Timestamp.now(io, .awake);
+ const ns = started.durationTo(now).nanoseconds;
+ if (ns <= 0) return 0;
+ return @intCast(@divFloor(ns, std.time.ns_per_ms));
+}
+
+fn csvBody(gpa: std.mem.Allocator, rows: []const []const u8) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ try out.appendSlice(gpa, csv_header);
+ for (rows) |row| {
+ try out.append(gpa, '\n');
+ try out.appendSlice(gpa, row);
+ }
+ try out.append(gpa, '\n');
+ return out.toOwnedSlice(gpa);
+}
+
+test "a successful lookup" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{ .body = se_address }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var address = try client.lookup("01001-000", .{});
+ defer address.deinit(testing.allocator);
+
+ try testing.expectEqualStrings("São Paulo", address.localidade);
+ try testing.expectEqualStrings("SP", address.uf);
+ try testing.expectEqualStrings("Praça da Sé", address.logradouro);
+ try testing.expect(address.geo == null);
+
+ var buffer: [16]u8 = undefined;
+ try testing.expectEqualStrings("01001-000", address.formattedCep(&buffer));
+}
+
+test "the coordinate is read when present" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01310100", &.{.{
+ .body =
+ \\{"cep":"01310100","logradouro":"Avenida Paulista","localidade":"São Paulo",
+ \\"uf":"SP","geo":{"lat":-23.5,"lon":-46.6,"raio_m":120,"precisao":"logradouro"}}
+ ,
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var address = try client.lookup("01310100", .{});
+ defer address.deinit(testing.allocator);
+
+ const geo = address.geo.?;
+ try testing.expectApproxEqAbs(@as(f64, -23.5), geo.lat, 1e-9);
+ try testing.expectApproxEqAbs(@as(f64, -46.6), geo.lon, 1e-9);
+ try testing.expectEqual(@as(i64, 120), geo.raio_m);
+ try testing.expect(geo.isPrecise());
+}
+
+test "a municipality coordinate is not precise" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/69900000", &.{.{
+ .body =
+ \\{"cep":"69900000","localidade":"Rio Branco","uf":"AC",
+ \\"geo":{"lat":-9.9,"lon":-67.8,"raio_m":22000,"precisao":"localidade"}}
+ ,
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var address = try client.lookup("69900000", .{});
+ defer address.deinit(testing.allocator);
+
+ try testing.expect(!address.geo.?.isPrecise());
+}
+
+test "a malformed geo block does not lose the address" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{
+ .body =
+ \\{"cep":"01001000","localidade":"São Paulo","geo":{"lat":"not a number"}}
+ ,
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var address = try client.lookup("01001000", .{});
+ defer address.deinit(testing.allocator);
+
+ try testing.expect(address.geo == null);
+ try testing.expectEqualStrings("São Paulo", address.localidade);
+}
+
+test "a new API field does not break the client" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{
+ .body =
+ \\{"cep":"01001000","localidade":"São Paulo","campo_do_futuro":"value"}
+ ,
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var address = try client.lookup("01001000", .{});
+ 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 missing CEP is not found" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/99999999", &.{.{
+ .status = 404,
+ .body =
+ \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}
+ ,
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var diag: cepis.Diagnostics = .{};
+ try testing.expectError(error.NotFound, client.lookup("99999999", .{ .diagnostics = &diag }));
+ try testing.expect(!cepis.isRetriable(error.NotFound));
+ try testing.expect(std.mem.indexOf(u8, diag.message(), "99999999") != null);
+}
+
+test "a 404 is not retried" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/99999999", &.{.{ .status = 404, .body = "{}" }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ try testing.expectError(error.NotFound, client.lookup("99999999", .{}));
+ try testing.expectEqual(@as(usize, 1), fixture.server.countPrefix("/99999999"));
+}
+
+test "an invalid CEP never leaves the machine" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var diag: cepis.Diagnostics = .{};
+ try testing.expectError(error.InvalidCep, client.lookup("123", .{ .diagnostics = &diag }));
+ try testing.expectEqual(@as(usize, 0), fixture.server.callCount());
+ try testing.expect(std.mem.indexOf(u8, diag.message(), "8 digits") != null);
+}
+
+test "a 400 from the API becomes InvalidCep with its own wording" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/00000000", &.{.{
+ .status = 400,
+ .body =
+ \\{"erro":"cep_invalido","mensagem":"rejected by the API"}
+ ,
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var diag: cepis.Diagnostics = .{};
+ try testing.expectError(error.InvalidCep, client.lookup("00000000", .{ .diagnostics = &diag }));
+ try testing.expectEqualStrings("rejected by the API", diag.message());
+ try testing.expectEqual(@as(usize, 1), fixture.server.countPrefix("/00000000"));
+}
+
+test "a response that is not JSON is an invalid response" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{ .body = "<html>captive portal</html>" }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var diag: cepis.Diagnostics = .{};
+ try testing.expectError(
+ error.InvalidResponse,
+ client.lookup("01001000", .{ .diagnostics = &diag }),
+ );
+ try testing.expect(std.mem.indexOf(u8, diag.message(), "captive portal") != null);
+}
+
+test "a 500 is retried and then succeeds" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{
+ .{ .status = 500, .body = "{\"erro\":\"interno\"}" },
+ .{ .body = se_address },
+ });
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var address = try client.lookup("01001000", .{});
+ defer address.deinit(testing.allocator);
+
+ try testing.expectEqualStrings("São Paulo", address.localidade);
+ try testing.expectEqual(@as(usize, 2), fixture.server.countPrefix("/01001000"));
+}
+
+test "the attempts are exhausted and then it fails" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{ .status = 503, .body = "{\"erro\":\"interno\"}" }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var diag: cepis.Diagnostics = .{};
+ try testing.expectError(error.ServerError, client.lookup("01001000", .{ .diagnostics = &diag }));
+ try testing.expectEqual(@as(?u16, 503), diag.status);
+ try testing.expectEqual(@as(u32, 3), diag.attempts);
+ try testing.expect(cepis.isRetriable(error.ServerError));
+ try testing.expectEqual(@as(usize, 3), fixture.server.countPrefix("/01001000"));
+}
+
+test "max_attempts of one disables retrying" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{ .status = 500, .body = "{}" }});
+
+ var client = try fixture.server.client(.{ .max_attempts = 1 });
+ defer client.deinit();
+
+ try testing.expectError(error.ServerError, client.lookup("01001000", .{}));
+ try testing.expectEqual(@as(usize, 1), fixture.server.countPrefix("/01001000"));
+}
+
+test "a 429 reports the Retry-After it was given" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{
+ .status = 429,
+ .body = "{}",
+ .headers = &.{.{ .name = "retry-after", .value = "7" }},
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var diag: cepis.Diagnostics = .{};
+ try testing.expectError(error.RateLimited, client.lookup("01001000", .{ .diagnostics = &diag }));
+ try testing.expectEqual(@as(?u64, 7000), diag.retry_after_ms);
+}
+
+test "a huge Retry-After is capped so the caller is not parked" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{
+ .status = 429,
+ .body = "{}",
+ .headers = &.{.{ .name = "Retry-After", .value = "3600" }},
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const started = std.Io.Timestamp.now(fixture.threaded.io(), .awake);
+ var diag: cepis.Diagnostics = .{};
+ try testing.expectError(error.RateLimited, client.lookup("01001000", .{ .diagnostics = &diag }));
+
+ try testing.expectEqual(@as(?u64, 3_600_000), diag.retry_after_ms);
+ try testing.expect(elapsedMs(fixture.threaded.io(), started) < 5_000);
+}
+
+test "a Retry-After as an HTTP date is understood" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{
+ .status = 429,
+ .body = "{}",
+ .headers = &.{.{ .name = "retry-after", .value = "Wed, 21 Oct 2099 07:28:00 GMT" }},
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const started = std.Io.Timestamp.now(fixture.threaded.io(), .awake);
+ var diag: cepis.Diagnostics = .{};
+ try testing.expectError(error.RateLimited, client.lookup("01001000", .{ .diagnostics = &diag }));
+
+ try testing.expect(diag.retry_after_ms.? > 86_400_000);
+ try testing.expect(elapsedMs(fixture.threaded.io(), started) < 5_000);
+}
+
+test "nothing listening is a network error" {
+ var threaded: std.Io.Threaded = .init(testing.allocator, .{});
+ defer threaded.deinit();
+
+ var client = try cepis.Client.init(testing.allocator, threaded.io(), .{
+ .base_url = "http://127.0.0.1:1",
+ .max_attempts = 2,
+ .backoff_base_ms = 1,
+ .backoff_max_ms = 5,
+ .timeout_ms = 1000,
+ });
+ defer client.deinit();
+
+ var diag: cepis.Diagnostics = .{};
+ try testing.expectError(error.NetworkError, client.lookup("01001000", .{ .diagnostics = &diag }));
+ try testing.expect(cepis.isRetriable(error.NetworkError));
+ try testing.expectEqual(@as(u32, 2), diag.attempts);
+}
+
+test "total_timeout bounds the whole call" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/01001000", &.{.{ .status = 500, .body = "{}" }});
+
+ var client = try fixture.server.client(.{
+ .max_attempts = 50,
+ .backoff_base_ms = 50,
+ .backoff_max_ms = 50,
+ .total_timeout_ms = 300,
+ });
+ defer client.deinit();
+
+ const started = std.Io.Timestamp.now(fixture.threaded.io(), .awake);
+ try testing.expectError(error.ServerError, client.lookup("01001000", .{}));
+
+ try testing.expect(elapsedMs(fixture.threaded.io(), started) < 5_000);
+ try testing.expect(fixture.server.countPrefix("/01001000") < 50);
+}
+
+test "the batch keeps order and marks the missing one" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ const body = try csvBody(testing.allocator, &.{
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001",
+ });
+ defer testing.allocator.free(body);
+ try fixture.server.respond("/csv", &.{.{ .body = body }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const result = try client.lookupMany(&.{ "01001000", "99999999", "20040020" }, .{});
+ defer cepis.freeAddresses(testing.allocator, result);
+
+ try testing.expectEqual(@as(usize, 3), result.len);
+ try testing.expectEqualStrings("São Paulo", result[0].?.localidade);
+ try testing.expect(result[1] == null);
+ try testing.expectEqualStrings("RJ", result[2].?.uf);
+ try testing.expectEqualStrings("Avenida Rio Branco", result[2].?.logradouro);
+}
+
+test "the batch carries no coordinate" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ const body = try csvBody(testing.allocator, &.{
+ "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107",
+ });
+ defer testing.allocator.free(body);
+ try fixture.server.respond("/csv", &.{.{ .body = body }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const result = try client.lookupMany(&.{"01001000"}, .{});
+ defer cepis.freeAddresses(testing.allocator, result);
+
+ try testing.expect(result[0].?.geo == null);
+ try testing.expectEqualStrings("", result[0].?.raw);
+}
+
+test "the batch parses a field containing a comma" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ const body = try csvBody(testing.allocator, &.{
+ "01001000,\"Rua Um, Dois\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ });
+ defer testing.allocator.free(body);
+ try fixture.server.respond("/csv", &.{.{ .body = body }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const result = try client.lookupMany(&.{"01001000"}, .{});
+ defer cepis.freeAddresses(testing.allocator, result);
+
+ try testing.expectEqualStrings("Rua Um, Dois", result[0].?.logradouro);
+}
+
+test "a malformed CEP in a batch is null, not an error" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ const body = try csvBody(testing.allocator, &.{
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ "nope,,,,,,,,,,,,",
+ });
+ defer testing.allocator.free(body);
+ try fixture.server.respond("/csv", &.{.{ .body = body }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const result = try client.lookupMany(&.{ "01001000", "no pe&" }, .{});
+ defer cepis.freeAddresses(testing.allocator, result);
+
+ try testing.expect(result[0] != null);
+ try testing.expect(result[1] == null);
+ try testing.expect(std.mem.indexOf(u8, fixture.server.lastCall().?, "no%20pe%26") != null);
+}
+
+test "an empty batch makes no request" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const result = try client.lookupMany(&.{}, .{});
+ defer cepis.freeAddresses(testing.allocator, result);
+
+ try testing.expectEqual(@as(usize, 0), result.len);
+ try testing.expectEqual(@as(usize, 0), fixture.server.callCount());
+}
+
+test "the batch chunks above the API limit" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ const gpa = testing.allocator;
+ const total = cepis.max_batch + 1;
+
+ var ceps: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (ceps.items) |cep| gpa.free(cep);
+ ceps.deinit(gpa);
+ }
+ var rows: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (rows.items) |row| gpa.free(row);
+ rows.deinit(gpa);
+ }
+ for (0..total) |i| {
+ try ceps.append(gpa, try std.fmt.allocPrint(gpa, "{d:0>8}", .{i}));
+ try rows.append(gpa, try std.fmt.allocPrint(
+ gpa,
+ "{d:0>8},,,,,X,SP,São Paulo,Sudeste,1,,11,1",
+ .{i},
+ ));
+ }
+
+ const first = try csvBody(gpa, rows.items[0..cepis.max_batch]);
+ defer gpa.free(first);
+ const second = try csvBody(gpa, rows.items[cepis.max_batch..]);
+ defer gpa.free(second);
+
+ try fixture.server.respond("/csv", &.{ .{ .body = first }, .{ .body = second } });
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const result = try client.lookupMany(ceps.items, .{});
+ defer cepis.freeAddresses(gpa, result);
+
+ try testing.expectEqual(total, result.len);
+ try testing.expectEqual(@as(usize, 2), fixture.server.countPrefix("/csv"));
+ for (result) |slot| try testing.expect(slot != null);
+ try testing.expectEqualStrings("00000500", result[cepis.max_batch].?.cep);
+}
+
+test "a truncated batch pads instead of shifting" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ const body = try csvBody(testing.allocator, &.{
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ });
+ defer testing.allocator.free(body);
+ try fixture.server.respond("/csv", &.{.{ .body = body }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const result = try client.lookupMany(&.{ "01001000", "20040020" }, .{});
+ defer cepis.freeAddresses(testing.allocator, result);
+
+ try testing.expectEqual(@as(usize, 2), result.len);
+ try testing.expect(result[0] != null);
+ try testing.expect(result[1] == null);
+}
+
+test "the batch is retried like any other request" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ const body = try csvBody(testing.allocator, &.{
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ });
+ defer testing.allocator.free(body);
+ try fixture.server.respond("/csv", &.{
+ .{ .status = 502, .body = "{}" },
+ .{ .body = body },
+ });
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ const result = try client.lookupMany(&.{"01001000"}, .{});
+ defer cepis.freeAddresses(testing.allocator, result);
+
+ try testing.expect(result[0] != null);
+ try testing.expectEqual(@as(usize, 2), fixture.server.countPrefix("/csv"));
+}
+
+test "health reads the database version" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/health", &.{.{
+ .body =
+ \\{"status":"ok","dne":"26071","ceps":1596168,"geo":894489,"base_gerada_em":"2026-07-24T03:48:28Z"}
+ ,
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var health = try client.health(.{});
+ 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 API is not an error" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try fixture.server.respond("/health", &.{.{
+ .status = 503,
+ .body =
+ \\{"status":"degradado","dne":"","ceps":0,"geo":0}
+ ,
+ }});
+
+ var client = try fixture.server.client(.{});
+ defer client.deinit();
+
+ var health = try client.health(.{});
+ defer health.deinit(testing.allocator);
+
+ try testing.expect(!health.ok());
+ try testing.expectEqual(@as(usize, 1), fixture.server.countPrefix("/health"));
+}
+
+test "a trailing slash on base_url is not duplicated" {
+ var threaded: std.Io.Threaded = .init(testing.allocator, .{});
+ defer threaded.deinit();
+
+ var client = try cepis.Client.init(testing.allocator, threaded.io(), .{
+ .base_url = "http://example.invalid/",
+ });
+ defer client.deinit();
+
+ try testing.expectEqualStrings("http://example.invalid", client.base_url);
+}
+
+test "the User-Agent identifies the library and can be overridden" {
+ var fixture: Fixture = undefined;
+ try fixture.init();
+ defer fixture.deinit();
+
+ try testing.expect(std.mem.startsWith(u8, cepis.default_user_agent, "cepis-zig/0.1.0"));
+
+ try fixture.server.respond("/01001000", &.{.{ .body = se_address }});
+
+ var client = try fixture.server.client(.{ .user_agent = "my-app/1.0" });
+ defer client.deinit();
+
+ var address = try client.lookup("01001000", .{});
+ defer address.deinit(testing.allocator);
+
+ try testing.expectEqualStrings("my-app/1.0", client.user_agent);
+}
+
+test "a zero max_attempts or timeout is a configuration error" {
+ var threaded: std.Io.Threaded = .init(testing.allocator, .{});
+ defer threaded.deinit();
+
+ try testing.expectError(error.InvalidConfiguration, cepis.Client.init(
+ testing.allocator,
+ threaded.io(),
+ .{ .max_attempts = 0 },
+ ));
+ try testing.expectError(error.InvalidConfiguration, cepis.Client.init(
+ testing.allocator,
+ threaded.io(),
+ .{ .timeout_ms = 0 },
+ ));
+}
+
+test "normalizeCep is reachable from the public surface" {
+ var buffer: [8]u8 = undefined;
+ try testing.expectEqualStrings("01001000", try cepis.normalizeCep("01001-000", &buffer));
+ try testing.expectError(error.InvalidCep, cepis.normalizeCep("123", &buffer));
+}