/** * 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 municipality a CEP that does $(B not) exist would have belonged to. * * This arrives with a $(B 404), on $(D CepNotFoundException.faixa). It is * $(B context for an error, never an address): the CEP asked for does not * exist, and nothing here says otherwise. Rendering it as though the lookup had * succeeded turns a clean not-found into a wrong answer. * * It is worth having because a CEP that fails is rarely a random number — it is * someone who knows the city and guessed the digits, and the classic guess is * the `-000` that 957 municipalities never received. * * $(D cep_ini) and $(D cep_fim) are the smallest and the largest CEP that * $(B actually exist) in that municipality, $(B not) the bounds the Correios * publish for it. The DNE's own start for Areal is `25845000` — precisely the * CEP that just failed; the smallest one that exists is `25845001`. * * The range is also $(B sparse). Areal's 118 CEPs are scattered across the * ~4,900 numbers between `25845001` and `25849899`. That is what $(D ceps) is * for: never present the pair as the list of valid CEPs for the city, and never * walk it expecting the numbers in between to resolve. */ struct Faixa { /// Municipality that owns the range: `"Areal"`. string localidade; /// Federative unit, two letters: `"RJ"`. string uf; /// Federative unit spelled out: `"Rio de Janeiro"`. string estado; /// Region: `"Sudeste"`, `"Nordeste"`, and so on. string regiao; /// IBGE municipality code — the key to join with public datasets. string ibge; /// The smallest CEP that exists in the municipality, unmasked. string cep_ini; /// The largest CEP that exists in the municipality, unmasked. string cep_fim; /// How many CEPs actually exist between $(D cep_ini) and $(D cep_fim). ulong ceps; /** * The municipality the way one says it out loud: `"Areal/RJ"`. * * For a message to a human — it is what the not-found error puts in its * own. Falls back to whichever of the two the API sent. */ string localidadeUf() const pure nothrow { if (localidade.length == 0) return uf; if (uf.length == 0) return localidade; return localidade ~ "/" ~ uf; } /** * Build from the `faixa` block a 404 carried. * * Returns a null $(D Nullable) for anything that does not name a * municipality: absent, not an object, or with an empty $(D localidade). * The API $(B omits) the block — it never sends null — when the CEP falls * in a band no municipality owns, and that is an ordinary not-found, not a * failure. Nothing read here may turn a 404 into a parse error. */ static Nullable!Faixa fromJSON(const JSONValue value) { if (value.type != JSONType.object) return Nullable!Faixa.init; Faixa faixa; faixa.localidade = jsonText(value, "localidade"); if (faixa.localidade.length == 0) return Nullable!Faixa.init; faixa.uf = jsonText(value, "uf"); faixa.estado = jsonText(value, "estado"); faixa.regiao = jsonText(value, "regiao"); faixa.ibge = jsonText(value, "ibge"); faixa.cep_ini = jsonText(value, "cep_ini"); faixa.cep_fim = jsonText(value, "cep_fim"); faixa.ceps = jsonCount(value, "ceps"); return nullable(faixa); } } /** * 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"); } @("a faixa names the municipality the missing cep would belong to") unittest { import std.json : parseJSON; const faixa = Faixa.fromJSON(parseJSON(`{"localidade":"Areal","uf":"RJ",` ~ `"estado":"Rio de Janeiro","regiao":"Sudeste","ibge":"3300225",` ~ `"cep_ini":"25845001","cep_fim":"25849899","ceps":118}`)); assert(!faixa.isNull); assert(faixa.get.localidade == "Areal"); assert(faixa.get.ibge == "3300225"); assert(faixa.get.cep_ini == "25845001"); assert(faixa.get.cep_fim == "25849899"); assert(faixa.get.ceps == 118); assert(faixa.get.localidadeUf == "Areal/RJ"); } @("a faixa that names no municipality is absent, not an error") unittest { import std.json : parseJSON; assert(Faixa.fromJSON(parseJSON(`{}`)).isNull); assert(Faixa.fromJSON(parseJSON(`{"localidade":"","uf":"RJ"}`)).isNull); assert(Faixa.fromJSON(parseJSON(`"Areal"`)).isNull); assert(Faixa.fromJSON(parseJSON(`null`)).isNull); assert(Faixa.fromJSON(JSONValue.init).isNull); } @("a partial faixa keeps what it has") unittest { import std.json : parseJSON; const faixa = Faixa.fromJSON(parseJSON(`{"localidade":"Areal","ceps":"118"}`)); assert(!faixa.isNull); assert(faixa.get.ceps == 118); assert(faixa.get.cep_ini == ""); assert(faixa.get.localidadeUf == "Areal"); } @("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); }