summaryrefslogtreecommitdiffstats
path: root/src/model.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/model.zig')
-rw-r--r--src/model.zig543
1 files changed, 543 insertions, 0 deletions
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);
+}