summaryrefslogtreecommitdiffstats
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/kotlin/org/cepis/Cepis.kt70
-rw-r--r--src/main/kotlin/org/cepis/Client.kt372
-rw-r--r--src/main/kotlin/org/cepis/Csv.kt93
-rw-r--r--src/main/kotlin/org/cepis/Errors.kt88
-rw-r--r--src/main/kotlin/org/cepis/Json.kt218
-rw-r--r--src/main/kotlin/org/cepis/Models.kt167
6 files changed, 1008 insertions, 0 deletions
diff --git a/src/main/kotlin/org/cepis/Cepis.kt b/src/main/kotlin/org/cepis/Cepis.kt
new file mode 100644
index 0000000..b9e0e34
--- /dev/null
+++ b/src/main/kotlin/org/cepis/Cepis.kt
@@ -0,0 +1,70 @@
+@file:JvmName("Cepis")
+
+package org.cepis
+
+/**
+ * Where the public API lives. Pass [Client]'s `baseUrl` to point elsewhere.
+ */
+const val DEFAULT_BASE_URL: String = "https://cep.is"
+
+/** The most CEPs the batch endpoint accepts in one request. */
+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"
+
+private val DIGITS_ONLY = Regex("""^\d{8}$""")
+
+/**
+ * The shared client behind the top-level [lookup], [lookupMany] and [health].
+ *
+ * Built once because [Client] keeps no state between calls and is thread-safe.
+ * It applies process-wide, including to third-party libraries that also use
+ * this module. Anyone needing different settings should build their own.
+ */
+private val defaultClient: Client by lazy { Client() }
+
+/**
+ * Return the CEP's 8 digits, accepting the mask.
+ *
+ * `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+ *
+ * This runs **before** any request: a 7-digit CEP is the caller's bug, and
+ * spending a round trip for the server to say so wastes both sides' time.
+ *
+ * @throws InvalidCepException unless exactly 8 digits remain.
+ */
+fun normalizeCep(value: String): String {
+ val cleaned =
+ value
+ .trim()
+ .replace("-", "")
+ .replace(".", "")
+ .replace(" ", "")
+ if (!DIGITS_ONLY.matches(cleaned)) {
+ throw InvalidCepException("CEP must be 8 digits, got '$value'")
+ }
+ return cleaned
+}
+
+/**
+ * Look up a CEP using the default configuration.
+ *
+ * Shorthand for `Client().lookup(cep)`. See [Client.lookup].
+ */
+fun lookup(cep: String): Address = defaultClient.lookup(cep)
+
+/**
+ * Look up many CEPs in one batch, using the default configuration.
+ *
+ * Shorthand for [Client.lookupMany] — read that one, especially the warning
+ * that batches carry no coordinate.
+ */
+fun lookupMany(ceps: List<String>): List<Address?> = defaultClient.lookupMany(ceps)
+
+/**
+ * Query the API's health using the default configuration.
+ *
+ * Shorthand for [Client.health].
+ */
+fun health(): Health = defaultClient.health()
diff --git a/src/main/kotlin/org/cepis/Client.kt b/src/main/kotlin/org/cepis/Client.kt
new file mode 100644
index 0000000..e315b74
--- /dev/null
+++ b/src/main/kotlin/org/cepis/Client.kt
@@ -0,0 +1,372 @@
+package org.cepis
+
+import java.io.IOException
+import java.net.URI
+import java.net.URLEncoder
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.nio.charset.StandardCharsets
+import java.time.Duration
+import java.time.Instant
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeParseException
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.pow
+import kotlin.random.Random
+
+private val RETRIABLE_STATUS = setOf(429, 500, 502, 503, 504)
+
+private const val ACCEPT_HEADER = "application/json, text/csv;q=0.9, */*;q=0.1"
+
+/**
+ * Client for the cep.is API.
+ *
+ * Reuse one instance: the configuration is immutable and no method keeps state
+ * between calls, so it is safe to share across threads. It wraps one
+ * [java.net.http.HttpClient], which pools connections — a shared instance is
+ * also the cheaper one.
+ *
+ * @param baseUrl where the API lives. Point it at a local instance (the API
+ * repo brings one up on `http://localhost:8080` with `just up`) or at a test
+ * server. It is the only parameter that usually changes between
+ * environments. A trailing slash is stripped, so it cannot produce doubled
+ * separators that some proxies normalise and others 404.
+ * @param timeout how long to wait **per attempt** — it bounds the connection
+ * and then the response of each individual request, not the whole call.
+ * That cap is [totalTimeout].
+ * @param maxAttempts how many attempts in total, counting the first. `1`
+ * disables retrying. The default of `3` covers the common case (one
+ * transient failure) without turning a real outage into a long wait.
+ * @param backoffBase base of the exponential wait. The actual wait is drawn
+ * from `[0, base * 2^(n-1)]` — *full jitter*. The randomness is not
+ * decoration: without it, N clients that fail together come back together
+ * and reproduce the spike that took the service down.
+ * @param backoffMax cap on each individual wait. It applies to a server-sent
+ * `Retry-After` too, or a `Retry-After: 3600` from a misconfigured proxy
+ * would park the thread for an hour.
+ * @param totalTimeout cap on the whole call, attempts and waits included.
+ * `null` leaves [maxAttempts] as the only bound. Set it in code serving a
+ * web request: without it the worst case is `maxAttempts * timeout` plus the
+ * sum of the backoffs.
+ * @param userAgent overrides the `User-Agent`. The default identifies the
+ * library and its version, which is what lets the server diagnose an old
+ * client misbehaving.
+ * @throws IllegalArgumentException if [maxAttempts] is below 1 or [timeout] is
+ * not positive.
+ */
+class Client(
+ baseUrl: String = DEFAULT_BASE_URL,
+ val timeout: Duration = Duration.ofSeconds(10),
+ val maxAttempts: Int = 3,
+ val backoffBase: Duration = Duration.ofMillis(500),
+ val backoffMax: Duration = Duration.ofSeconds(8),
+ val totalTimeout: Duration? = null,
+ userAgent: String? = null,
+) {
+ /** Where the API lives, without a trailing slash. */
+ val baseUrl: String = baseUrl.trimEnd('/')
+
+ /** The `User-Agent` sent with every request. */
+ val userAgent: String = userAgent ?: "cepis-kotlin/$VERSION (+https://cep.is)"
+
+ private val http: HttpClient
+
+ init {
+ require(maxAttempts >= 1) { "maxAttempts must be >= 1" }
+ require(!timeout.isZero && !timeout.isNegative) { "timeout must be > 0" }
+ require(!backoffMax.isNegative) { "backoffMax must be >= 0" }
+ require(!backoffBase.isNegative) { "backoffBase must be >= 0" }
+ http =
+ HttpClient
+ .newBuilder()
+ .connectTimeout(timeout)
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .version(HttpClient.Version.HTTP_1_1)
+ .build()
+ }
+
+ /**
+ * Look up a single CEP, masked or unmasked.
+ *
+ * A 400 from the API is surfaced as [InvalidCepException] carrying the
+ * API's own message: the format already passed local validation, so the
+ * server knows something this client does not.
+ *
+ * @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.
+ * @throws ServerErrorException 5xx after every attempt.
+ * @throws NetworkException the request never completed.
+ * @throws InvalidResponseException the body could not be parsed.
+ */
+ fun lookup(cep: String): Address {
+ val number = normalizeCep(cep)
+ val reply = request("/$number")
+
+ if (reply.status == 404) {
+ throw CepNotFoundException("CEP $number does not exist in the DNE database")
+ }
+ if (reply.status == 400) {
+ throw InvalidCepException(errorMessage(reply.body, "CEP $number rejected"))
+ }
+ return Address.fromJson(jsonObject(reply.body))
+ }
+
+ /**
+ * Look up many CEPs in one batch.
+ *
+ * Returns a list of **the same length and in the same order** as the
+ * input. A CEP that does not exist becomes `null` at its position — it does
+ * not vanish from the list, and it does not throw. That is the right
+ * difference from [lookup]: in a batch of a thousand, one bad CEP must not
+ * cost the other nine hundred and ninety-nine.
+ *
+ * A malformed CEP also becomes `null`, for the same reason, rather than
+ * throwing [InvalidCepException]. If you need to know *which* one was
+ * malformed, validate first with [normalizeCep].
+ *
+ * Input is chunked into blocks of [MAX_BATCH] (the API's limit)
+ * automatically.
+ *
+ * **The batch carries no coordinate.** The endpoint behind it is CSV, and
+ * the CSV has no `geo` column. Every [Address] returned here has
+ * `geo == null`, including for CEPs that do have one. Need `geo`? Use
+ * [lookup], one at a time.
+ *
+ * @throws RateLimitedException 429 after every attempt.
+ * @throws ServerErrorException 5xx after every attempt.
+ * @throws NetworkException the request never completed.
+ */
+ fun lookupMany(ceps: List<String>): List<Address?> {
+ if (ceps.isEmpty()) return emptyList()
+ val result = ArrayList<Address?>(ceps.size)
+ for (chunk in ceps.chunked(MAX_BATCH)) {
+ result.addAll(lookupChunk(chunk))
+ }
+ return result
+ }
+
+ /**
+ * Query `GET /health`: does the API answer, and with which database.
+ *
+ * Does not throw for an API reporting itself degraded — that arrives in
+ * [Health.status], and checking health is exactly when you want the
+ * diagnosis rather than an exception. Network failures still throw.
+ *
+ * @throws NetworkException the request never completed.
+ * @throws InvalidResponseException the body could not be parsed.
+ */
+ fun health(): Health {
+ val reply = request("/health", setOf(200, 503))
+ return Health.fromJson(jsonObject(reply.body))
+ }
+
+ /**
+ * One chunk of at most [MAX_BATCH] CEPs, in a single request.
+ *
+ * A blank CSV row means "not found": the API fills only the `cep` column
+ * and leaves the rest empty, precisely so it does not misalign whoever is
+ * consuming the batch in a spreadsheet.
+ *
+ * The API guarantees one row per requested CEP. Should that ever fail to
+ * hold — a proxy truncating, a cut response — the result is padded to the
+ * input length rather than returned short, because a shifted list silently
+ * pairs the wrong address with the wrong CEP.
+ */
+ private fun lookupChunk(ceps: List<String>): List<Address?> {
+ val query = URLEncoder.encode(ceps.joinToString(","), StandardCharsets.UTF_8)
+ val reply = request("/csv?ceps=$query")
+ val text = reply.body.toString(StandardCharsets.UTF_8).removePrefix("\uFEFF")
+
+ val rows =
+ Csv.rows(text).map { row ->
+ if (row["localidade"].orEmpty().isBlank()) null else Address.fromJson(row)
+ }
+
+ if (rows.size == ceps.size) return rows
+ return List(ceps.size) { index -> rows.getOrNull(index) }
+ }
+
+ private fun jsonObject(body: ByteArray): Map<String, Any?> {
+ val parsed =
+ try {
+ Json.parse(body.toString(StandardCharsets.UTF_8))
+ } catch (e: JsonSyntaxException) {
+ throw InvalidResponseException(
+ "response is not valid JSON — something between you and the API is " +
+ "answering instead of it: ${preview(body)}",
+ e,
+ )
+ }
+ if (parsed !is Map<*, *>) {
+ throw InvalidResponseException(
+ "expected a JSON object, got ${parsed?.javaClass?.simpleName ?: "null"}",
+ )
+ }
+ @Suppress("UNCHECKED_CAST")
+ return parsed as Map<String, Any?>
+ }
+
+ private fun errorMessage(
+ body: ByteArray,
+ fallback: String,
+ ): String {
+ val parsed =
+ try {
+ Json.parse(body.toString(StandardCharsets.UTF_8))
+ } catch (e: JsonSyntaxException) {
+ return fallback
+ }
+ val message = (parsed as? Map<*, *>)?.get("mensagem")?.toString()
+ return if (message.isNullOrEmpty()) fallback else message
+ }
+
+ /**
+ * How long to wait before the next attempt, in seconds.
+ *
+ * The server's `Retry-After` wins over the local calculation when present:
+ * it knows when it will be back, we are guessing. It still goes through the
+ * [backoffMax] cap. Otherwise the wait is drawn from `[0, ceiling]` rather
+ * than being the ceiling — see [backoffBase] on why that randomness
+ * matters.
+ */
+ private fun backoff(
+ attempt: Int,
+ retryAfter: Double?,
+ ): Double {
+ val ceilingMax = backoffMax.toMillis() / 1000.0
+ if (retryAfter != null) return min(retryAfter, ceilingMax)
+ val base = backoffBase.toMillis() / 1000.0
+ val ceiling = min(base * 2.0.pow(attempt - 1), ceilingMax)
+ return if (ceiling <= 0.0) 0.0 else Random.nextDouble(0.0, ceiling)
+ }
+
+ /**
+ * Perform the GET, with retries.
+ *
+ * *Definitive* error statuses (400, 404) are returned normally, for the
+ * caller to turn into the right exception — only transient failures become
+ * exceptions here, and only once the attempts are exhausted.
+ */
+ private fun request(
+ path: String,
+ acceptedStatus: Set<Int> = setOf(200),
+ ): Reply {
+ val url = "$baseUrl$path"
+ val uri =
+ try {
+ URI.create(url)
+ } catch (e: IllegalArgumentException) {
+ throw NetworkException("$url is not a usable URL", e)
+ }
+ val deadline = totalTimeout?.let { System.nanoTime() + it.toNanos() }
+
+ var lastStatus: Int? = null
+ var lastRetryAfter: Double? = null
+ var lastNetworkError: Throwable? = null
+ var attempt = 1
+
+ while (attempt <= maxAttempts) {
+ if (deadline != null && System.nanoTime() >= deadline) break
+
+ val httpRequest =
+ HttpRequest
+ .newBuilder(uri)
+ .GET()
+ .timeout(timeout)
+ .header("User-Agent", userAgent)
+ .header("Accept", ACCEPT_HEADER)
+ .build()
+
+ try {
+ val response = http.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray())
+ val status = response.statusCode()
+ if (status in acceptedStatus || status !in RETRIABLE_STATUS) {
+ return Reply(status, response.body())
+ }
+ lastStatus = status
+ lastRetryAfter =
+ retryAfterSeconds(response.headers().firstValue("Retry-After").orElse(null))
+ lastNetworkError = null
+ } catch (e: IOException) {
+ lastNetworkError = e
+ lastStatus = null
+ lastRetryAfter = null
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw NetworkException("interrupted while requesting $url", e)
+ }
+
+ if (attempt == maxAttempts) break
+
+ var wait = backoff(attempt, lastRetryAfter)
+ if (deadline != null) {
+ val remaining = (deadline - System.nanoTime()) / 1e9
+ if (remaining <= 0.0) break
+ wait = min(wait, remaining)
+ }
+ sleepSeconds(wait)
+ attempt++
+ }
+
+ if (lastStatus == 429) {
+ throw RateLimitedException(
+ "rate limit reached at $url (after $maxAttempts attempt(s))",
+ lastRetryAfter,
+ )
+ }
+ if (lastStatus != null) {
+ throw ServerErrorException(
+ "the API answered $lastStatus at $url (after $maxAttempts attempt(s))",
+ lastStatus,
+ )
+ }
+ throw NetworkException(
+ "could not reach $url (after $maxAttempts attempt(s)): $lastNetworkError",
+ lastNetworkError,
+ )
+ }
+
+ private fun sleepSeconds(seconds: Double) {
+ if (seconds <= 0.0) return
+ try {
+ Thread.sleep((seconds * 1000.0).toLong())
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw NetworkException("interrupted while backing off", e)
+ }
+ }
+
+ private class Reply(
+ val status: Int,
+ val body: ByteArray,
+ )
+
+ private companion object {
+ fun preview(body: ByteArray): String = body.toString(StandardCharsets.UTF_8).take(120)
+
+ /**
+ * Parse the `Retry-After` header in both RFC 9110 forms.
+ *
+ * It may arrive as a number of seconds (`"120"`) or as an HTTP date
+ * (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the
+ * common bug, and it makes a client ignore exactly the wait request
+ * that matters most.
+ */
+ fun retryAfterSeconds(value: String?): Double? {
+ val text = value?.trim()
+ if (text.isNullOrEmpty()) return null
+ text.toDoubleOrNull()?.let { return max(0.0, it) }
+ return try {
+ val target =
+ ZonedDateTime.parse(text, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
+ max(0.0, Duration.between(Instant.now(), target).toMillis() / 1000.0)
+ } catch (e: DateTimeParseException) {
+ null
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/org/cepis/Csv.kt b/src/main/kotlin/org/cepis/Csv.kt
new file mode 100644
index 0000000..9e86a0c
--- /dev/null
+++ b/src/main/kotlin/org/cepis/Csv.kt
@@ -0,0 +1,93 @@
+package org.cepis
+
+/**
+ * A minimal RFC 4180 reader.
+ *
+ * The batch endpoint returns values that may contain commas, and those arrive
+ * double-quoted with `""` escaping. Splitting on `,` would corrupt them.
+ */
+internal object Csv {
+ /** Split [text] into rows of fields, honouring quotes, CR, LF and CRLF. */
+ fun parse(text: String): List<List<String>> {
+ val rows = ArrayList<List<String>>()
+ var row = ArrayList<String>()
+ val field = StringBuilder()
+ var quoted = false
+ var i = 0
+
+ while (i < text.length) {
+ val c = text[i]
+ if (quoted) {
+ if (c == '"') {
+ if (i + 1 < text.length && text[i + 1] == '"') {
+ field.append('"')
+ i += 2
+ } else {
+ quoted = false
+ i++
+ }
+ } else {
+ field.append(c)
+ i++
+ }
+ continue
+ }
+ when (c) {
+ '"' -> {
+ quoted = true
+ i++
+ }
+ ',' -> {
+ row.add(field.toString())
+ field.setLength(0)
+ i++
+ }
+ '\n' -> {
+ row.add(field.toString())
+ field.setLength(0)
+ rows.add(row)
+ row = ArrayList()
+ i++
+ }
+ '\r' -> {
+ row.add(field.toString())
+ field.setLength(0)
+ rows.add(row)
+ row = ArrayList()
+ i++
+ if (i < text.length && text[i] == '\n') i++
+ }
+ else -> {
+ field.append(c)
+ i++
+ }
+ }
+ }
+
+ if (field.isNotEmpty() || row.isNotEmpty()) {
+ row.add(field.toString())
+ rows.add(row)
+ }
+ return rows
+ }
+
+ /**
+ * Read [text] as a header row followed by data rows.
+ *
+ * A row shorter than the header gets `""` for the fields it lacks; fields
+ * beyond the header are dropped. Blank lines are skipped.
+ */
+ fun rows(text: String): List<Map<String, String>> {
+ val parsed = parse(text)
+ if (parsed.isEmpty()) return emptyList()
+ val header = parsed[0]
+ return parsed
+ .drop(1)
+ .filterNot { it.size <= 1 && it.firstOrNull().isNullOrEmpty() }
+ .map { values ->
+ header
+ .withIndex()
+ .associate { (index, name) -> name to (values.getOrNull(index) ?: "") }
+ }
+ }
+}
diff --git a/src/main/kotlin/org/cepis/Errors.kt b/src/main/kotlin/org/cepis/Errors.kt
new file mode 100644
index 0000000..8d3f1ff
--- /dev/null
+++ b/src/main/kotlin/org/cepis/Errors.kt
@@ -0,0 +1,88 @@
+package org.cepis
+
+/**
+ * Base of every error this library raises. Catch it to handle any failure.
+ *
+ * Below it, the split is by **the decision the caller has to make**, not by the
+ * HTTP status that caused it:
+ *
+ * - [InvalidCepException] and [CepNotFoundException] are problems with the
+ * *request*. Retrying is pointless — the same CEP will fail again.
+ * - [NetworkException], [ServerErrorException] and [RateLimitedException] are
+ * problems with *transport*. Retrying may help, and the client already
+ * retried on its own before raising any of them.
+ *
+ * That split is why [CepNotFoundException] is not "the HTTP 404 exception": the
+ * client also raises [InvalidCepException] without making any request at all,
+ * when the CEP is malformed before it ever leaves the machine.
+ *
+ * The hierarchy is `sealed`, so a `when` over it is exhaustive and a new error
+ * kind cannot be introduced from outside this library.
+ */
+sealed class CepisException(
+ message: String,
+ cause: Throwable? = null,
+) : RuntimeException(message, cause)
+
+/**
+ * The CEP is not 8 digits.
+ *
+ * Usually raised **without** a request ever being made — [normalizeCep] checks
+ * the format before the client goes to the network.
+ *
+ * The Python original also derives this from `ValueError` so that code already
+ * validating input keeps working. The JVM allows a class only one supertype,
+ * and a single catchable base ([CepisException]) is worth more than kinship
+ * with `IllegalArgumentException`, so that part does not carry over. Note that
+ * the [Client] constructor still throws a plain `IllegalArgumentException` for
+ * a nonsensical configuration: that is a programming error, not a lookup
+ * failure.
+ */
+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 request never completed: DNS, refused connection, timeout, TLS.
+ *
+ * Raised only after every configured attempt has failed.
+ */
+class NetworkException(
+ message: String,
+ cause: Throwable? = null,
+) : CepisException(message, cause)
+
+/**
+ * The API answered 5xx on every attempt.
+ *
+ * @property status the HTTP status seen on the last attempt.
+ */
+class ServerErrorException(
+ message: String,
+ val status: Int,
+) : CepisException(message)
+
+/**
+ * The API (or the edge in front of it) answered 429 on every attempt.
+ *
+ * @property retryAfter what the server asked for, in seconds, or `null` if it
+ * did not say. The client already honoured that value across the attempts it
+ * made; it is exposed here for callers who want to reschedule the work.
+ */
+class RateLimitedException(
+ message: String,
+ val retryAfter: Double? = null,
+) : CepisException(message)
+
+/**
+ * A success status came back with a body that cannot be parsed.
+ *
+ * In practice this 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.
+ */
+class InvalidResponseException(
+ message: String,
+ cause: Throwable? = null,
+) : CepisException(message, cause)
diff --git a/src/main/kotlin/org/cepis/Json.kt b/src/main/kotlin/org/cepis/Json.kt
new file mode 100644
index 0000000..3bbc4d2
--- /dev/null
+++ b/src/main/kotlin/org/cepis/Json.kt
@@ -0,0 +1,218 @@
+package org.cepis
+
+internal class JsonSyntaxException(
+ message: String,
+) : RuntimeException(message)
+
+/**
+ * A minimal JSON reader.
+ *
+ * Objects become `Map<String, Any?>` (insertion-ordered), arrays `List<Any?>`,
+ * strings `String`, integral numbers `Long`, the rest `Double`, and `null`
+ * stays `null`. That is everything this library needs to read the API, and it
+ * keeps unknown fields intact for [Address.raw].
+ */
+internal object Json {
+ /** Parse a whole document. @throws JsonSyntaxException if it is malformed. */
+ fun parse(text: String): Any? {
+ val parser = Parser(text)
+ val value = parser.readValue()
+ parser.skipWhitespace()
+ if (!parser.atEnd()) {
+ throw JsonSyntaxException("unexpected trailing content")
+ }
+ return value
+ }
+
+ private class Parser(
+ private val src: String,
+ ) {
+ private var pos = 0
+
+ fun atEnd(): Boolean = pos >= src.length
+
+ fun skipWhitespace() {
+ while (pos < src.length) {
+ when (src[pos]) {
+ ' ', '\t', '\n', '\r', '\uFEFF' -> pos++
+ else -> return
+ }
+ }
+ }
+
+ fun readValue(): Any? {
+ skipWhitespace()
+ if (atEnd()) throw JsonSyntaxException("unexpected end of input")
+ val c = src[pos]
+ return when {
+ c == '{' -> readObject()
+ c == '[' -> readArray()
+ c == '"' -> readString()
+ c == 't' -> readLiteral("true", true)
+ c == 'f' -> readLiteral("false", false)
+ c == 'n' -> readLiteral("null", null)
+ c == '-' || c in '0'..'9' -> readNumber()
+ else -> throw JsonSyntaxException("unexpected character '$c' at offset $pos")
+ }
+ }
+
+ private fun readLiteral(
+ word: String,
+ value: Any?,
+ ): Any? {
+ if (!src.startsWith(word, pos)) {
+ throw JsonSyntaxException("invalid literal at offset $pos")
+ }
+ pos += word.length
+ return value
+ }
+
+ private fun readObject(): Map<String, Any?> {
+ pos++
+ val out = LinkedHashMap<String, Any?>()
+ skipWhitespace()
+ if (!atEnd() && src[pos] == '}') {
+ pos++
+ return out
+ }
+ while (true) {
+ skipWhitespace()
+ if (atEnd() || src[pos] != '"') {
+ throw JsonSyntaxException("expected a key at offset $pos")
+ }
+ val key = readString()
+ skipWhitespace()
+ if (atEnd() || src[pos] != ':') {
+ throw JsonSyntaxException("expected ':' at offset $pos")
+ }
+ pos++
+ out[key] = readValue()
+ skipWhitespace()
+ if (atEnd()) throw JsonSyntaxException("unterminated object")
+ when (src[pos]) {
+ ',' -> pos++
+ '}' -> {
+ pos++
+ return out
+ }
+ else -> throw JsonSyntaxException("expected ',' or '}' at offset $pos")
+ }
+ }
+ }
+
+ private fun readArray(): List<Any?> {
+ pos++
+ val out = ArrayList<Any?>()
+ skipWhitespace()
+ if (!atEnd() && src[pos] == ']') {
+ pos++
+ return out
+ }
+ while (true) {
+ out.add(readValue())
+ skipWhitespace()
+ if (atEnd()) throw JsonSyntaxException("unterminated array")
+ when (src[pos]) {
+ ',' -> pos++
+ ']' -> {
+ pos++
+ return out
+ }
+ else -> throw JsonSyntaxException("expected ',' or ']' at offset $pos")
+ }
+ }
+ }
+
+ private fun readString(): String {
+ pos++
+ val out = StringBuilder()
+ while (true) {
+ if (atEnd()) throw JsonSyntaxException("unterminated string")
+ when (val c = src[pos]) {
+ '"' -> {
+ pos++
+ return out.toString()
+ }
+ '\\' -> {
+ pos++
+ out.append(readEscape())
+ }
+ else -> {
+ out.append(c)
+ pos++
+ }
+ }
+ }
+ }
+
+ private fun readEscape(): Char {
+ if (atEnd()) throw JsonSyntaxException("unterminated escape")
+ val c = src[pos]
+ pos++
+ return when (c) {
+ '"' -> '"'
+ '\\' -> '\\'
+ '/' -> '/'
+ 'b' -> '\b'
+ 'f' -> '\u000C'
+ 'n' -> '\n'
+ 'r' -> '\r'
+ 't' -> '\t'
+ 'u' -> readUnicodeEscape()
+ else -> throw JsonSyntaxException("invalid escape '\\$c'")
+ }
+ }
+
+ private fun readUnicodeEscape(): Char {
+ if (pos + 4 > src.length) throw JsonSyntaxException("truncated \\u escape")
+ val code =
+ src.substring(pos, pos + 4).toIntOrNull(16)
+ ?: throw JsonSyntaxException("invalid \\u escape at offset $pos")
+ pos += 4
+ return code.toChar()
+ }
+
+ private fun readNumber(): Any {
+ val start = pos
+ if (!atEnd() && src[pos] == '-') pos++
+ while (!atEnd() && src[pos] in '0'..'9') pos++
+ var floating = false
+ if (!atEnd() && src[pos] == '.') {
+ floating = true
+ pos++
+ while (!atEnd() && src[pos] in '0'..'9') pos++
+ }
+ if (!atEnd() && (src[pos] == 'e' || src[pos] == 'E')) {
+ floating = true
+ pos++
+ if (!atEnd() && (src[pos] == '+' || src[pos] == '-')) pos++
+ while (!atEnd() && src[pos] in '0'..'9') pos++
+ }
+ val literal = src.substring(start, pos)
+ if (literal.isEmpty() || literal == "-") {
+ throw JsonSyntaxException("invalid number at offset $start")
+ }
+ if (!floating) {
+ literal.toLongOrNull()?.let { return it }
+ }
+ return literal.toDoubleOrNull()
+ ?: throw JsonSyntaxException("invalid number '$literal'")
+ }
+ }
+}
+
+/** The value at [key] as text, with a missing or null value becoming `""`. */
+internal fun Map<String, Any?>.text(key: String): String =
+ when (val value = this[key]) {
+ null -> ""
+ is String -> value
+ else -> value.toString()
+ }
+
+/** The value at [key] as a number, or `null` when it is absent or not one. */
+internal fun Map<String, Any?>.number(key: String): Double? =
+ when (val value = this[key]) {
+ is Number -> value.toDouble()
+ is String -> value.trim().toDoubleOrNull()
+ else -> null
+ }
diff --git a/src/main/kotlin/org/cepis/Models.kt b/src/main/kotlin/org/cepis/Models.kt
new file mode 100644
index 0000000..058244e
--- /dev/null
+++ b/src/main/kotlin/org/cepis/Models.kt
@@ -0,0 +1,167 @@
+package org.cepis
+
+/**
+ * The CEP's coordinate, when the database has one.
+ *
+ * The point is the **median** of the coordinates of that CEP's addresses, not
+ * the mean: one badly geocoded address 40 km away shifts a mean and does not
+ * move a median.
+ *
+ * Always check [precisao] before using [lat]/[lon] for distance:
+ *
+ * - `"logradouro"`: the point is a stretch of street. [raioM] lands around
+ * 100–150 m. Good for maps and for distance.
+ * - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
+ * CEP), and the point is its centroid. [raioM] runs from 7 km to 40 km.
+ * Computing shipping from this is wrong by tens of kilometres.
+ *
+ * @property lat latitude, in degrees.
+ * @property lon longitude, in degrees.
+ * @property raioM the radius, in metres, containing 90% of that CEP's
+ * addresses. It exists precisely so the error above cannot pass unnoticed.
+ * The API calls this field `raio_m`.
+ * @property precisao either `"logradouro"` or `"localidade"`.
+ */
+data class Geo(
+ val lat: Double,
+ val lon: Double,
+ val raioM: Int,
+ val precisao: String,
+) {
+ /**
+ * `true` when the coordinate points at a street, not a municipality.
+ *
+ * Shorthand for `precisao == "logradouro"`, which is the check to make
+ * before using the point to measure distance.
+ */
+ val isPrecise: Boolean
+ get() = precisao == "logradouro"
+}
+
+/**
+ * A CEP and everything the database knows about it.
+ *
+ * **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
+ * `localidade`. Everything else in this library is English; the data fields are
+ * the one deliberate exception, so that what you read in the JSON at
+ * `https://cep.is/01001000` is what you access here. Only the casing is
+ * adapted where Kotlin demands it: the API's `raio_m` is [Geo.raioM] and its
+ * `base_gerada_em` is [Health.baseGeradaEm].
+ *
+ * Fields the database does not have for that CEP come back as empty strings,
+ * never `null` — that is what the API returns.
+ *
+ * [geo] is the exception: it is `null` when there is no coordinate. There the
+ * difference is real, because the API **omits** the whole field in that case
+ * (about 43% of CEPs) rather than sending null.
+ *
+ * @property raw the response exactly as it arrived, uninterpreted, so a field
+ * the API grows before this library is updated is not lost.
+ */
+data class Address(
+ val cep: String = "",
+ val logradouro: String = "",
+ val complemento: String = "",
+ val unidade: String = "",
+ val bairro: String = "",
+ val localidade: String = "",
+ val uf: String = "",
+ val estado: String = "",
+ val regiao: String = "",
+ val ibge: String = "",
+ val gia: String = "",
+ val ddd: String = "",
+ val siafi: String = "",
+ val geo: Geo? = null,
+ val raw: Map<String, Any?> = emptyMap(),
+) {
+ /**
+ * The CEP with its mask: `01001-000`.
+ *
+ * The native contract always returns it unmasked; formatting is
+ * presentation, and it lives here so it does not become a repeated string
+ * template in every project that uses this.
+ */
+ val formattedCep: String
+ get() = if (cep.length == 8) "${cep.substring(0, 5)}-${cep.substring(5)}" else cep
+
+ companion object {
+ /**
+ * Build from the map the API returned.
+ *
+ * Unknown fields are ignored during construction but preserved in
+ * [raw]. A new field in the API therefore cannot break this library.
+ *
+ * A malformed `geo` block yields `geo = null` rather than propagating
+ * the error: it is not worth losing the whole address over, since the
+ * rest of the response is still useful.
+ */
+ fun fromJson(data: Map<String, Any?>): Address =
+ Address(
+ cep = data.text("cep"),
+ logradouro = data.text("logradouro"),
+ complemento = data.text("complemento"),
+ unidade = data.text("unidade"),
+ bairro = data.text("bairro"),
+ localidade = data.text("localidade"),
+ uf = data.text("uf"),
+ estado = data.text("estado"),
+ regiao = data.text("regiao"),
+ ibge = data.text("ibge"),
+ gia = data.text("gia"),
+ ddd = data.text("ddd"),
+ siafi = data.text("siafi"),
+ geo = geoFrom(data["geo"]),
+ raw = LinkedHashMap(data),
+ )
+
+ private fun geoFrom(block: Any?): Geo? {
+ if (block !is Map<*, *>) return null
+ @Suppress("UNCHECKED_CAST")
+ val fields = block as Map<String, Any?>
+ val lat = fields.number("lat") ?: return null
+ val lon = fields.number("lon") ?: return null
+ val raioM = fields.number("raio_m") ?: return null
+ val precisao = fields["precisao"]?.toString() ?: return null
+ return Geo(lat = lat, lon = lon, raioM = raioM.toInt(), precisao = precisao)
+ }
+ }
+}
+
+/**
+ * The response of `GET /health`.
+ *
+ * Good for two things: checking that the API answers, and knowing **which
+ * version of the database** is live. The DNE database changes every two weeks;
+ * [dne] identifies it.
+ *
+ * @property status `"ok"` when the API reports itself healthy.
+ * @property dne identifier of the fortnightly Correios database in use.
+ * @property ceps how many CEPs that database holds.
+ * @property geo how many of them carry a coordinate.
+ * @property baseGeradaEm when the base was generated, ISO 8601 UTC. The API
+ * calls this field `base_gerada_em`.
+ */
+data class Health(
+ val status: String = "",
+ val dne: String = "",
+ val ceps: Int = 0,
+ val geo: Int = 0,
+ val baseGeradaEm: String = "",
+) {
+ /** `true` when the API reports itself healthy. */
+ val ok: Boolean
+ get() = status == "ok"
+
+ companion object {
+ /** Build from the map the API returned. */
+ fun fromJson(data: Map<String, Any?>): Health =
+ Health(
+ status = data.text("status"),
+ dne = data.text("dne"),
+ ceps = data.number("ceps")?.toInt() ?: 0,
+ geo = data.number("geo")?.toInt() ?: 0,
+ baseGeradaEm = data.text("base_gerada_em"),
+ )
+ }
+}