diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:22:17 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:23:14 -0300 |
| commit | 516254878b0b8f92a8ef4543bf5d0601aac3466d (patch) | |
| tree | bbe750f8863fe2ee16bbee405caf0c2e58694dfe /src/main | |
| parent | 0757164507a1f9be8b9f61292a9069759f1da034 (diff) | |
| download | kotlin-0.2.0.tar.gz | |
A CEP that fails is almost never a random number: it is someone who knows
the city and guessed the digits, most often the -000 that 957 municipalities
never received. The API now names the municipality that number would belong
to, so the client surfaces it as CepNotFoundException.faixa instead of
throwing away the one piece of information that makes the failure useful.
It stays an exception. The block is context for a 404, not an address, and
cep_ini/cep_fim are the smallest and largest CEP that actually exist there
rather than the range the Correios publish -- the published start for Areal
is the very CEP that just failed. The range is sparse (118 CEPs across some
9,000 numbers), which is what ceps is for.
Parsing is deliberately total: absent, null, empty or malformed all yield
null. Losing a clean not-found to a parse error over an optional field would
be a bad trade.
Diffstat (limited to 'src/main')
| -rw-r--r-- | src/main/kotlin/org/cepis/Cepis.kt | 2 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Client.kt | 29 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Errors.kt | 15 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Models.kt | 78 |
4 files changed, 120 insertions, 4 deletions
diff --git a/src/main/kotlin/org/cepis/Cepis.kt b/src/main/kotlin/org/cepis/Cepis.kt index b9e0e34..2cd3516 100644 --- a/src/main/kotlin/org/cepis/Cepis.kt +++ b/src/main/kotlin/org/cepis/Cepis.kt @@ -11,7 +11,7 @@ const val DEFAULT_BASE_URL: String = "https://cep.is" const val MAX_BATCH: Int = 500 /** This library's version, as it appears in the default `User-Agent`. */ -const val VERSION: String = "0.1.0" +const val VERSION: String = "0.2.0" private val DIGITS_ONLY = Regex("""^\d{8}$""") diff --git a/src/main/kotlin/org/cepis/Client.kt b/src/main/kotlin/org/cepis/Client.kt index e315b74..6a054ae 100644 --- a/src/main/kotlin/org/cepis/Client.kt +++ b/src/main/kotlin/org/cepis/Client.kt @@ -95,6 +95,10 @@ class 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] in [CepNotFoundException.faixa] naming the + * municipality the CEP would have belonged to. It is still a 404 — the + * block is context for the failure, not an address. + * * @throws InvalidCepException the CEP is not 8 digits (no request is made). * @throws CepNotFoundException well-formed, but not in the database. * @throws RateLimitedException 429 after every attempt. @@ -107,7 +111,12 @@ class Client( val reply = request("/$number") if (reply.status == 404) { - throw CepNotFoundException("CEP $number does not exist in the DNE database") + val faixa = faixaFrom(reply.body) + val where = faixa?.let { " — that number falls in ${it.localidade}/${it.uf}" } ?: "" + throw CepNotFoundException( + "CEP $number does not exist in the DNE database$where", + faixa, + ) } if (reply.status == 400) { throw InvalidCepException(errorMessage(reply.body, "CEP $number rejected")) @@ -210,6 +219,24 @@ class Client( return parsed as Map<String, Any?> } + /** + * The `faixa` block of a 404 body, or `null`. + * + * Swallows everything: a body that is not JSON, is not an object, or has + * no usable `faixa`. The lookup has already failed and the caller is going + * to get [CepNotFoundException] either way — losing that exception to a + * parse error over an optional field would be a bad trade. + */ + private fun faixaFrom(body: ByteArray): Faixa? { + val parsed = + try { + Json.parse(body.toString(StandardCharsets.UTF_8)) + } catch (e: JsonSyntaxException) { + return null + } + return Faixa.fromJson((parsed as? Map<*, *>)?.get("faixa")) + } + private fun errorMessage( body: ByteArray, fallback: String, diff --git a/src/main/kotlin/org/cepis/Errors.kt b/src/main/kotlin/org/cepis/Errors.kt index 8d3f1ff..d80df57 100644 --- a/src/main/kotlin/org/cepis/Errors.kt +++ b/src/main/kotlin/org/cepis/Errors.kt @@ -40,8 +40,19 @@ sealed class CepisException( */ class InvalidCepException(message: String) : CepisException(message) -/** The CEP is well-formed but does not exist in the DNE database. */ -class CepNotFoundException(message: String) : CepisException(message) +/** + * The CEP is well-formed but does not exist in the DNE database. + * + * @property faixa the municipality the CEP would have belonged to, when the + * API can name one, and `null` when the number falls in a band no + * municipality owns. It is **context for the failure, not an address**: this + * is still a 404 and there is still no CEP. Read [Faixa] before showing its + * range to anyone — the two endpoints are not a list of valid CEPs. + */ +class CepNotFoundException( + message: String, + val faixa: Faixa? = null, +) : CepisException(message) /** * The request never completed: DNS, refused connection, timeout, TLS. diff --git a/src/main/kotlin/org/cepis/Models.kt b/src/main/kotlin/org/cepis/Models.kt index 058244e..7841082 100644 --- a/src/main/kotlin/org/cepis/Models.kt +++ b/src/main/kotlin/org/cepis/Models.kt @@ -39,6 +39,84 @@ data class Geo( } /** + * The municipality a **nonexistent** CEP would have belonged to. + * + * This is context for an error, never an address. It arrives on the 404 of + * [Client.lookup], in [CepNotFoundException.faixa], and it does not make the + * CEP exist: the lookup still failed. + * + * It is there because a CEP that fails is rarely a random number — it is + * usually someone who knows the city and guessed the digits. The classic guess + * is the `-000`, which 957 municipalities never received. + * + * Two things about the range that are easy to get wrong: + * + * - [cepIni] and [cepFim] are the smallest and the largest CEP that **actually + * exist** in that municipality, not the bounds the Correios publish. For + * Areal the DNE's own start is `25845000` — which is exactly the CEP that + * just failed. Echoing that back would be useless, so the API does not. + * - The range is **sparse**. Areal's 118 CEPs are spread across some 4,900 + * numbers. [cepIni]/[cepFim] are two endpoints, not a list of valid CEPs, + * and [ceps] is there to keep that from being forgotten. + * + * @property localidade the municipality's name. + * @property uf its two-letter state code. + * @property estado the state's full name. + * @property regiao the region of Brazil it sits in. + * @property ibge the municipality's IBGE code — the field to use to look the + * place up somewhere else. + * @property cepIni the smallest CEP that exists there. The API calls this + * field `cep_ini`. + * @property cepFim the largest CEP that exists there. The API calls this field + * `cep_fim`. + * @property ceps how many CEPs exist between those two endpoints. Almost + * always far fewer than the numeric distance between them. + */ +data class Faixa( + val localidade: String = "", + val uf: String = "", + val estado: String = "", + val regiao: String = "", + val ibge: String = "", + val cepIni: String = "", + val cepFim: String = "", + val ceps: Int = 0, +) { + companion object { + /** + * Build from the `faixa` block of a 404 body, or `null`. + * + * Takes `Any?` rather than a map, and returns `null` for anything that + * is not a usable block — absent, not an object, empty, or without a + * municipality name. The API **omits** `faixa` (it never sends null) + * when the CEP falls in a numeric band no municipality owns, and that + * is the common case this has to survive. + * + * Nothing here throws, deliberately. The caller is already handling a + * failed lookup; a surprise in this block must not turn a clean + * [CepNotFoundException] into a parse error. + */ + fun fromJson(block: Any?): Faixa? { + if (block !is Map<*, *>) return null + @Suppress("UNCHECKED_CAST") + val fields = block as Map<String, Any?> + val localidade = fields.text("localidade") + if (localidade.isEmpty()) return null + return Faixa( + localidade = localidade, + uf = fields.text("uf"), + estado = fields.text("estado"), + regiao = fields.text("regiao"), + ibge = fields.text("ibge"), + cepIni = fields.text("cep_ini"), + cepFim = fields.text("cep_fim"), + ceps = fields.number("ceps")?.toInt() ?: 0, + ) + } + } +} + +/** * A CEP and everything the database knows about it. * * **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`, |