summaryrefslogtreecommitdiffstats
path: root/source
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:37:09 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:37:09 -0300
commita891657aa43d2aba06a6347dd2f94edd19afd6ba (patch)
treeb6369ed2c8485455f9c1aa707c1cd287cb8e7065 /source
parent794f28199a4dde9d18ec711864336c96c58bb422 (diff)
downloadd-0.2.0.tar.gz
Carry the municipality a missing CEP would belong toHEADv0.2.0main
A 404 is rarely a random number: it is usually someone who knows the city and guessed the digits, and the classic guess is the -000 that 957 municipalities never received. The API now attaches a faixa to the not-found body naming the municipality that owns the range, so the caller can say "no such CEP, but that range is Areal/RJ" instead of only "no". It stays an error. Faixa hangs off CepNotFoundException, never off Address, and parsing is deliberately lenient: an absent, malformed or unnamed block yields a null Nullable, so reading the extra context can never cost the caller the answer it already had. cep_ini and cep_fim are the smallest and largest CEP that actually exist there, not the bounds the Correios publish, and the range between them is sparse — hence ceps, so nobody mistakes the pair for a list.
Diffstat (limited to 'source')
-rw-r--r--source/cepis/client.d37
-rw-r--r--source/cepis/exception.d27
-rw-r--r--source/cepis/model.d129
-rw-r--r--source/cepis/package.d4
4 files changed, 191 insertions, 6 deletions
diff --git a/source/cepis/client.d b/source/cepis/client.d
index a93af7f..ef2f10e 100644
--- a/source/cepis/client.d
+++ b/source/cepis/client.d
@@ -37,7 +37,7 @@ import cepis.retry;
@safe:
/// The version of this library, as sent in the `User-Agent`.
-enum string VERSION = "0.1.0";
+enum string VERSION = "0.2.0";
/// Where the public API lives.
enum string DEFAULT_URL = "https://cep.is";
@@ -232,11 +232,16 @@ struct Client
* API's own message: the format already passed local validation, so the
* server knows something this client does not.
*
+ * A 404 may carry a `faixa` — the municipality the CEP would have belonged
+ * to — which lands on $(D CepNotFoundException.faixa). It is still a 404:
+ * the block is context for the error and never becomes an $(D Address).
+ *
* Throws:
* $(UL
* $(LI $(D InvalidCepException) — not 8 digits (no request is made).)
* $(LI $(D CepNotFoundException) — well-formed, but not in the
- * database.)
+ * database; carries $(D faixa) when the API named a
+ * municipality.)
* $(LI $(D RateLimitedException) — 429 after every attempt.)
* $(LI $(D ServerException) — 5xx after every attempt.)
* $(LI $(D NetworkException) — the request never completed.)
@@ -250,7 +255,7 @@ struct Client
const response = request("/" ~ number, [ushort(200)]);
if (response.status == 404)
- throw new CepNotFoundException(number);
+ throw new CepNotFoundException(number, notFoundFaixa(response.content));
if (response.status == 400)
throw new InvalidCepException(errorMessage(response.content,
"CEP " ~ number ~ " rejected"));
@@ -540,6 +545,32 @@ private string errorMessage(string content, string fallback)
return fallback;
}
+/**
+ * Pull the `faixa` block out of a 404 body.
+ *
+ * Every failure here — a body that is not JSON, a `faixa` that is missing,
+ * empty or malformed — is null, not an exception. The answer to the question
+ * asked was "this CEP does not exist"; failing to read the extra context the
+ * API attached to it must never cost the caller that answer.
+ */
+private Nullable!Faixa notFoundFaixa(string content)
+{
+ try
+ {
+ const value = parseJSON(content);
+ if (value.type == JSONType.object)
+ {
+ const object = value.objectNoRef;
+ if (auto block = "faixa" in object)
+ return Faixa.fromJSON(*block);
+ }
+ }
+ catch (Exception)
+ {
+ }
+ return Nullable!Faixa.init;
+}
+
private string preview(string content)
{
return `"` ~ sanitize(content).byDchar.take(120).array.to!string ~ `"`;
diff --git a/source/cepis/exception.d b/source/cepis/exception.d
index 9b77449..d34f725 100644
--- a/source/cepis/exception.d
+++ b/source/cepis/exception.d
@@ -26,6 +26,8 @@ module cepis.exception;
import core.time : Duration;
import std.typecons : Nullable;
+import cepis.model : Faixa;
+
@safe:
/**
@@ -74,17 +76,36 @@ class InvalidCepException : CepisException
}
}
-/// The CEP is well-formed but does not exist in the DNE database.
+/**
+ * The CEP is well-formed but does not exist in the DNE database.
+ *
+ * When the API can say which municipality the number $(I would) have belonged
+ * to, that arrives in $(D faixa) and the message names it. It is $(B context
+ * for the error), not a result: the CEP still does not exist.
+ */
class CepNotFoundException : CepisException
{
/// The CEP that was looked up, unmasked.
const string cep;
+ /**
+ * The municipality whose range the CEP falls in, or null.
+ *
+ * Null whenever the API sent no usable `faixa` — including the ordinary
+ * case of a CEP in a band no municipality owns, where the API $(B omits)
+ * the field rather than sending null. Either way the lookup is still a
+ * not-found; see $(D Faixa) before showing any of it to a user.
+ */
+ const Nullable!Faixa faixa;
+
///
- this(string cep, string file = __FILE__, size_t line = __LINE__) pure nothrow
+ this(string cep, Nullable!Faixa faixa = Nullable!Faixa.init,
+ string file = __FILE__, size_t line = __LINE__) pure nothrow
{
- super("CEP " ~ cep ~ " does not exist in the DNE database", file, line);
+ super("CEP " ~ cep ~ " does not exist in the DNE database" ~ (faixa.isNull
+ ? "" : " (that range belongs to " ~ faixa.get.localidadeUf ~ ")"), file, line);
this.cep = cep;
+ this.faixa = faixa;
}
}
diff --git a/source/cepis/model.d b/source/cepis/model.d
index 9ec3ffb..01692dd 100644
--- a/source/cepis/model.d
+++ b/source/cepis/model.d
@@ -201,6 +201,92 @@ struct 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
@@ -365,6 +451,49 @@ unittest
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
{
diff --git a/source/cepis/package.d b/source/cepis/package.d
index 073d377..9a8c433 100644
--- a/source/cepis/package.d
+++ b/source/cepis/package.d
@@ -41,6 +41,10 @@
* if (error.retriable) reschedule(); // network, server, rate limit
* ---
*
+ * A not-found sometimes knows where the CEP $(I would) have been — see
+ * $(D CepNotFoundException.faixa) and $(D Faixa). That is context for the
+ * error, never an address.
+ *
* Note on field names: the API surface is English, but the $(B data fields keep
* the API's own Portuguese names) ($(D logradouro), $(D bairro),
* $(D localidade)). What you read in the JSON is what you access on the value.