diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:37:09 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:37:09 -0300 |
| commit | a891657aa43d2aba06a6347dd2f94edd19afd6ba (patch) | |
| tree | b6369ed2c8485455f9c1aa707c1cd287cb8e7065 | |
| parent | 794f28199a4dde9d18ec711864336c96c58bb422 (diff) | |
| download | d-0.2.0.tar.gz | |
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.
| -rw-r--r-- | README.md | 47 | ||||
| -rw-r--r-- | dub.json | 2 | ||||
| -rw-r--r-- | source/cepis/client.d | 37 | ||||
| -rw-r--r-- | source/cepis/exception.d | 27 | ||||
| -rw-r--r-- | source/cepis/model.d | 129 | ||||
| -rw-r--r-- | source/cepis/package.d | 4 | ||||
| -rw-r--r-- | test/cepis_test.d | 99 |
7 files changed, 335 insertions, 10 deletions
@@ -21,7 +21,7 @@ Or in `dub.json`: ```json "dependencies": { - "cepis": "~>0.1.0" + "cepis": "~>0.2.0" } ``` @@ -166,7 +166,7 @@ status that caused it: | exception | when | does retrying help? | | --- | --- | --- | | `InvalidCepException` | not 8 digits | no | -| `CepNotFoundException` | well-formed, not in the database | no | +| `CepNotFoundException` | well-formed, not in the database; may carry `faixa` | no | | `RateLimitedException` | 429; carries `retryAfter` | yes, later | | `ServerException` | 5xx; carries `status` | yes, later | | `NetworkException` | DNS, connection, timeout, TLS | yes, later | @@ -190,6 +190,49 @@ catch (CepisException error) API is answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful as a diagnosis: the problem is not in your code. +### When the CEP does not exist + +A 404 sometimes knows *where* the CEP would have been. `CepNotFoundException` +carries it in `faixa`: the municipality that owns the range the number falls +in. + +```d +try + render(lookup("25845-000")); +catch (CepNotFoundException error) +{ + if (error.faixa.isNull) + askForAnotherCep(); + else + { + auto f = error.faixa.get; + writeln("no such CEP — that range is ", f.localidade, "/", f.uf); + writeln(f.ceps, " CEPs, from ", f.cep_ini, " to ", f.cep_fim); + // 118 CEPs, from 25845001 to 25849899 + } +} +``` + +It is **context for an error, not an address.** The status is still 404 and the +CEP still does not exist; `faixa` never becomes an `Address` and must not be +rendered as if the lookup had worked. + +`cep_ini` and `cep_fim` are the smallest and the largest CEP that **actually +exist** there — not the bounds the Correios publish. The DNE's own start for +Areal is `25845000`, which is exactly the CEP that just failed. + +The range is **sparse**: Areal's 118 CEPs are scattered across the ~4,900 +numbers between `25845001` and `25849899`. That is what `ceps` is for. Never +present the pair as the list of valid CEPs for the city, and do not walk it +expecting the numbers in between to resolve. + +`faixa` is null whenever the API sent none — and it is **omitted, never null**, +when the CEP falls in a band no municipality owns. + +Why it exists: 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. + ## Development Everything runs through `make`, which shells out to `dub`: @@ -1,6 +1,6 @@ { "name": "cepis", - "version": "0.1.0", + "version": "0.2.0", "description": "Client for the public CEP API at cep.is (official Correios/DNE data)", "authors": [ "Murilo Ijanc <[email protected]>" 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. diff --git a/test/cepis_test.d b/test/cepis_test.d index 3ced878..694da0f 100644 --- a/test/cepis_test.d +++ b/test/cepis_test.d @@ -16,7 +16,7 @@ module cepis_test; import core.sync.mutex : Mutex; import core.thread : Thread; import core.time : Duration, MonoTime, dur; -import std.algorithm : filter, map, startsWith; +import std.algorithm : canFind, filter, map, startsWith; import std.array : array, join; import std.conv : to; import std.exception : assertThrown; @@ -35,6 +35,22 @@ private enum string SE_ADDRESS = `{ "ddd": "11", "siafi": "7107" }`; +/** + * A 404 that knows which municipality the CEP would have belonged to. + * + * `25845000` is the DNE's own start for Areal and does not exist; the smallest + * one that does is `25845001`. Fixed here rather than fetched: production does + * not serve `faixa` yet, so a live call would pass for the wrong reason. + */ +private enum string AREAL_NOT_FOUND = `{ + "erro": "cep_nao_encontrado", "mensagem": "CEP não existe na base do DNE", + "faixa": { + "localidade": "Areal", "uf": "RJ", "estado": "Rio de Janeiro", + "regiao": "Sudeste", "ibge": "3300225", "cep_ini": "25845001", + "cep_fim": "25849899", "ceps": 118 + } +}`; + private enum string CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf," ~ "estado,regiao,ibge,gia,ddd,siafi"; @@ -339,6 +355,87 @@ unittest assert(server.calledTimes("/99999999") == 1); } +@("a not-found says which municipality the cep would have belonged to") +unittest +{ + auto server = new TestServer(); + server.respond("/25845000", Reply(404, AREAL_NOT_FOUND)); + + auto client = server.client; + bool caught; + try + client.lookup("25845-000"); + catch (CepNotFoundException error) + { + caught = true; + assert(error.cep == "25845000"); + assert(!error.retriable); + assert(!error.faixa.isNull); + + const faixa = error.faixa.get; + assert(faixa.localidade == "Areal"); + assert(faixa.uf == "RJ"); + assert(faixa.estado == "Rio de Janeiro"); + assert(faixa.regiao == "Sudeste"); + assert(faixa.ibge == "3300225"); + assert(faixa.cep_ini == "25845001"); + assert(faixa.cep_fim == "25849899"); + assert(faixa.ceps == 118); + assert(error.msg.canFind("Areal/RJ")); + } + + assert(caught); + assert(server.calledTimes("/25845000") == 1); +} + +@("a not-found in a band nobody owns carries no faixa") +unittest +{ + auto server = new TestServer(); + server.respond("/99999999", Reply(404, + `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}`)); + + auto client = server.client; + bool caught; + try + client.lookup("99999999"); + catch (CepNotFoundException error) + { + caught = true; + assert(error.faixa.isNull); + assert(error.msg.canFind("99999999")); + } + + assert(caught); +} + +@("a malformed faixa is still an ordinary not-found") +unittest +{ + foreach (content; [ + `{"erro":"cep_nao_encontrado","faixa":{}}`, + `{"erro":"cep_nao_encontrado","faixa":{"uf":"RJ"}}`, + `{"erro":"cep_nao_encontrado","faixa":"Areal"}`, + `{"erro":"cep_nao_encontrado","faixa":null}`, `<html>gateway</html>`, `` + ]) + { + auto server = new TestServer(); + server.respond("/99999999", Reply(404, content)); + + auto client = server.client; + bool caught; + try + client.lookup("99999999"); + catch (CepNotFoundException error) + { + caught = true; + assert(error.faixa.isNull); + } + + assert(caught); + } +} + @("an invalid cep never leaves the machine") unittest { |