diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:19:13 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:19:13 -0300 |
| commit | 0757164507a1f9be8b9f61292a9069759f1da034 (patch) | |
| tree | e958ba14c7f6a6ae3cdabe1ed0cd5f69b530edb7 /src/main | |
| download | kotlin-0757164507a1f9be8b9f61292a9069759f1da034.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src/main')
| -rw-r--r-- | src/main/kotlin/org/cepis/Cepis.kt | 70 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Client.kt | 372 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Csv.kt | 93 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Errors.kt | 88 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Json.kt | 218 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Models.kt | 167 |
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"), + ) + } +} |