diff options
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 55 | ||||
| -rw-r--r-- | README.md | 251 | ||||
| -rw-r--r-- | build.zig | 51 | ||||
| -rw-r--r-- | build.zig.zon | 15 | ||||
| -rw-r--r-- | src/client.zig | 804 | ||||
| -rw-r--r-- | src/csv.zig | 207 | ||||
| -rw-r--r-- | src/errors.zig | 176 | ||||
| -rw-r--r-- | src/main.zig | 118 | ||||
| -rw-r--r-- | src/model.zig | 543 | ||||
| -rw-r--r-- | src/retry.zig | 152 | ||||
| -rw-r--r-- | src/root.zig | 90 | ||||
| -rw-r--r-- | test/api.zig | 898 |
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 @@ -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)); +} |