/** * 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); }