diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:47:38 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:47:38 -0300 |
| commit | 794f28199a4dde9d18ec711864336c96c58bb422 (patch) | |
| tree | 3985f1c033a9c62a264829cd354da9ce214e5db7 /source/cepis/model.d | |
| download | d-794f28199a4dde9d18ec711864336c96c58bb422.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'source/cepis/model.d')
| -rw-r--r-- | source/cepis/model.d | 379 |
1 files changed, 379 insertions, 0 deletions
diff --git a/source/cepis/model.d b/source/cepis/model.d new file mode 100644 index 0000000..9ec3ffb --- /dev/null +++ b/source/cepis/model.d @@ -0,0 +1,379 @@ +/** + * The data the API returns, as values. + * + * $(B Field names are the API's own, in Portuguese) — $(D logradouro), + * $(D bairro), $(D 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 + * $(LINK https://cep.is/01001000) is exactly what you access on the value. + * Translating to $(D street)/$(D 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. + */ +module cepis.model; + +import std.conv : to; +import std.json : JSONType, JSONValue; +import std.typecons : Nullable, nullable; + +@safe: + +/** + * The CEP's coordinate, when the database has one. + * + * The point is the $(B 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 $(D precisao) before using $(D lat)/$(D lon) for distance: + * + * $(UL + * $(LI $(D "logradouro"): the point is a stretch of street. $(D raio_m) lands + * around 100–150 m. Good for maps and for distance.) + * $(LI $(D "localidade"): the CEP $(I is) the whole municipality (a city-wide + * general CEP), and the point is its centroid. $(D raio_m) runs from + * 7 km to 40 km. Computing shipping from this is wrong by tens of + * kilometres.) + * ) + * + * $(D raio_m) is the radius containing 90% of that CEP's addresses. It exists + * precisely so that error cannot pass unnoticed. + */ +struct Geo +{ + /// Latitude in decimal degrees, WGS 84. + double lat; + /// Longitude in decimal degrees, WGS 84. + double lon; + /// Radius, in metres, containing 90% of the CEP's addresses. + long raio_m; + /// `"logradouro"` (a street) or `"localidade"` (a municipality centroid). + string precisao; + + /** + * 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. + */ + bool isPrecise() const pure nothrow @nogc + { + return precisao == "logradouro"; + } + + /** + * Build from the `geo` block the API returned. + * + * Returns a null $(D Nullable) for anything that is not a complete, + * readable block: a partial or malformed coordinate is not worth losing the + * whole address over. + */ + static Nullable!Geo fromJSON(const JSONValue value) + { + if (value.type != JSONType.object) + return Nullable!Geo.init; + + const object = value.objectNoRef; + auto lat = "lat" in object; + auto lon = "lon" in object; + auto raio = "raio_m" in object; + auto precisao = "precisao" in object; + if (lat is null || lon is null || raio is null || precisao is null) + return Nullable!Geo.init; + if (precisao.type != JSONType.string) + return Nullable!Geo.init; + + const latitude = asNumber(*lat); + const longitude = asNumber(*lon); + const radius = asNumber(*raio); + if (latitude.isNull || longitude.isNull || radius.isNull) + return Nullable!Geo.init; + + return nullable(Geo(latitude.get, longitude.get, cast(long) radius.get, precisao.str)); + } +} + +/** + * 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. + * + * $(D geo) is the exception: it is null when there is no coordinate. There the + * difference is real, because the API $(B omits) the whole field in that case + * (about 43% of CEPs) rather than sending null. + * + * $(D raw) holds the response exactly as it arrived, uninterpreted, so a field + * the API grows before this library is updated is not lost. + */ +struct Address +{ + /// The CEP, always unmasked: `"01001000"`. + string cep; + /// Street, avenue, square — the public way. + string logradouro; + /// Range or side of the street the CEP covers, when it covers only part. + string complemento; + /// Name of the large user or building the CEP belongs to, when it does. + string unidade; + /// Neighbourhood. + string bairro; + /// Municipality. + string localidade; + /// Federative unit, two letters: `"SP"`. + string uf; + /// Federative unit spelled out: `"São Paulo"`. + string estado; + /// Region: `"Sudeste"`, `"Nordeste"`, and so on. + string regiao; + /// IBGE municipality code — the key to join with public datasets. + string ibge; + /// GIA code, São Paulo state tax administration only. + string gia; + /// Area code for telephony: `"11"`. + string ddd; + /// SIAFI municipality code, federal administration. + string siafi; + + /** + * The coordinate, or null when the database has none for this CEP. + * + * Always null for an address that came from $(D Client.lookupMany) — + * batches carry no coordinate at all. + */ + Nullable!Geo geo; + + /// The response exactly as it arrived, so a new API field is never lost. + JSONValue raw; + + /** + * The CEP with its mask: `01001-000`. + * + * The native contract always returns it unmasked; formatting is + * presentation, and it lives here so it does not become a repeated + * $(D format) call in every project that uses this. + */ + string formattedCep() const pure nothrow + { + return cep.length == 8 ? cep[0 .. 5] ~ "-" ~ cep[5 .. $] : cep; + } + + /** + * Build from the JSON object the API returned. + * + * Unknown fields are ignored during construction but preserved in $(D raw). + * A new field in the API therefore cannot break this library. + * + * A malformed `geo` block yields a null $(D geo) rather than failing the + * whole value: it is not worth losing the address over, since the rest of + * the response is still useful. + */ + static Address fromJSON(JSONValue value) + { + Address address; + address.cep = jsonText(value, "cep"); + address.logradouro = jsonText(value, "logradouro"); + address.complemento = jsonText(value, "complemento"); + address.unidade = jsonText(value, "unidade"); + address.bairro = jsonText(value, "bairro"); + address.localidade = jsonText(value, "localidade"); + address.uf = jsonText(value, "uf"); + address.estado = jsonText(value, "estado"); + address.regiao = jsonText(value, "regiao"); + address.ibge = jsonText(value, "ibge"); + address.gia = jsonText(value, "gia"); + address.ddd = jsonText(value, "ddd"); + address.siafi = jsonText(value, "siafi"); + + if (value.type == JSONType.object) + { + const object = value.objectNoRef; + if (auto block = "geo" in object) + address.geo = Geo.fromJSON(*block); + } + + address.raw = value; + return address; + } +} + +/** + * The response of `GET /health`. + * + * Good for two things: checking that the API answers, and knowing $(B which + * version of the database) is live. The DNE database changes every two weeks; + * $(D dne) identifies it. + */ +struct Health +{ + /// `"ok"` when the API considers itself healthy. + string status; + /// Identifier of the fortnightly DNE database currently loaded. + string dne; + /// How many CEPs the database holds. + ulong ceps; + /// How many of those carry a coordinate. + ulong geo; + /// When the database was generated, RFC 3339 in UTC. + string base_gerada_em; + /// The response exactly as it arrived. + JSONValue raw; + + /// True when the API reports itself healthy. + bool ok() const pure nothrow @nogc + { + return status == "ok"; + } + + /// Build from the JSON object the API returned. + static Health fromJSON(JSONValue value) + { + Health health; + health.status = jsonText(value, "status"); + health.dne = jsonText(value, "dne"); + health.ceps = jsonCount(value, "ceps"); + health.geo = jsonCount(value, "geo"); + health.base_gerada_em = jsonText(value, "base_gerada_em"); + health.raw = value; + return health; + } +} + +private string jsonText(const JSONValue value, string key) +{ + if (value.type != JSONType.object) + return ""; + + const object = value.objectNoRef; + auto found = key in object; + if (found is null) + return ""; + + switch (found.type) + { + case JSONType.string: + return found.str; + case JSONType.null_: + return ""; + default: + return found.toString(); + } +} + +private ulong jsonCount(const JSONValue value, string key) +{ + if (value.type != JSONType.object) + return 0; + + const object = value.objectNoRef; + auto found = key in object; + if (found is null) + return 0; + + switch (found.type) + { + case JSONType.integer: + return found.integer < 0 ? 0 : cast(ulong) found.integer; + case JSONType.uinteger: + return found.uinteger; + case JSONType.float_: + return found.floating < 0 ? 0 : cast(ulong) found.floating; + case JSONType.string: + try + return found.str.to!ulong; + catch (Exception) + return 0; + default: + return 0; + } +} + +private Nullable!double asNumber(const JSONValue value) +{ + switch (value.type) + { + case JSONType.integer: + return nullable(cast(double) value.integer); + case JSONType.uinteger: + return nullable(cast(double) value.uinteger); + case JSONType.float_: + return nullable(value.floating); + case JSONType.string: + try + return nullable(value.str.to!double); + catch (Exception) + return Nullable!double.init; + default: + return Nullable!double.init; + } +} + +@("an address survives a malformed geo block") +unittest +{ + import std.json : parseJSON; + + const address = Address.fromJSON(parseJSON( + `{"cep":"01001000","localidade":"São Paulo","geo":{"lat":"nowhere"}}`)); + + assert(address.geo.isNull); + assert(address.localidade == "São Paulo"); +} + +@("a complete geo block is read") +unittest +{ + import std.json : parseJSON; + + const address = Address.fromJSON(parseJSON(`{"cep":"01001000","geo":{"lat":-23.5,` + ~ `"lon":-46.6,"raio_m":1084,"precisao":"logradouro"}}`)); + + assert(!address.geo.isNull); + assert(address.geo.get.raio_m == 1084); + assert(address.geo.get.isPrecise); +} + +@("a municipality coordinate is not precise") +unittest +{ + const geo = Geo(-23.5, -46.6, 22_000, "localidade"); + + assert(!geo.isPrecise); +} + +@("missing fields become empty strings, not errors") +unittest +{ + import std.json : parseJSON; + + const address = Address.fromJSON(parseJSON(`{"cep":"01001000"}`)); + + assert(address.logradouro == ""); + assert(address.formattedCep == "01001-000"); +} + +@("an unknown field is preserved in raw") +unittest +{ + import std.json : parseJSON; + + const address = Address.fromJSON(parseJSON(`{"cep":"01001000","campo_do_futuro":"value"}`)); + + assert(address.raw["campo_do_futuro"].str == "value"); +} + +@("health reads counts as numbers") +unittest +{ + import std.json : parseJSON; + + const health = Health.fromJSON( + parseJSON(`{"status":"ok","dne":"26071","ceps":1596168,"geo":894489}`)); + + assert(health.ok); + assert(health.ceps == 1_596_168); + assert(health.geo == 894_489); +} |