# 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).