# 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; `faixa` in the diagnostics | 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. ### A CEP that does not exist `GET /{cep}` answers **404** for a CEP the DNE does not have, and this package turns that into `error.NotFound`. When the number falls inside a band that belongs to a municipality, the 404 also names it, and `diag.faixa()` gives you that: ```zig var diag: cepis.Diagnostics = .{}; client.lookup("25845-000", .{ .diagnostics = &diag }) catch |err| switch (err) { error.NotFound => { if (diag.faixa()) |f| { f.localidade; // "Areal" f.uf; // "RJ" f.estado; // "Rio de Janeiro" f.regiao; // "Sudeste" f.ibge; // "3300225" f.cep_ini; // "25845001" f.cep_fim; // "25849899" f.ceps; // 118 } return askForAnotherCep(); }, else => return err, }; ``` Worth having because a failing CEP is rarely random: it is someone who knows the city and guessed the number, and the classic guess is the `-000` that 957 municipalities never received — `25845000` above is exactly that guess for Areal. > **It is still an error.** `faixa` explains *where* the number would have > fallen. It is not an address, there is no address, and treating a non-null > `faixa` as a successful lookup turns a clean failure into wrong data. Two things about the range, both of which make it wrong to hand it to a user as a list of CEPs to try: - **`cep_ini` and `cep_fim` are not the Correios' bounds.** They are the smallest and the largest CEP that *actually exist* in the municipality. The DNE's own start for Areal is `25845000` — the CEP that just failed — while `cep_ini` is `25845001`. - **The range is sparse.** Areal's 118 CEPs are scattered over the ~4,900 numbers between the two. That is what `ceps` is for; never compute `cep_fim - cep_ini` and call it a count. `faixa()` is null whenever the API said nothing — the CEP fell in a band no municipality owns, and there the field is **omitted**, never sent as null. Null is the ordinary case, not a sign of trouble. Like `message()`, the value borrows the `Diagnostics` and is valid only as long as it is; nothing is allocated and there is nothing to free. ## 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).