diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:39:06 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:39:06 -0300 |
| commit | 497b1c1b01f6c9a584e5b40e02aff4763447529c (patch) | |
| tree | b50feb4252ba93cf045be3aaac14a96eeb04dff5 | |
| parent | 87166a5299aa551517a86379a0c05eb2110b52b0 (diff) | |
| download | zig-main.tar.gz | |
A CEP that fails to resolve is rarely a random number. It is almost always
someone who knows the city and guessed the digits, and the classic guess is
the -000 that 957 municipalities never received. The API now says so: a 404
carries an optional faixa block naming the municipality the number would have
belonged to, and answering "no such CEP in Areal/RJ, which has 118 of them" is
a great deal more useful than "not found".
It arrives through Diagnostics, which is where every other detail a Zig error
tag cannot carry already travels, and it is read with diag.faixa(). Like
message(), it lives in a fixed buffer inside the value and allocates nothing,
so no caller has to learn a new deinit for it.
The lookup still fails. faixa is context for the error, never an address, and
cep_ini/cep_fim are the smallest and largest CEP that actually exist there
rather than the bounds the Correios publish — for Areal the DNE's own start is
25845000, which is exactly the CEP that failed. The range is also sparse, 118
CEPs over ~4,900 numbers, so ceps is the count and the pair must never be
presented as a list. The doc comments and the README say all three, because
this field is easy to misread as a success.
Parsing is deliberately inert: a faixa that is missing, null, not an object,
nameless or oversized yields null, and an unreadable body is skipped. Nothing
about this field may promote a clean not-found into a parse failure.
| -rw-r--r-- | README.md | 56 | ||||
| -rw-r--r-- | build.zig.zon | 2 | ||||
| -rw-r--r-- | src/client.zig | 69 | ||||
| -rw-r--r-- | src/errors.zig | 128 | ||||
| -rw-r--r-- | src/main.zig | 7 | ||||
| -rw-r--r-- | src/model.zig | 196 | ||||
| -rw-r--r-- | src/root.zig | 5 | ||||
| -rw-r--r-- | test/api.zig | 135 |
8 files changed, 590 insertions, 8 deletions
@@ -200,7 +200,7 @@ 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.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 | @@ -228,6 +228,60 @@ const address = client.lookup(cep_from_form, .{ .diagnostics = &diag }) catch |e 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`: diff --git a/build.zig.zon b/build.zig.zon index 3d0c3e3..0263f66 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .cepis, - .version = "0.1.0", + .version = "0.2.0", .fingerprint = 0x3d8a6589b96b9a53, // Changing this has security and trust implications. .minimum_zig_version = "0.16.0", .dependencies = .{}, diff --git a/src/client.zig b/src/client.zig index 3a726e5..2c71e16 100644 --- a/src/client.zig +++ b/src/client.zig @@ -22,7 +22,7 @@ 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"; +pub const version = "0.2.0"; /// Where the public API lives. pub const default_url = "https://cep.is"; @@ -238,6 +238,11 @@ pub const Client = struct { /// in `Diagnostics`: the format already passed local validation, so the /// server knows something this client does not. /// + /// A 404 becomes `error.NotFound`, and the 404 body may name the + /// municipality the CEP would have belonged to — read it with + /// `Diagnostics.faixa`. It stays an error: the CEP does not exist, and the + /// range is context for saying so, not an address. + /// /// Errors: `error.InvalidCep` (not 8 digits — no request is made), /// `error.NotFound`, `error.RateLimited`, `error.ServerError`, /// `error.NetworkError`, `error.InvalidResponse`, `error.OutOfMemory`. @@ -259,6 +264,7 @@ pub const Client = struct { defer c.gpa.free(response.body); if (response.status == 404) { + noteFaixa(options.diagnostics, response.body); note(options.diagnostics, "CEP {s} does not exist in the DNE database", .{number}); return error.NotFound; } @@ -746,6 +752,35 @@ fn noteApiMessage( d.setMessage(fallback, args); } +/// Record the `faixa` a 404 body may carry — the municipality the CEP would +/// have belonged to — into `diagnostics`. +/// +/// Every failure here is silent and leaves `Diagnostics.faixa` null. The answer +/// is already `error.NotFound`, and no body, however mangled, may promote a +/// clean not-found into `error.InvalidResponse`. The parse runs against a fixed +/// stack buffer, so a body too large for it is simply skipped: nothing is +/// allocated from `gpa` on this path and nothing can leak. +/// +/// The *leaky* parse is the point of the fixed buffer: the arena +/// `parseFromSlice` builds asks its backing allocator for a node far larger +/// than the document, which a stack buffer this size cannot serve. Here the +/// buffer *is* the arena, and it is discarded whole on return. +fn noteFaixa(diagnostics: ?*Diagnostics, body: []const u8) void { + const d = diagnostics orelse return; + d.clearFaixa(); + + var buffer: [4096]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&buffer); + const value = std.json.parseFromSliceLeaky( + std.json.Value, + fba.allocator(), + body, + .{}, + ) catch return; + + d.setFaixa(value); +} + fn preview(body: []const u8) []const u8 { return body[0..@min(body.len, 120)]; } @@ -793,6 +828,38 @@ test "only transient statuses are retriable" { } } +test "noteFaixa reads the range a 404 carries" { + var diag: Diagnostics = .{}; + noteFaixa(&diag, + \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", + \\ "faixa":{"localidade":"Areal","uf":"RJ","ibge":"3300225", + \\ "cep_ini":"25845001","cep_fim":"25849899","ceps":118}} + ); + const faixa = diag.faixa().?; + try testing.expectEqualStrings("Areal", faixa.localidade); + try testing.expectEqual(@as(u64, 118), faixa.ceps); +} + +test "noteFaixa reports nothing rather than failing" { + var diag: Diagnostics = .{}; + for ([_][]const u8{ + "<html>", + "", + \\{"erro":"cep_nao_encontrado"} + , + \\{"faixa":[]} + , + \\{"faixa":{"uf":"RJ"}} + , + }) |body| { + noteFaixa(&diag, body); + try testing.expect(diag.faixa() == null); + } + + // Null works the same way with no `Diagnostics` at all. + noteFaixa(null, "{}"); +} + test "noteApiMessage prefers the API wording" { var diag: Diagnostics = .{}; noteApiMessage(&diag, "{\"erro\":\"cep_invalido\",\"mensagem\":\"rejected\"}", "fallback {s}", .{"x"}); diff --git a/src/errors.zig b/src/errors.zig index c46e266..5ce474a 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -18,10 +18,13 @@ //! //! 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. +//! message, the municipality a missing CEP would have belonged to — travels in +//! an optional `Diagnostics` the caller passes in. const std = @import("std"); +const Faixa = @import("model.zig").Faixa; + /// 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. @@ -35,6 +38,10 @@ pub const CepisError = error{ InvalidCep, /// The CEP is well-formed but does not exist in the DNE database. + /// + /// `Diagnostics.faixa` may name the municipality the CEP would have + /// belonged to. That is context for the failure, not an address — the + /// lookup found nothing either way. NotFound, /// The API (or the edge in front of it) answered 429 on every attempt. @@ -91,9 +98,9 @@ pub fn isRetriable(err: Error) bool { /// }; /// ``` /// -/// 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. +/// It owns no memory and needs no `deinit`: the message and the range live in +/// fixed buffers inside the struct, and are only valid as long as the +/// `Diagnostics` itself. Copy them if you need them 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 @@ -117,6 +124,17 @@ pub const Diagnostics = struct { /// Length of the text in `message_buffer`. Internal. message_len: usize = 0, + /// Backing storage for `faixa`. Internal; read it through `faixa`. + faixa_buffer: [Faixa.max_bytes]u8 = undefined, + + /// Length of each of `faixa`'s strings inside `faixa_buffer`, in + /// `Faixa.text_fields` order, or null when no range was reported. + /// Internal. + faixa_lengths: ?[Faixa.text_fields.len]u16 = null, + + /// The `ceps` count of the reported range. Internal. Read through `faixa`. + faixa_ceps: u64 = 0, + /// The longest explanation a `Diagnostics` will keep. Longer text is /// truncated rather than allocated. pub const max_message_len = 512; @@ -127,12 +145,47 @@ pub const Diagnostics = struct { return d.message_buffer[0..d.message_len]; } + /// After `error.NotFound`, the municipality the CEP would have belonged to. + /// + /// **The lookup still failed.** A non-null `Faixa` explains *where* the + /// number would have fallen; it is not the address, and there is no address + /// to be had. Use it to answer better — "no such CEP in Areal/RJ, which has + /// 118 of them" — not to carry on as though the CEP existed. + /// + /// Null whenever the API said nothing: the CEP fell in a band no + /// municipality owns (the API **omits** the field there, it never sends + /// null), the response predates the field, or the body could not be read. + /// Null is therefore ordinary, not a sign of trouble. + /// + /// Like `message`, the value borrows this `Diagnostics` and is valid only + /// as long as it is. + /// + /// ```zig + /// client.lookup(cep, .{ .diagnostics = &diag }) catch |err| switch (err) { + /// error.NotFound => if (diag.faixa()) |f| { + /// std.log.info("no such CEP in {s}/{s} ({d} CEPs)", .{ f.localidade, f.uf, f.ceps }); + /// }, + /// else => return err, + /// }; + /// ``` + pub fn faixa(d: *const Diagnostics) ?Faixa { + const lengths = d.faixa_lengths orelse return null; + var found: Faixa = .{ .ceps = d.faixa_ceps }; + var cursor: usize = 0; + inline for (Faixa.text_fields, 0..) |name, i| { + @field(found, name) = d.faixa_buffer[cursor..][0..lengths[i]]; + cursor += lengths[i]; + } + return found; + } + /// 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; + d.clearFaixa(); } /// Set the message, truncating at `max_message_len`. Internal. @@ -141,6 +194,26 @@ pub const Diagnostics = struct { writer.print(fmt, args) catch {}; d.message_len = writer.end; } + + /// Record the `faixa` block of a 404 body, or clear it when the body has + /// none this can use. Never fails. Internal. + pub fn setFaixa(d: *Diagnostics, value: std.json.Value) void { + d.clearFaixa(); + const found = Faixa.fromJson(value, &d.faixa_buffer) orelse return; + + var lengths: [Faixa.text_fields.len]u16 = undefined; + inline for (Faixa.text_fields, 0..) |name, i| { + lengths[i] = @intCast(@field(found, name).len); + } + d.faixa_lengths = lengths; + d.faixa_ceps = found.ceps; + } + + /// Forget any range previously recorded. Internal. + pub fn clearFaixa(d: *Diagnostics) void { + d.faixa_lengths = null; + d.faixa_ceps = 0; + } }; /// Write to `maybe_diagnostics` when the caller supplied one. Internal. @@ -174,3 +247,50 @@ test "Diagnostics truncates instead of overflowing" { diag.setMessage("{s}", .{"x" ** (Diagnostics.max_message_len * 2)}); try std.testing.expectEqual(Diagnostics.max_message_len, diag.message().len); } + +test "Diagnostics keeps a faixa without allocating" { + const body = + \\{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal","uf":"RJ", + \\ "estado":"Rio de Janeiro","regiao":"Sudeste","ibge":"3300225", + \\ "cep_ini":"25845001","cep_fim":"25849899","ceps":118}} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, body, .{}); + defer parsed.deinit(); + + var diag: Diagnostics = .{}; + try std.testing.expect(diag.faixa() == null); + + diag.setFaixa(parsed.value); + const found = diag.faixa().?; + try std.testing.expectEqualStrings("Areal", found.localidade); + try std.testing.expectEqualStrings("RJ", found.uf); + try std.testing.expectEqualStrings("Rio de Janeiro", found.estado); + try std.testing.expectEqualStrings("25845001", found.cep_ini); + try std.testing.expectEqualStrings("25849899", found.cep_fim); + try std.testing.expectEqual(@as(u64, 118), found.ceps); + + diag.reset(); + try std.testing.expect(diag.faixa() == null); +} + +test "a 404 without a faixa clears the one before it" { + const with = + \\{"faixa":{"localidade":"Areal","uf":"RJ","ceps":118}} + ; + const without = + \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"} + ; + + var diag: Diagnostics = .{}; + inline for (.{ with, without }) |body| { + const parsed = try std.json.parseFromSlice( + std.json.Value, + std.testing.allocator, + body, + .{}, + ); + defer parsed.deinit(); + diag.setFaixa(parsed.value); + } + try std.testing.expect(diag.faixa() == null); +} diff --git a/src/main.zig b/src/main.zig index 6bb9777..77baae8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -79,6 +79,13 @@ pub fn main(init: std.process.Init) !u8 { 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() }); + // Not an address — the lookup failed. Only where the number would + // have landed, which is usually the useful thing to say back. + if (diag.faixa()) |faixa| { + try out.print(" would be in {s}/{s}, which has {d} CEPs between {s} and {s}\n", .{ + faixa.localidade, faixa.uf, faixa.ceps, faixa.cep_ini, faixa.cep_fim, + }); + } return 1; }; defer address.deinit(gpa); diff --git a/src/model.zig b/src/model.zig index 10ecbd3..8fa1673 100644 --- a/src/model.zig +++ b/src/model.zig @@ -16,6 +16,9 @@ //! 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. +//! +//! `Faixa` is the exception: it allocates nothing and owns nothing. It travels +//! inside a `Diagnostics`, which is a stack value, and borrows its storage. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -264,6 +267,119 @@ pub const Address = struct { } }; +/// The municipality a CEP that does **not** exist would have belonged to. +/// +/// `GET /{cep}` still answers **404** for a CEP the DNE does not have. When the +/// number falls inside a band a municipality owns, that 404 now carries this +/// block as well. It is **context for an error, never an address**: the CEP +/// asked for does not exist, and nothing here says otherwise. Treating a +/// non-null `faixa` as a successful lookup turns a clean failure into wrong +/// data. +/// +/// It is worth reporting because a failing CEP is rarely random — it is usually +/// someone who knows the city and guessed the number, and the classic guess is +/// the `-000` that 957 municipalities never received. +/// +/// ## `cep_ini` and `cep_fim` are not the Correios' bounds +/// +/// They are the smallest and the largest CEP that **actually exist** in that +/// municipality, not the range the Correios publish. For Areal (RJ) the DNE's +/// own start is `25845000` — precisely the CEP that just failed — while +/// `cep_ini` is `25845001`. +/// +/// ## The range is sparse +/// +/// Areal's 118 CEPs are scattered across the ~4,900 numbers between `25845001` +/// and `25849899`. Never present the pair as a list of valid CEPs, and never +/// count `cep_fim - cep_ini`: `ceps` is the count, and it is here so that +/// mistake cannot be made by accident. +/// +/// ## Ownership +/// +/// Owns nothing and has no `deinit`. Its strings borrow the buffer they were +/// packed into — the `Diagnostics` that read them — and are valid for exactly +/// as long as that value is. Copy them if you need them to outlive it. +pub const Faixa = struct { + /// Municipality: `"Areal"`. + localidade: []const u8 = "", + /// Federative unit, two letters: `"RJ"`. + uf: []const u8 = "", + /// Federative unit spelled out: `"Rio de Janeiro"`. + estado: []const u8 = "", + /// Region: `"Sudeste"`. + regiao: []const u8 = "", + /// IBGE municipality code — the key to join with public datasets. + ibge: []const u8 = "", + /// The **smallest CEP that exists** in the municipality, not the start of + /// the band the Correios published for it. + cep_ini: []const u8 = "", + /// The **largest CEP that exists** in the municipality. + cep_fim: []const u8 = "", + /// How many CEPs the municipality actually has between `cep_ini` and + /// `cep_fim`. Almost always far fewer than the numbers those two span. + ceps: u64 = 0, + + /// The string fields, in the order they are packed into the buffer. Used to + /// keep the field list and the JSON keys in step; `localidade` must stay + /// first, since it is the one that decides the block says anything at all. + pub const text_fields = [_][]const u8{ + "localidade", "uf", "estado", "regiao", + "ibge", "cep_ini", "cep_fim", + }; + + /// Bytes of storage a `Faixa` can need. The longest municipality name the + /// DNE holds is 72 bytes and every other field is short, so this has room + /// to spare — see `fromJson` for what happens if it somehow does not. + pub const max_bytes = 256; + + /// Read the `faixa` block off the JSON body of a 404, packing its strings + /// into `buffer` in `text_fields` order. + /// + /// Returns null — never an error — when the block is missing, null, not an + /// object, carries no `localidade`, or would not fit in `buffer`. The + /// answer is already `error.NotFound`, and no shape of this field may turn + /// that into a failure to parse. The API **omits** `faixa` rather than + /// sending null when the CEP falls in a band no municipality owns, so + /// absent is the common case, not a malformed one. + /// + /// Nothing is allocated. The result borrows `buffer`, which must be at + /// least `max_bytes` long to be sure of holding any real range. + pub fn fromJson(value: std.json.Value, buffer: []u8) ?Faixa { + const object = switch (value) { + .object => |o| o, + else => return null, + }; + const block = switch (object.get("faixa") orelse return null) { + .object => |o| o, + else => return null, + }; + const wrapped: std.json.Value = .{ .object = block }; + + var scratch: [text_fields.len][32]u8 = undefined; + var values: [text_fields.len][]const u8 = undefined; + var total: usize = 0; + inline for (text_fields, 0..) |name, i| { + values[i] = jsonText(wrapped, name, &scratch[i]); + total += values[i].len; + } + + // Without the municipality the block names nothing, which is the same + // as not being there. + if (values[0].len == 0) return null; + if (total > buffer.len) return null; + + var faixa: Faixa = .{ .ceps = jsonCount(block.get("ceps")) }; + var cursor: usize = 0; + inline for (text_fields, 0..) |name, i| { + const v = values[i]; + @memcpy(buffer[cursor..][0..v.len], v); + @field(faixa, name) = buffer[cursor..][0..v.len]; + cursor += v.len; + } + return faixa; + } +}; + /// The response of `GET /health`. /// /// Good for two things: checking that the API answers, and knowing **which @@ -510,6 +626,86 @@ test "formattedCep passes a malformed CEP through" { try testing.expectEqualStrings("123", address.formattedCep(&buffer)); } +const areal_404 = + \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", + \\ "faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro", + \\ "regiao":"Sudeste","ibge":"3300225","cep_ini":"25845001", + \\ "cep_fim":"25849899","ceps":118}} +; + +test "Faixa.fromJson names the municipality of a CEP that does not exist" { + const parsed = try parseValue(areal_404); + defer parsed.deinit(); + + var buffer: [Faixa.max_bytes]u8 = undefined; + const faixa = Faixa.fromJson(parsed.value, &buffer).?; + + try testing.expectEqualStrings("Areal", faixa.localidade); + try testing.expectEqualStrings("RJ", faixa.uf); + try testing.expectEqualStrings("Rio de Janeiro", faixa.estado); + try testing.expectEqualStrings("Sudeste", faixa.regiao); + try testing.expectEqualStrings("3300225", faixa.ibge); + // The DNE's own start for Areal is 25845000; cep_ini is the smallest CEP + // that exists, which is not the same number. + try testing.expectEqualStrings("25845001", faixa.cep_ini); + try testing.expectEqualStrings("25849899", faixa.cep_fim); + // 118 CEPs over the ~4,900 numbers the pair spans: the range is sparse. + try testing.expectEqual(@as(u64, 118), faixa.ceps); +} + +test "a faixa the API omitted is null, not an error" { + const bodies = [_][]const u8{ + \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"} + , + \\{"erro":"cep_nao_encontrado","faixa":null} + , + \\{"erro":"cep_nao_encontrado","faixa":{}} + , + \\{"erro":"cep_nao_encontrado","faixa":"Areal"} + , + \\{"erro":"cep_nao_encontrado","faixa":{"uf":"RJ","ceps":118}} + , + \\{"erro":"cep_nao_encontrado","faixa":{"localidade":"","uf":"RJ"}} + , + \\[] + , + }; + for (bodies) |body| { + const parsed = try parseValue(body); + defer parsed.deinit(); + + var buffer: [Faixa.max_bytes]u8 = undefined; + try testing.expect(Faixa.fromJson(parsed.value, &buffer) == null); + } +} + +test "a partial faixa keeps what it has" { + const body = + \\{"faixa":{"localidade":"Areal","ceps":"118"}} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var buffer: [Faixa.max_bytes]u8 = undefined; + const faixa = Faixa.fromJson(parsed.value, &buffer).?; + + try testing.expectEqualStrings("Areal", faixa.localidade); + try testing.expectEqualStrings("", faixa.uf); + try testing.expectEqualStrings("", faixa.cep_ini); + try testing.expectEqual(@as(u64, 118), faixa.ceps); +} + +test "a faixa that does not fit is reported as absent, not truncated" { + const body = + \\{"faixa":{"localidade":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","uf":"RJ"}} + ; + const parsed = try parseValue(body); + defer parsed.deinit(); + + var small: [8]u8 = undefined; + try testing.expect(Faixa.fromJson(parsed.value, &small) == null); +} + test "Health.fromJson reads counts and status" { const body = \\{"status":"ok","dne":"26071","ceps":1596168,"geo":894489, diff --git a/src/root.zig b/src/root.zig index 0a83c5c..d97c8ea 100644 --- a/src/root.zig +++ b/src/root.zig @@ -44,6 +44,10 @@ //! }; //! ``` //! +//! After `error.NotFound`, `diag.faixa()` may name the municipality the CEP +//! would have belonged to. It is context for the failure and never an address — +//! see `Faixa`. +//! //! 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. @@ -77,6 +81,7 @@ pub const Diagnostics = errors.Diagnostics; pub const isRetriable = errors.isRetriable; pub const Address = model.Address; +pub const Faixa = model.Faixa; pub const Geo = model.Geo; pub const Health = model.Health; diff --git a/test/api.zig b/test/api.zig index fcdb956..b860e41 100644 --- a/test/api.zig +++ b/test/api.zig @@ -379,6 +379,139 @@ test "a missing CEP is not found" { try testing.expect(std.mem.indexOf(u8, diag.message(), "99999999") != null); } +test "a missing CEP names the municipality it would have been in" { + var fixture: Fixture = undefined; + try fixture.init(); + defer fixture.deinit(); + + // 25845000 is Areal's own start in the DNE — and it is not a CEP that + // exists. That is exactly the guess this field is there to answer. + try fixture.server.respond("/25845000", &.{.{ + .status = 404, + .body = + \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", + \\"faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro", + \\"regiao":"Sudeste","ibge":"3300225","cep_ini":"25845001", + \\"cep_fim":"25849899","ceps":118}} + , + }}); + + var client = try fixture.server.client(.{}); + defer client.deinit(); + + var diag: cepis.Diagnostics = .{}; + // Still an error. The range explains the failure; it does not undo it. + try testing.expectError(error.NotFound, client.lookup("25845-000", .{ .diagnostics = &diag })); + try testing.expectEqual(@as(?u16, 404), diag.status); + + const faixa = diag.faixa().?; + try testing.expectEqualStrings("Areal", faixa.localidade); + try testing.expectEqualStrings("RJ", faixa.uf); + try testing.expectEqualStrings("Rio de Janeiro", faixa.estado); + try testing.expectEqualStrings("Sudeste", faixa.regiao); + try testing.expectEqualStrings("3300225", faixa.ibge); + // Not the Correios' bounds: cep_ini is the smallest CEP that exists, and + // the CEP that just failed is one below it. + try testing.expectEqualStrings("25845001", faixa.cep_ini); + try testing.expectEqualStrings("25849899", faixa.cep_fim); + // Sparse: 118 CEPs scattered over the ~4,900 numbers the pair spans. + try testing.expectEqual(@as(u64, 118), faixa.ceps); +} + +test "a CEP in a band no municipality owns reports no faixa" { + var fixture: Fixture = undefined; + try fixture.init(); + defer fixture.deinit(); + + // The API omits `faixa` here; it never sends null. + 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(diag.faixa() == null); + try testing.expect(std.mem.indexOf(u8, diag.message(), "99999999") != null); +} + +test "a malformed faixa is still a clean not found" { + var fixture: Fixture = undefined; + try fixture.init(); + defer fixture.deinit(); + + try fixture.server.respond("/99999999", &.{ + .{ .status = 404, .body = "{\"erro\":\"cep_nao_encontrado\",\"faixa\":\"Areal\"}" }, + .{ .status = 404, .body = "{\"faixa\":{\"uf\":\"RJ\",\"ceps\":118}}" }, + .{ .status = 404, .body = "not json at all" }, + .{ .status = 404, .body = "" }, + }); + + var client = try fixture.server.client(.{}); + defer client.deinit(); + + for (0..4) |_| { + var diag: cepis.Diagnostics = .{}; + try testing.expectError( + error.NotFound, + client.lookup("99999999", .{ .diagnostics = &diag }), + ); + try testing.expect(diag.faixa() == null); + } +} + +test "one Diagnostics reused across lookups does not keep a stale faixa" { + var fixture: Fixture = undefined; + try fixture.init(); + defer fixture.deinit(); + + try fixture.server.respond("/25845000", &.{.{ + .status = 404, + .body = + \\{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal","uf":"RJ", + \\"ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}} + , + }}); + 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("25845000", .{ .diagnostics = &diag })); + try testing.expectEqualStrings("Areal", diag.faixa().?.localidade); + + try testing.expectError(error.NotFound, client.lookup("99999999", .{ .diagnostics = &diag })); + try testing.expect(diag.faixa() == null); +} + +test "a successful lookup carries no faixa" { + 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 diag: cepis.Diagnostics = .{}; + var address = try client.lookup("01001000", .{ .diagnostics = &diag }); + defer address.deinit(testing.allocator); + + try testing.expect(diag.faixa() == null); +} + test "a 404 is not retried" { var fixture: Fixture = undefined; try fixture.init(); @@ -862,7 +995,7 @@ test "the User-Agent identifies the library and can be overridden" { try fixture.init(); defer fixture.deinit(); - try testing.expect(std.mem.startsWith(u8, cepis.default_user_agent, "cepis-zig/0.1.0")); + try testing.expect(std.mem.startsWith(u8, cepis.default_user_agent, "cepis-zig/0.2.0")); try fixture.server.respond("/01001000", &.{.{ .body = se_address }}); |