summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md49
-rw-r--r--build.gradle.kts2
-rw-r--r--src/main/kotlin/org/cepis/Cepis.kt2
-rw-r--r--src/main/kotlin/org/cepis/Client.kt29
-rw-r--r--src/main/kotlin/org/cepis/Errors.kt15
-rw-r--r--src/main/kotlin/org/cepis/Models.kt78
-rw-r--r--src/test/kotlin/org/cepis/LookupTest.kt61
-rw-r--r--src/test/kotlin/org/cepis/ServerTest.kt13
8 files changed, 241 insertions, 8 deletions
diff --git a/README.md b/README.md
index 4278f8a..b8da7ca 100644
--- a/README.md
+++ b/README.md
@@ -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"