# cepis Kotlin client for the [public CEP API at cep.is](https://cep.is). Official Correios data (DNE), refreshed every two weeks. ```kotlin import org.cepis.lookup val address = lookup("01001-000") println("${address.logradouro} ${address.localidade} ${address.uf}") // Praça da Sé São Paulo SP ``` No runtime dependencies: HTTP is `java.net.http.HttpClient` from the JDK, and the JSON and CSV readers are ~200 lines in this library — the API's payloads are small and fixed, which is cheaper than adding `kotlinx.serialization` and its compiler plugin to every consumer's build. ## Installation Requires Java 17 or newer. ```kotlin repositories { mavenCentral() // plus wherever you host it } dependencies { implementation("org.cepis:cepis:0.2.0") } ``` ## Usage ### A single lookup `lookup` accepts the CEP masked or unmasked and returns an `Address`: ```kotlin val a = lookup("01310-100") a.logradouro // "Avenida Paulista" a.localidade // "São Paulo" a.uf // "SP" a.ibge // "3550308" a.cep // "01310100" (always unmasked) a.formattedCep // "01310-100" ``` Data fields keep the API's own Portuguese names, so what you read in the JSON is what you access here. Only the casing is adapted where Kotlin demands it: the API's `raio_m` is `raioM` and its `base_gerada_em` is `baseGeradaEm`. Fields the database does not have for that CEP come back as empty strings, never `null`. `a.raw` holds the response exactly as it arrived, so a field the API grows before this library is updated is not lost. ### Coordinates About 57% of CEPs have a coordinate. When they do not, `geo` is `null`: ```kotlin a.geo?.let { geo -> println("${geo.lat} ${geo.lon}") // -23.567202 -46.649659 println(geo.raioM) // 1084 println(geo.precisao) // "logradouro" } ``` **Check `precisao` before computing distance.** There are two kinds of point, and the difference between them is tens of kilometres: | `precisao` | typical radius | what the point is | | --- | --- | --- | | `logradouro` | 110–140 m | the stretch of street — good for maps and distance | | `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city | `geo.isPrecise` is the shorthand for `precisao == "logradouro"`: ```kotlin val geo = a.geo val cost = if (geo != null && geo.isPrecise) { shippingFrom(geo.lat, geo.lon) } else { shippingFromMunicipality(a.ibge) } ``` ### Many CEPs at once Do not loop over `lookup`. Use `lookupMany`, which resolves up to 500 CEPs per request: ```kotlin val ceps = listOf("01001000", "99999999", "20040020") ceps.zip(lookupMany(ceps)).forEach { (requested, address) -> if (address == null) println("$requested: not found") else println("$requested: ${address.localidade}") } ``` The returned list has **the same length and the same order** as the input. A CEP that does not exist, or is malformed, becomes `null` at its position — it does not vanish, and it does not throw. Lists longer than 500 are chunked automatically. > **Batches carry no coordinate.** The endpoint behind them is CSV, and the > CSV has no `geo` column. Every `Address` from `lookupMany` has `geo == null`, > including for CEPs that do have one. Need `geo`? Use `lookup`, one at a time. ### API health Useful for two things: checking the API answers, and knowing **which version of the database** is live: ```kotlin val h = health() h.ok // true h.dne // "26071" identifier of the fortnightly database h.ceps // 1596168 h.baseGeradaEm // "2026-07-24T03:48:28Z" ``` An API reporting itself degraded does not throw — that arrives in `h.status`. ## Configuration For anything beyond the defaults, build a `Client` and reuse it. It keeps no state between calls and is safe to share across threads: ```kotlin import org.cepis.Client import java.time.Duration val client = Client( baseUrl = "https://cep.is", // point at another instance timeout = Duration.ofSeconds(10), // per attempt: connect, then response maxAttempts = 3, // counting the first; 1 disables retrying 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.2.0 ) val address = client.lookup("01001000") ``` Retries cover 429, 5xx and network failures, with exponential backoff and jitter; 400 and 404 are never retried. The top-level functions (`lookup`, `lookupMany`, `health`) use a shared default client. It applies process-wide, including to third-party libraries that also use this module. Need a different configuration? Build your own. ### Pointing at a local instance `baseUrl` is the only parameter that usually changes between environments. The API repo brings a local instance up with `just up`: ```kotlin val client = Client(baseUrl = "http://localhost:8080") ``` ## Errors Everything derives from `CepisException`, a `sealed` class, so one `catch` handles any failure and a `when` over it is exhaustive. Below that, the split 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; may carry `faixa` | no | | `RateLimitedException` | 429; carries `retryAfter` | yes, later | | `ServerErrorException` | 5xx; carries `status` | yes, later | | `NetworkException` | DNS, connection, timeout, TLS | yes, later | | `InvalidResponseException` | unintelligible response | probably not | ```kotlin try { val address = lookup(cepFromForm) } catch (e: CepNotFoundException) { // ask the user for another CEP } catch (e: CepisException) { // transient failure: reschedule } ``` ### 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. `InvalidResponseException` almost always means something between you and the 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. ## Development Everything runs through `make`, which shells out to the Gradle wrapper — there is nothing to install first beyond a JDK 17: ```sh make # list the targets make test # run the test suite make lint # ktlint, touches nothing make format # apply ktlint (writes) make build # jar + sources jar into build/libs/ make check # lint, then test, then build ``` The tests stand up a real `com.sun.net.httpserver.HttpServer` on a loopback port and point a real `Client` at it through `baseUrl`. Nothing is mocked and nothing touches the network. ## Licence ISC. See [LICENSE](LICENSE).