diff options
Diffstat (limited to 'src/model.zig')
| -rw-r--r-- | src/model.zig | 543 |
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); +} |