diff options
| -rw-r--r-- | README.md | 49 | ||||
| -rw-r--r-- | build.gradle.kts | 2 | ||||
| -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 | ||||
| -rw-r--r-- | src/test/kotlin/org/cepis/LookupTest.kt | 61 | ||||
| -rw-r--r-- | src/test/kotlin/org/cepis/ServerTest.kt | 13 |
8 files changed, 241 insertions, 8 deletions
@@ -27,7 +27,7 @@ repositories { } dependencies { - implementation("org.cepis:cepis:0.1.0") + implementation("org.cepis:cepis:0.2.0") } ``` @@ -145,7 +145,7 @@ val client = backoffBase = Duration.ofMillis(500), // base of the exponential wait backoffMax = Duration.ofSeconds(8), // cap on each wait totalTimeout = null, // cap on the whole call, attempts included - userAgent = null, // defaults to cepis-kotlin/0.1.0 + userAgent = null, // defaults to cepis-kotlin/0.2.0 ) val address = client.lookup("01001000") @@ -176,7 +176,7 @@ is by **the decision you have to make**, not by the HTTP status that caused it: | error | 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 | | `ServerErrorException` | 5xx; carries `status` | yes, later | | `NetworkException` | DNS, connection, timeout, TLS | yes, later | @@ -192,6 +192,49 @@ try { } ``` +### When the CEP does not exist + +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. So `CepNotFoundException` carries a `faixa` +naming the municipality the number would have belonged to: + +```kotlin +try { + lookup("25845-000") +} catch (e: CepNotFoundException) { + val f = e.faixa + if (f != null) { + println("${f.localidade}/${f.uf}") // Areal/RJ + println(f.ibge) // "3300225" + println("${f.cepIni}..${f.cepFim}") // 25845001..25849899 + println(f.ceps) // 118 + } +} +``` + +Three things to keep straight: + +- **It is still a 404.** `faixa` is context for the failure, not an address. + The CEP does not exist; do not let the block turn a not-found into a + success. +- **`cepIni` and `cepFim` are the smallest and the largest CEP that actually + exist** in that municipality, not the bounds the Correios publish. The DNE's + own start for Areal is `25845000` — precisely the CEP that just failed, so + echoing it back would help nobody. +- **The range is sparse.** Areal's 118 CEPs are spread over some 4,900 + numbers; that is what `ceps` is for. Never present the pair as a list of + valid CEPs — "try something between 25845001 and 25849899" is wrong advice + 99% of the time. + +`faixa` is **omitted, never null**, when the number falls in a band no +municipality owns, so `null` is a normal outcome and not an error. A missing, +malformed or empty block simply yields `null`: it can never cost you the +`CepNotFoundException` itself. + +`lookupMany` is unaffected — the CSV endpoint behind it has no such column, +and a CEP that does not exist is still `null` at its position. + A nonsensical `Client` configuration throws a plain `IllegalArgumentException` instead: that is a programming error, not a lookup failure. diff --git a/build.gradle.kts b/build.gradle.kts index 31ce4a7..1813169 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "org.cepis" -version = "0.1.0" +version = "0.2.0" repositories { mavenCentral() 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`, diff --git a/src/test/kotlin/org/cepis/LookupTest.kt b/src/test/kotlin/org/cepis/LookupTest.kt index c266025..e106f70 100644 --- a/src/test/kotlin/org/cepis/LookupTest.kt +++ b/src/test/kotlin/org/cepis/LookupTest.kt @@ -79,6 +79,67 @@ internal class LookupTest : ServerTest() { } @Test + fun `not found names the municipality the cep would belong to`() { + server.respond("/25845000", Reply(404, Fixtures.NOT_FOUND_WITH_FAIXA_JSON)) + val error = assertFailsWith<CepNotFoundException> { client.lookup("25845-000") } + val faixa = assertNotNull(error.faixa) + + assertEquals("Areal", faixa.localidade) + assertEquals("RJ", faixa.uf) + assertEquals("Rio de Janeiro", faixa.estado) + assertEquals("Sudeste", faixa.regiao) + assertEquals("3300225", faixa.ibge) + assertEquals("25845001", faixa.cepIni) + assertEquals("25849899", faixa.cepFim) + assertEquals(118, faixa.ceps) + assertTrue(error.message!!.contains("Areal")) + } + + @Test + fun `not found without a faixa is still a clean not found`() { + server.respond("/99999999", Reply(404, Fixtures.NOT_FOUND_JSON)) + val error = assertFailsWith<CepNotFoundException> { client.lookup("99999999") } + + assertNull(error.faixa) + assertTrue(error.message!!.contains("99999999")) + } + + @Test + fun `an unusable faixa does not cost the not found`() { + val bodies = + listOf( + """{"erro":"cep_nao_encontrado","faixa":null}""", + """{"erro":"cep_nao_encontrado","faixa":{}}""", + """{"erro":"cep_nao_encontrado","faixa":"Areal"}""", + """{"erro":"cep_nao_encontrado","faixa":{"uf":"RJ"}}""", + """{"erro":"cep_nao_encontrado","faixa":""", + "<html>not found</html>", + "", + ) + for (body in bodies) { + server.respond("/99999999", Reply(404, body)) + val error = assertFailsWith<CepNotFoundException>(body) { client.lookup("99999999") } + + assertNull(error.faixa, body) + } + } + + @Test + fun `a faixa missing a field keeps the rest`() { + val body = + """{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal","uf":"RJ"}}""" + server.respond("/25845000", Reply(404, body)) + val faixa = + assertNotNull( + assertFailsWith<CepNotFoundException> { client.lookup("25845000") }.faixa, + ) + + assertEquals("Areal", faixa.localidade) + assertEquals("", faixa.cepIni) + assertEquals(0, faixa.ceps) + } + + @Test fun `not found is not retried`() { server.respond("/99999999", Reply(404, """{"erro":"x"}""")) assertFailsWith<CepNotFoundException> { client.lookup("99999999") } diff --git a/src/test/kotlin/org/cepis/ServerTest.kt b/src/test/kotlin/org/cepis/ServerTest.kt index b22975a..8175190 100644 --- a/src/test/kotlin/org/cepis/ServerTest.kt +++ b/src/test/kotlin/org/cepis/ServerTest.kt @@ -40,6 +40,19 @@ internal object Fixtures { "ddd":"11","siafi":"7107"} """ + /** A 404 body carrying the municipality the CEP would have belonged to. */ + const val NOT_FOUND_WITH_FAIXA_JSON = """ + {"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}} + """ + + /** A 404 for a number in a band no municipality owns: `faixa` is absent. */ + const val NOT_FOUND_JSON = """ + {"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"} + """ + const val CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado," + "regiao,ibge,gia,ddd,siafi" |