summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:19:13 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:19:13 -0300
commit0757164507a1f9be8b9f61292a9069759f1da034 (patch)
treee958ba14c7f6a6ae3cdabe1ed0cd5f69b530edb7
downloadkotlin-0.1.0.tar.gz
Initial importv0.1.0
-rw-r--r--.editorconfig20
-rw-r--r--.gitignore13
-rw-r--r--LICENSE15
-rw-r--r--Makefile33
-rw-r--r--README.md222
-rw-r--r--build.gradle.kts42
-rw-r--r--gradle/wrapper/gradle-wrapper.jarbin0 -> 48462 bytes
-rw-r--r--gradle/wrapper/gradle-wrapper.properties9
-rwxr-xr-xgradlew248
-rw-r--r--gradlew.bat82
-rw-r--r--settings.gradle.kts1
-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
-rw-r--r--src/test/kotlin/org/cepis/BatchTest.kt110
-rw-r--r--src/test/kotlin/org/cepis/ConfigurationTest.kt42
-rw-r--r--src/test/kotlin/org/cepis/FakeServer.kt98
-rw-r--r--src/test/kotlin/org/cepis/HealthTest.kt36
-rw-r--r--src/test/kotlin/org/cepis/LookupTest.kt128
-rw-r--r--src/test/kotlin/org/cepis/NormalizationTest.kt26
-rw-r--r--src/test/kotlin/org/cepis/ParserTest.kt86
-rw-r--r--src/test/kotlin/org/cepis/RetryTest.kt99
-rw-r--r--src/test/kotlin/org/cepis/ServerTest.kt51
26 files changed, 2369 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..a7f4f3f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,20 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{kt,kts}]
+indent_style = space
+indent_size = 4
+max_line_length = 120
+ktlint_code_style = ktlint_official
+
+[Makefile]
+indent_style = tab
+
+[*.{md,yml,yaml}]
+indent_style = space
+indent_size = 2
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d826c8c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+build/
+out/
+.gradle/
+*.class
+*.jar
+!gradle/wrapper/gradle-wrapper.jar
+.kotlin/
+local.properties
+.idea/
+*.iml
+*.ipr
+*.iws
+.DS_Store
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d28fbc1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,33 @@
+GRADLE ?= ./gradlew
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the test suite'
+ @echo ' lint ktlint, read-only'
+ @echo ' format apply ktlint (writes)'
+ @echo ' build assemble the jar and sources jar into build/libs/'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build artefacts and caches'
+ @echo
+ @echo 'Override the build tool with e.g. make test GRADLE=gradle'
+
+test:
+ $(GRADLE) test
+
+lint:
+ $(GRADLE) ktlintCheck
+
+format:
+ $(GRADLE) ktlintFormat
+
+build:
+ $(GRADLE) assemble
+
+check: lint test build
+
+clean:
+ $(GRADLE) clean
+ rm -rf .gradle .kotlin build
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4278f8a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,222 @@
+# 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.1.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.1.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 | 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
+}
+```
+
+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).
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..31ce4a7
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,42 @@
+plugins {
+ kotlin("jvm") version "2.1.20"
+ id("org.jlleitschuh.gradle.ktlint") version "12.1.2"
+ `java-library`
+ `maven-publish`
+}
+
+group = "org.cepis"
+version = "0.1.0"
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ testImplementation(kotlin("test"))
+ testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+kotlin {
+ jvmToolchain(17)
+}
+
+java {
+ withSourcesJar()
+}
+
+tasks.test {
+ useJUnitPlatform()
+ testLogging {
+ events("passed", "skipped", "failed")
+ }
+}
+
+publishing {
+ publications {
+ create<MavenPublication>("maven") {
+ from(components["java"])
+ }
+ }
+}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..b1b8ef5
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a9db115
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..379a658
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..a51ec4f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..c396541
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1 @@
+rootProject.name = "cepis"
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"),
+ )
+ }
+}
diff --git a/src/test/kotlin/org/cepis/BatchTest.kt b/src/test/kotlin/org/cepis/BatchTest.kt
new file mode 100644
index 0000000..67aab2d
--- /dev/null
+++ b/src/test/kotlin/org/cepis/BatchTest.kt
@@ -0,0 +1,110 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+internal class BatchTest : ServerTest() {
+ @Test
+ fun `preserves order and marks missing`() {
+ val body =
+ Fixtures.csv(
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo," +
+ "Sudeste,3550308,1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ," +
+ "Rio de Janeiro,Sudeste,3304557,,21,6001",
+ )
+ server.respond("/csv?ceps=01001000%2C99999999%2C20040020", Reply(200, body))
+ val result = client.lookupMany(listOf("01001000", "99999999", "20040020"))
+
+ assertEquals(3, result.size)
+ assertEquals("São Paulo", assertNotNull(result[0]).localidade)
+ assertNull(result[1])
+ assertEquals("RJ", assertNotNull(result[2]).uf)
+ }
+
+ @Test
+ fun `batches carry no coordinate`() {
+ val body =
+ Fixtures.csv("01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,,11,1")
+ server.respond("/csv?ceps=01001000", Reply(200, body))
+
+ assertNull(assertNotNull(client.lookupMany(listOf("01001000"))[0]).geo)
+ }
+
+ @Test
+ fun `field with a comma is parsed`() {
+ val body =
+ Fixtures.csv(
+ """01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1""",
+ )
+ server.respond("/csv?ceps=01001000", Reply(200, body))
+
+ assertEquals(
+ "Rua Um, Dois",
+ assertNotNull(client.lookupMany(listOf("01001000"))[0]).logradouro,
+ )
+ }
+
+ @Test
+ fun `field with an escaped quote is parsed`() {
+ val body =
+ Fixtures.csv(
+ "01001000,\"Rua \"\"Dois\"\"\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ )
+ server.respond("/csv?ceps=01001000", Reply(200, body))
+
+ assertEquals(
+ "Rua \"Dois\"",
+ assertNotNull(client.lookupMany(listOf("01001000"))[0]).logradouro,
+ )
+ }
+
+ @Test
+ fun `empty list makes no request`() {
+ assertEquals(emptyList<Address?>(), client.lookupMany(emptyList()))
+ assertEquals(0, server.calls.size)
+ }
+
+ @Test
+ fun `chunks above the api limit`() {
+ val ceps = (0 until 501).map { "%08d".format(it) }
+ val first = ceps.take(500).map { "$it,,,,,X,SP,São Paulo,Sudeste,1,,11,1" }
+ val second = ceps.drop(500).map { "$it,,,,,X,SP,São Paulo,Sudeste,1,,11,1" }
+ var seen = 0
+
+ server.responder = {
+ seen += 1
+ val rows = if (seen == 1) first else second
+ Reply(200, Fixtures.csv(*rows.toTypedArray()))
+ }
+ val result = client.lookupMany(ceps)
+
+ assertEquals(2, seen)
+ assertEquals(501, result.size)
+ assertEquals("X", assertNotNull(result[500]).localidade)
+ }
+
+ @Test
+ fun `truncated response does not shift`() {
+ val body = Fixtures.csv("01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1")
+ server.respond("/csv?ceps=01001000%2C20040020", Reply(200, body))
+ val result = client.lookupMany(listOf("01001000", "20040020"))
+
+ assertEquals(2, result.size)
+ assertEquals("São Paulo", assertNotNull(result[0]).localidade)
+ assertNull(result[1])
+ }
+
+ @Test
+ fun `malformed cep is null rather than an error`() {
+ val body = Fixtures.csv("01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", "abc,,,,,,,,,,,,")
+ server.respond("/csv?ceps=01001000%2Cabc", Reply(200, body))
+ val result = client.lookupMany(listOf("01001000", "abc"))
+
+ assertEquals(2, result.size)
+ assertNull(result[1])
+ }
+}
diff --git a/src/test/kotlin/org/cepis/ConfigurationTest.kt b/src/test/kotlin/org/cepis/ConfigurationTest.kt
new file mode 100644
index 0000000..e166b6a
--- /dev/null
+++ b/src/test/kotlin/org/cepis/ConfigurationTest.kt
@@ -0,0 +1,42 @@
+package org.cepis
+
+import java.time.Duration
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+internal class ConfigurationTest {
+ @Test
+ fun `trailing slash is not duplicated`() {
+ assertEquals("http://x", Client(baseUrl = "http://x/").baseUrl)
+ }
+
+ @Test
+ fun `invalid parameters are rejected`() {
+ assertFailsWith<IllegalArgumentException> { Client(maxAttempts = 0) }
+ assertFailsWith<IllegalArgumentException> { Client(timeout = Duration.ZERO) }
+ assertFailsWith<IllegalArgumentException> { Client(timeout = Duration.ofSeconds(-1)) }
+ }
+
+ @Test
+ fun `user agent identifies the library`() {
+ assertTrue(Client().userAgent.startsWith("cepis-kotlin/$VERSION"))
+ }
+
+ @Test
+ fun `user agent can be overridden`() {
+ assertEquals("mine/1.0", Client(userAgent = "mine/1.0").userAgent)
+ }
+
+ @Test
+ fun `defaults match the documented ones`() {
+ val client = Client()
+
+ assertEquals(DEFAULT_BASE_URL, client.baseUrl)
+ assertEquals(3, client.maxAttempts)
+ assertEquals(Duration.ofMillis(500), client.backoffBase)
+ assertEquals(Duration.ofSeconds(8), client.backoffMax)
+ assertEquals(null, client.totalTimeout)
+ }
+}
diff --git a/src/test/kotlin/org/cepis/FakeServer.kt b/src/test/kotlin/org/cepis/FakeServer.kt
new file mode 100644
index 0000000..04ad4e2
--- /dev/null
+++ b/src/test/kotlin/org/cepis/FakeServer.kt
@@ -0,0 +1,98 @@
+package org.cepis
+
+import com.sun.net.httpserver.HttpServer
+import java.net.InetSocketAddress
+import java.util.Collections
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.atomic.AtomicInteger
+
+/** One canned HTTP response for the [FakeServer] to hand out. */
+internal class Reply(
+ val status: Int,
+ val body: String,
+ val headers: Map<String, String> = emptyMap(),
+)
+
+/**
+ * A throwaway HTTP server on a random loopback port.
+ *
+ * Tests point a real [Client] at it through `baseUrl`, so the code path under
+ * test is the one that runs in production — request construction, retries and
+ * error-body reading included. Nothing is mocked and nothing touches the
+ * network.
+ */
+internal class FakeServer {
+ private val server: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
+ private val routes = HashMap<String, MutableList<Reply>>()
+ private val lock = Any()
+
+ /** Every path requested so far, in order, query string included. */
+ val calls: MutableList<String> = Collections.synchronizedList(ArrayList())
+
+ /** When set, answers every request and bypasses the registered routes. */
+ var responder: ((String) -> Reply)? = null
+
+ private val pool =
+ Executors.newCachedThreadPool(
+ object : ThreadFactory {
+ private val counter = AtomicInteger()
+
+ override fun newThread(runnable: Runnable): Thread =
+ Thread(runnable, "fake-server-${counter.incrementAndGet()}").apply {
+ isDaemon = true
+ }
+ },
+ )
+
+ init {
+ server.createContext("/") { exchange ->
+ val uri = exchange.requestURI
+ val path = if (uri.rawQuery == null) uri.rawPath else "${uri.rawPath}?${uri.rawQuery}"
+ val reply = next(path)
+ val bytes = reply.body.toByteArray(Charsets.UTF_8)
+ reply.headers.forEach { (name, value) -> exchange.responseHeaders.add(name, value) }
+ exchange.sendResponseHeaders(
+ reply.status,
+ if (bytes.isEmpty()) -1L else bytes.size.toLong(),
+ )
+ exchange.responseBody.use { it.write(bytes) }
+ }
+ server.executor = pool
+ server.start()
+ }
+
+ /** The base URL to hand to [Client]. */
+ val url: String
+ get() = "http://127.0.0.1:${server.address.port}"
+
+ /**
+ * Register the responses for [path].
+ *
+ * Each request consumes the next reply, which is how retrying is tested.
+ * The last one repeats once the list runs out.
+ */
+ fun respond(
+ path: String,
+ vararg replies: Reply,
+ ) {
+ synchronized(lock) { routes[path] = replies.toMutableList() }
+ }
+
+ /** How many requests so far began with [prefix]. */
+ fun count(prefix: String): Int = synchronized(calls) { calls.count { it.startsWith(prefix) } }
+
+ fun stop() {
+ server.stop(0)
+ pool.shutdownNow()
+ }
+
+ private fun next(path: String): Reply {
+ synchronized(lock) {
+ calls.add(path)
+ responder?.let { return it(path) }
+ val queue = routes[path] ?: return Reply(404, """{"erro":"cep_nao_encontrado"}""")
+ return if (queue.size > 1) queue.removeAt(0) else queue[0]
+ }
+ }
+}
diff --git a/src/test/kotlin/org/cepis/HealthTest.kt b/src/test/kotlin/org/cepis/HealthTest.kt
new file mode 100644
index 0000000..ae57e48
--- /dev/null
+++ b/src/test/kotlin/org/cepis/HealthTest.kt
@@ -0,0 +1,36 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+internal class HealthTest : ServerTest() {
+ @Test
+ fun `reads health`() {
+ val body =
+ """
+ {"status":"ok","dne":"26071","ceps":1596168,"geo":894489,
+ "base_gerada_em":"2026-07-24T03:48:28Z"}
+ """
+ server.respond("/health", Reply(200, body))
+ val health = client.health()
+
+ assertTrue(health.ok)
+ assertEquals("26071", health.dne)
+ assertEquals(1596168, health.ceps)
+ assertEquals(894489, health.geo)
+ assertEquals("2026-07-24T03:48:28Z", health.baseGeradaEm)
+ }
+
+ @Test
+ fun `degraded does not raise and is not retried`() {
+ val body = """{"status":"degradado","dne":"","ceps":0,"geo":0}"""
+ server.respond("/health", Reply(503, body))
+ val health = client.health()
+
+ assertFalse(health.ok)
+ assertEquals("degradado", health.status)
+ assertEquals(1, server.count("/health"))
+ }
+}
diff --git a/src/test/kotlin/org/cepis/LookupTest.kt b/src/test/kotlin/org/cepis/LookupTest.kt
new file mode 100644
index 0000000..c266025
--- /dev/null
+++ b/src/test/kotlin/org/cepis/LookupTest.kt
@@ -0,0 +1,128 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+internal class LookupTest : ServerTest() {
+ @Test
+ fun `successful lookup`() {
+ server.respond("/01001000", Reply(200, Fixtures.SE_ADDRESS_JSON))
+ val address = client.lookup("01001-000")
+
+ assertEquals("São Paulo", address.localidade)
+ assertEquals("SP", address.uf)
+ assertEquals("Praça da Sé", address.logradouro)
+ assertEquals("01001-000", address.formattedCep)
+ assertNull(address.geo)
+ }
+
+ @Test
+ fun `reads the coordinate when present`() {
+ val body =
+ Fixtures.seAddressWith(
+ """"geo":{"lat":-23.5,"lon":-46.6,"raio_m":120,"precisao":"logradouro"}""",
+ )
+ server.respond("/01310100", Reply(200, body))
+ val geo = assertNotNull(client.lookup("01310100").geo)
+
+ assertEquals(-23.5, geo.lat, 1e-9)
+ assertEquals(-46.6, geo.lon, 1e-9)
+ assertEquals(120, geo.raioM)
+ assertTrue(geo.isPrecise)
+ }
+
+ @Test
+ fun `municipality coordinate is not precise`() {
+ val body =
+ Fixtures.seAddressWith(
+ """"geo":{"lat":-23.5,"lon":-46.6,"raio_m":22000,"precisao":"localidade"}""",
+ )
+ server.respond("/01001000", Reply(200, body))
+ val geo = assertNotNull(client.lookup("01001000").geo)
+
+ assertFalse(geo.isPrecise)
+ assertEquals(22000, geo.raioM)
+ }
+
+ @Test
+ fun `malformed geo does not lose the address`() {
+ val body = Fixtures.seAddressWith(""""geo":{"lat":"not a number"}""")
+ server.respond("/01001000", Reply(200, body))
+ val address = client.lookup("01001000")
+
+ assertNull(address.geo)
+ assertEquals("São Paulo", address.localidade)
+ }
+
+ @Test
+ fun `new api field does not break the client`() {
+ val body = Fixtures.seAddressWith(""""campo_do_futuro":"value"""")
+ server.respond("/01001000", Reply(200, body))
+
+ assertEquals("value", client.lookup("01001000").raw["campo_do_futuro"])
+ }
+
+ @Test
+ fun `not found`() {
+ server.respond(
+ "/99999999",
+ Reply(404, """{"erro":"cep_nao_encontrado","mensagem":"gone"}"""),
+ )
+ val error = assertFailsWith<CepNotFoundException> { client.lookup("99999999") }
+
+ assertTrue(error.message!!.contains("99999999"))
+ }
+
+ @Test
+ fun `not found is not retried`() {
+ server.respond("/99999999", Reply(404, """{"erro":"x"}"""))
+ assertFailsWith<CepNotFoundException> { client.lookup("99999999") }
+
+ assertEquals(1, server.count("/99999999"))
+ }
+
+ @Test
+ fun `invalid cep never leaves the machine`() {
+ assertFailsWith<InvalidCepException> { client.lookup("123") }
+
+ assertEquals(0, server.calls.size)
+ }
+
+ @Test
+ fun `api 400 becomes an invalid cep carrying the api message`() {
+ server.respond(
+ "/00000000",
+ Reply(400, """{"erro":"cep_invalido","mensagem":"rejected by the API"}"""),
+ )
+ val error = assertFailsWith<InvalidCepException> { client.lookup("00000000") }
+
+ assertTrue(error.message!!.contains("rejected by the API"))
+ }
+
+ @Test
+ fun `api 400 is not retried`() {
+ server.respond("/00000000", Reply(400, """{"erro":"cep_invalido"}"""))
+ assertFailsWith<InvalidCepException> { client.lookup("00000000") }
+
+ assertEquals(1, server.count("/00000000"))
+ }
+
+ @Test
+ fun `response that is not json`() {
+ server.respond("/01001000", Reply(200, "<html>captive portal</html>"))
+
+ assertFailsWith<InvalidResponseException> { client.lookup("01001000") }
+ }
+
+ @Test
+ fun `json that is not an object`() {
+ server.respond("/01001000", Reply(200, "[1, 2, 3]"))
+
+ assertFailsWith<InvalidResponseException> { client.lookup("01001000") }
+ }
+}
diff --git a/src/test/kotlin/org/cepis/NormalizationTest.kt b/src/test/kotlin/org/cepis/NormalizationTest.kt
new file mode 100644
index 0000000..b3069b6
--- /dev/null
+++ b/src/test/kotlin/org/cepis/NormalizationTest.kt
@@ -0,0 +1,26 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+internal class NormalizationTest {
+ @Test
+ fun `accepts masked and unmasked`() {
+ for (value in listOf("01001000", "01001-000", " 01001-000 ", "01001.000")) {
+ assertEquals("01001000", normalizeCep(value))
+ }
+ }
+
+ @Test
+ fun `rejects bad format`() {
+ for (value in listOf("", "123", "0100100a", "010010000", "abcdefgh")) {
+ assertFailsWith<InvalidCepException> { normalizeCep(value) }
+ }
+ }
+
+ @Test
+ fun `is a cepis error so one handler catches everything`() {
+ assertFailsWith<CepisException> { normalizeCep("123") }
+ }
+}
diff --git a/src/test/kotlin/org/cepis/ParserTest.kt b/src/test/kotlin/org/cepis/ParserTest.kt
new file mode 100644
index 0000000..0ec0cf8
--- /dev/null
+++ b/src/test/kotlin/org/cepis/ParserTest.kt
@@ -0,0 +1,86 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+internal class JsonTest {
+ @Test
+ fun `reads the shapes the api uses`() {
+ val parsed = Json.parse("""{"a":"x","b":1,"c":-2.5,"d":true,"e":null,"f":[1,"two"]}""")
+ val map = parsed as Map<*, *>
+
+ assertEquals("x", map["a"])
+ assertEquals(1L, map["b"])
+ assertEquals(-2.5, map["c"])
+ assertEquals(true, map["d"])
+ assertNull(map["e"])
+ assertEquals(listOf(1L, "two"), map["f"])
+ }
+
+ @Test
+ fun `reads escapes`() {
+ val map = Json.parse(""" {"s":"a\"b\\c\/d\ne\tfé"} """) as Map<*, *>
+
+ assertEquals("a\"b\\c/d\ne\tfé", map["s"])
+ }
+
+ @Test
+ fun `preserves key order`() {
+ val map = Json.parse("""{"z":1,"a":2,"m":3}""") as Map<*, *>
+
+ assertEquals(listOf("z", "a", "m"), map.keys.toList())
+ }
+
+ @Test
+ fun `rejects malformed documents`() {
+ for (bad in listOf("", "{", """{"a"}""", """{"a":}""", "[1,]", "tru", "{} junk")) {
+ assertFailsWith<JsonSyntaxException>(bad) { Json.parse(bad) }
+ }
+ }
+
+ @Test
+ fun `accepts an empty object and an empty array`() {
+ assertEquals(emptyMap<String, Any?>(), Json.parse("{}"))
+ assertEquals(emptyList<Any?>(), Json.parse("[]"))
+ }
+}
+
+internal class CsvTest {
+ @Test
+ fun `quoted fields keep commas newlines and quotes`() {
+ val rows = Csv.parse("a,b,c\n\"x,1\",\"y\nz\",\"q\"\"q\"\n")
+
+ assertEquals(listOf("a", "b", "c"), rows[0])
+ assertEquals(listOf("x,1", "y\nz", "q\"q"), rows[1])
+ assertEquals(2, rows.size)
+ }
+
+ @Test
+ fun `handles crlf and a missing final newline`() {
+ val rows = Csv.parse("a,b\r\n1,2\r\n3,4")
+
+ assertEquals(3, rows.size)
+ assertEquals(listOf("3", "4"), rows[2])
+ }
+
+ @Test
+ fun `a fully empty row is all empty fields`() {
+ val rows = Csv.rows("cep,localidade\n99999999,\n")
+
+ assertEquals(1, rows.size)
+ assertEquals("99999999", rows[0]["cep"])
+ assertTrue(rows[0]["localidade"].isNullOrEmpty())
+ }
+
+ @Test
+ fun `short rows are padded and blank lines skipped`() {
+ val rows = Csv.rows("a,b,c\n1\n\n2,3\n")
+
+ assertEquals(2, rows.size)
+ assertEquals(mapOf("a" to "1", "b" to "", "c" to ""), rows[0])
+ assertEquals(mapOf("a" to "2", "b" to "3", "c" to ""), rows[1])
+ }
+}
diff --git a/src/test/kotlin/org/cepis/RetryTest.kt b/src/test/kotlin/org/cepis/RetryTest.kt
new file mode 100644
index 0000000..d70735a
--- /dev/null
+++ b/src/test/kotlin/org/cepis/RetryTest.kt
@@ -0,0 +1,99 @@
+package org.cepis
+
+import java.time.Duration
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+internal class RetryTest : ServerTest() {
+ @Test
+ fun `retries on 500 then succeeds`() {
+ server.respond(
+ "/01001000",
+ Reply(500, """{"erro":"interno"}"""),
+ Reply(200, Fixtures.SE_ADDRESS_JSON),
+ )
+
+ assertEquals("São Paulo", client.lookup("01001000").localidade)
+ assertEquals(2, server.count("/01001000"))
+ }
+
+ @Test
+ fun `exhausts attempts then raises`() {
+ server.respond("/01001000", Reply(503, """{"erro":"interno"}"""))
+ val error = assertFailsWith<ServerErrorException> { client.lookup("01001000") }
+
+ assertEquals(503, error.status)
+ assertEquals(3, server.count("/01001000"))
+ }
+
+ @Test
+ fun `max attempts of one disables retrying`() {
+ val single = Client(baseUrl = server.url, maxAttempts = 1, timeout = Duration.ofSeconds(5))
+ server.respond("/01001000", Reply(500, "{}"))
+
+ assertFailsWith<ServerErrorException> { single.lookup("01001000") }
+ assertEquals(1, server.count("/01001000"))
+ }
+
+ @Test
+ fun `429 becomes rate limited with retry after`() {
+ server.respond("/01001000", Reply(429, "{}", mapOf("Retry-After" to "7")))
+ val error = assertFailsWith<RateLimitedException> { client.lookup("01001000") }
+
+ assertEquals(7.0, error.retryAfter)
+ assertEquals(3, server.count("/01001000"))
+ }
+
+ @Test
+ fun `retry after honours the cap`() {
+ server.respond("/01001000", Reply(429, "{}", mapOf("Retry-After" to "3600")))
+ val started = System.nanoTime()
+
+ assertFailsWith<RateLimitedException> { client.lookup("01001000") }
+ assertTrue((System.nanoTime() - started) / 1e9 < 5.0)
+ }
+
+ @Test
+ fun `retry after accepts an http date`() {
+ val future = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC).plusSeconds(30)
+ val header = java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME.format(future)
+ server.respond("/01001000", Reply(429, "{}", mapOf("Retry-After" to header)))
+ val error = assertFailsWith<RateLimitedException> { client.lookup("01001000") }
+ val seconds = error.retryAfter
+
+ assertTrue(seconds != null && seconds > 20.0 && seconds <= 30.0, "got $seconds")
+ }
+
+ @Test
+ fun `network error when nothing listens`() {
+ val unreachable =
+ Client(
+ baseUrl = "http://127.0.0.1:1",
+ maxAttempts = 2,
+ backoffBase = Duration.ofMillis(1),
+ timeout = Duration.ofSeconds(1),
+ )
+
+ assertFailsWith<NetworkException> { unreachable.lookup("01001000") }
+ }
+
+ @Test
+ fun `total timeout bounds the whole call`() {
+ val bounded =
+ Client(
+ baseUrl = server.url,
+ maxAttempts = 50,
+ backoffBase = Duration.ofMillis(50),
+ backoffMax = Duration.ofMillis(50),
+ totalTimeout = Duration.ofMillis(300),
+ )
+ server.respond("/01001000", Reply(500, "{}"))
+ val started = System.nanoTime()
+
+ assertFailsWith<ServerErrorException> { bounded.lookup("01001000") }
+ assertTrue((System.nanoTime() - started) / 1e9 < 3.0)
+ assertTrue(server.count("/01001000") < 50)
+ }
+}
diff --git a/src/test/kotlin/org/cepis/ServerTest.kt b/src/test/kotlin/org/cepis/ServerTest.kt
new file mode 100644
index 0000000..b22975a
--- /dev/null
+++ b/src/test/kotlin/org/cepis/ServerTest.kt
@@ -0,0 +1,51 @@
+package org.cepis
+
+import java.time.Duration
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+
+/**
+ * Fixture with a fake server and a client whose backoff is near zero.
+ *
+ * The backoff is tiny because what is under test is the retry logic, not
+ * `Thread.sleep`'s ability to sleep.
+ */
+internal abstract class ServerTest {
+ protected lateinit var server: FakeServer
+ protected lateinit var client: Client
+
+ @BeforeTest
+ fun startServer() {
+ server = FakeServer()
+ client =
+ Client(
+ baseUrl = server.url,
+ timeout = Duration.ofSeconds(5),
+ backoffBase = Duration.ofMillis(1),
+ backoffMax = Duration.ofMillis(10),
+ )
+ }
+
+ @AfterTest
+ fun stopServer() {
+ server.stop()
+ }
+}
+
+internal object Fixtures {
+ const val SE_ADDRESS_JSON = """
+ {"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",
+ "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP",
+ "estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004",
+ "ddd":"11","siafi":"7107"}
+ """
+
+ const val CSV_HEADER =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado," +
+ "regiao,ibge,gia,ddd,siafi"
+
+ /** The Sé address with an extra top-level field spliced in. */
+ fun seAddressWith(extra: String): String = SE_ADDRESS_JSON.trim().removeSuffix("}").trimEnd() + ",$extra}"
+
+ fun csv(vararg rows: String): String = CSV_HEADER + "\n" + rows.joinToString("\n") + "\n"
+}