summaryrefslogtreecommitdiffstats
path: root/source/cepis/model.d
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:47:38 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:47:38 -0300
commit794f28199a4dde9d18ec711864336c96c58bb422 (patch)
tree3985f1c033a9c62a264829cd354da9ce214e5db7 /source/cepis/model.d
downloadd-794f28199a4dde9d18ec711864336c96c58bb422.tar.gz
Initial importv0.1.0
Diffstat (limited to 'source/cepis/model.d')
-rw-r--r--source/cepis/model.d379
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);
+}