From 0757164507a1f9be8b9f61292a9069759f1da034 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:19:13 -0300 Subject: Initial import --- .editorconfig | 20 ++ .gitignore | 13 + LICENSE | 15 + Makefile | 33 +++ README.md | 222 +++++++++++++++ build.gradle.kts | 42 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 248 +++++++++++++++++ gradlew.bat | 82 ++++++ settings.gradle.kts | 1 + src/main/kotlin/org/cepis/Cepis.kt | 70 +++++ src/main/kotlin/org/cepis/Client.kt | 372 +++++++++++++++++++++++++ src/main/kotlin/org/cepis/Csv.kt | 93 +++++++ src/main/kotlin/org/cepis/Errors.kt | 88 ++++++ src/main/kotlin/org/cepis/Json.kt | 218 +++++++++++++++ src/main/kotlin/org/cepis/Models.kt | 167 +++++++++++ src/test/kotlin/org/cepis/BatchTest.kt | 110 ++++++++ src/test/kotlin/org/cepis/ConfigurationTest.kt | 42 +++ src/test/kotlin/org/cepis/FakeServer.kt | 98 +++++++ src/test/kotlin/org/cepis/HealthTest.kt | 36 +++ src/test/kotlin/org/cepis/LookupTest.kt | 128 +++++++++ src/test/kotlin/org/cepis/NormalizationTest.kt | 26 ++ src/test/kotlin/org/cepis/ParserTest.kt | 86 ++++++ src/test/kotlin/org/cepis/RetryTest.kt | 99 +++++++ src/test/kotlin/org/cepis/ServerTest.kt | 51 ++++ 26 files changed, 2369 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/kotlin/org/cepis/Cepis.kt create mode 100644 src/main/kotlin/org/cepis/Client.kt create mode 100644 src/main/kotlin/org/cepis/Csv.kt create mode 100644 src/main/kotlin/org/cepis/Errors.kt create mode 100644 src/main/kotlin/org/cepis/Json.kt create mode 100644 src/main/kotlin/org/cepis/Models.kt create mode 100644 src/test/kotlin/org/cepis/BatchTest.kt create mode 100644 src/test/kotlin/org/cepis/ConfigurationTest.kt create mode 100644 src/test/kotlin/org/cepis/FakeServer.kt create mode 100644 src/test/kotlin/org/cepis/HealthTest.kt create mode 100644 src/test/kotlin/org/cepis/LookupTest.kt create mode 100644 src/test/kotlin/org/cepis/NormalizationTest.kt create mode 100644 src/test/kotlin/org/cepis/ParserTest.kt create mode 100644 src/test/kotlin/org/cepis/RetryTest.kt create mode 100644 src/test/kotlin/org/cepis/ServerTest.kt 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 + +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("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 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar 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//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): List = 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): List { + if (ceps.isEmpty()) return emptyList() + val result = ArrayList(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): List { + 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 { + 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 + } + + 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 = 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> { + val rows = ArrayList>() + var row = ArrayList() + 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> { + 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` (insertion-ordered), arrays `List`, + * 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 { + pos++ + val out = LinkedHashMap() + 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 { + pos++ + val out = ArrayList() + 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.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.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 = 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): 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 + 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): 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(), 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 { Client(maxAttempts = 0) } + assertFailsWith { Client(timeout = Duration.ZERO) } + assertFailsWith { 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 = 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>() + private val lock = Any() + + /** Every path requested so far, in order, query string included. */ + val calls: MutableList = 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 { client.lookup("99999999") } + + assertTrue(error.message!!.contains("99999999")) + } + + @Test + fun `not found is not retried`() { + server.respond("/99999999", Reply(404, """{"erro":"x"}""")) + assertFailsWith { client.lookup("99999999") } + + assertEquals(1, server.count("/99999999")) + } + + @Test + fun `invalid cep never leaves the machine`() { + assertFailsWith { 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 { 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 { client.lookup("00000000") } + + assertEquals(1, server.count("/00000000")) + } + + @Test + fun `response that is not json`() { + server.respond("/01001000", Reply(200, "captive portal")) + + assertFailsWith { client.lookup("01001000") } + } + + @Test + fun `json that is not an object`() { + server.respond("/01001000", Reply(200, "[1, 2, 3]")) + + assertFailsWith { 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 { normalizeCep(value) } + } + } + + @Test + fun `is a cepis error so one handler catches everything`() { + assertFailsWith { 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(bad) { Json.parse(bad) } + } + } + + @Test + fun `accepts an empty object and an empty array`() { + assertEquals(emptyMap(), Json.parse("{}")) + assertEquals(emptyList(), 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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" +} -- cgit v1.3