summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:20:42 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:20:42 -0300
commit329f679cd359bcab92adf3366cf17711836d0c79 (patch)
tree68f956deb2e0d5c3a25da20260ee3c0678a64cd6 /src
downloadjava-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'src')
-rw-r--r--src/main/java/is/cep/cepis/Address.java184
-rw-r--r--src/main/java/is/cep/cepis/CepNotFoundException.java20
-rw-r--r--src/main/java/is/cep/cepis/Cepis.java109
-rw-r--r--src/main/java/is/cep/cepis/CepisException.java49
-rw-r--r--src/main/java/is/cep/cepis/Client.java613
-rw-r--r--src/main/java/is/cep/cepis/Csv.java102
-rw-r--r--src/main/java/is/cep/cepis/Geo.java69
-rw-r--r--src/main/java/is/cep/cepis/Health.java38
-rw-r--r--src/main/java/is/cep/cepis/InvalidCepException.java25
-rw-r--r--src/main/java/is/cep/cepis/InvalidResponseException.java32
-rw-r--r--src/main/java/is/cep/cepis/Json.java386
-rw-r--r--src/main/java/is/cep/cepis/Main.java122
-rw-r--r--src/main/java/is/cep/cepis/NetworkException.java22
-rw-r--r--src/main/java/is/cep/cepis/RateLimitedException.java39
-rw-r--r--src/main/java/is/cep/cepis/ServerErrorException.java31
-rw-r--r--src/test/java/is/cep/cepis/Assertions.java92
-rw-r--r--src/test/java/is/cep/cepis/BatchTest.java119
-rw-r--r--src/test/java/is/cep/cepis/ConfigurationTest.java52
-rw-r--r--src/test/java/is/cep/cepis/CsvTest.java60
-rw-r--r--src/test/java/is/cep/cepis/FakeServer.java120
-rw-r--r--src/test/java/is/cep/cepis/HealthTest.java40
-rw-r--r--src/test/java/is/cep/cepis/HttpTest.java59
-rw-r--r--src/test/java/is/cep/cepis/JsonTest.java96
-rw-r--r--src/test/java/is/cep/cepis/LookupTest.java127
-rw-r--r--src/test/java/is/cep/cepis/ModelTest.java78
-rw-r--r--src/test/java/is/cep/cepis/NormalizeTest.java38
-rw-r--r--src/test/java/is/cep/cepis/RetryTest.java105
-rw-r--r--src/test/java/is/cep/cepis/TestRunner.java108
28 files changed, 2935 insertions, 0 deletions
diff --git a/src/main/java/is/cep/cepis/Address.java b/src/main/java/is/cep/cepis/Address.java
new file mode 100644
index 0000000..2617463
--- /dev/null
+++ b/src/main/java/is/cep/cepis/Address.java
@@ -0,0 +1,184 @@
+package is.cep.cepis;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A CEP and everything the database knows about it.
+ *
+ * <p>The component names are the API's own, in Portuguese, with only the
+ * casing adapted to Java. Everything else in this library is English; the data
+ * fields are the one deliberate exception, so that what you read in the JSON
+ * at {@code https://cep.is/01001000} is what you access on the object.
+ *
+ * <p>Fields the database does not have for that CEP come back as empty
+ * strings, never {@code null} — that is what the API returns.
+ *
+ * <p>{@link #geo()} is the exception: it is {@code null} when there is no
+ * coordinate. There the difference is real, because the API <b>omits</b> the
+ * whole field in that case (about 43% of CEPs) rather than sending null. It is
+ * also always {@code null} for an address that came from
+ * {@link Client#lookupMany(List)}, which carries no coordinate at all.
+ *
+ * <p>{@link #raw()} holds the response exactly as it arrived, uninterpreted,
+ * so a field the API grows before this library is updated is not lost. It
+ * takes no part in {@link #equals(Object)}, {@link #hashCode()} or
+ * {@link #toString()}: two addresses describing the same place are equal even
+ * if one of them was built by hand.
+ *
+ * @param cep the eight digits, always unmasked
+ * @param logradouro the street
+ * @param complemento the qualifier on the street, such as a range of numbers
+ * @param unidade the named unit, for a CEP that belongs to one
+ * @param bairro the neighbourhood
+ * @param localidade the municipality
+ * @param uf the two-letter state code
+ * @param estado the state's name
+ * @param regiao the region of the country
+ * @param ibge the IBGE municipality code
+ * @param gia the GIA code, São Paulo only
+ * @param ddd the telephone area code
+ * @param siafi the SIAFI municipality code
+ * @param geo the coordinate, or {@code null} when there is none
+ * @param raw the decoded response, unmodifiable
+ */
+public record Address(
+ String cep,
+ String logradouro,
+ String complemento,
+ String unidade,
+ String bairro,
+ String localidade,
+ String uf,
+ String estado,
+ String regiao,
+ String ibge,
+ String gia,
+ String ddd,
+ String siafi,
+ Geo geo,
+ Map<String, Object> raw) {
+
+ /** Canonical constructor. A {@code null} {@code raw} becomes an empty map. */
+ public Address {
+ raw = raw == null
+ ? Map.of()
+ : Collections.unmodifiableMap(new LinkedHashMap<>(raw));
+ }
+
+ /**
+ * Build an address with no raw response behind it, for tests and fixtures.
+ *
+ * @param cep the eight digits, always unmasked
+ * @param logradouro the street
+ * @param complemento the qualifier on the street
+ * @param unidade the named unit
+ * @param bairro the neighbourhood
+ * @param localidade the municipality
+ * @param uf the two-letter state code
+ * @param estado the state's name
+ * @param regiao the region of the country
+ * @param ibge the IBGE municipality code
+ * @param gia the GIA code
+ * @param ddd the telephone area code
+ * @param siafi the SIAFI municipality code
+ * @param geo the coordinate, or {@code null}
+ */
+ public Address(
+ String cep,
+ String logradouro,
+ String complemento,
+ String unidade,
+ String bairro,
+ String localidade,
+ String uf,
+ String estado,
+ String regiao,
+ String ibge,
+ String gia,
+ String ddd,
+ String siafi,
+ Geo geo) {
+ this(cep, logradouro, complemento, unidade, bairro, localidade, uf, estado, regiao,
+ ibge, gia, ddd, siafi, geo, Map.of());
+ }
+
+ /**
+ * The CEP with its mask, {@code 01001-000}.
+ *
+ * <p>The API always returns it unmasked; formatting is presentation, and it
+ * lives here so it does not become a repeated string concatenation in every
+ * project that uses this library.
+ *
+ * @return the masked CEP, or {@link #cep()} unchanged if it is not eight
+ * characters long
+ */
+ public String formattedCep() {
+ return cep != null && cep.length() == 8
+ ? cep.substring(0, 5) + "-" + cep.substring(5)
+ : cep;
+ }
+
+ static Address fromJson(Map<String, ?> data) {
+ Geo geo = null;
+ if (data.get("geo") instanceof Map<?, ?> block) {
+ try {
+ geo = Geo.fromJson(block);
+ } catch (RuntimeException malformed) {
+ geo = null;
+ }
+ }
+ return new Address(
+ Json.string(data, "cep"),
+ Json.string(data, "logradouro"),
+ Json.string(data, "complemento"),
+ Json.string(data, "unidade"),
+ Json.string(data, "bairro"),
+ Json.string(data, "localidade"),
+ Json.string(data, "uf"),
+ Json.string(data, "estado"),
+ Json.string(data, "regiao"),
+ Json.string(data, "ibge"),
+ Json.string(data, "gia"),
+ Json.string(data, "ddd"),
+ Json.string(data, "siafi"),
+ geo,
+ new LinkedHashMap<String, Object>(data));
+ }
+
+ private List<Object> identity() {
+ return Arrays.asList(cep, logradouro, complemento, unidade, bairro, localidade, uf,
+ estado, regiao, ibge, gia, ddd, siafi, geo);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof Address address && identity().equals(address.identity());
+ }
+
+ @Override
+ public int hashCode() {
+ return identity().hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return "Address[cep=" + cep
+ + ", logradouro=" + logradouro
+ + ", complemento=" + complemento
+ + ", unidade=" + unidade
+ + ", bairro=" + bairro
+ + ", localidade=" + localidade
+ + ", uf=" + uf
+ + ", estado=" + estado
+ + ", regiao=" + regiao
+ + ", ibge=" + ibge
+ + ", gia=" + gia
+ + ", ddd=" + ddd
+ + ", siafi=" + siafi
+ + ", geo=" + geo + "]";
+ }
+}
diff --git a/src/main/java/is/cep/cepis/CepNotFoundException.java b/src/main/java/is/cep/cepis/CepNotFoundException.java
new file mode 100644
index 0000000..d6865d7
--- /dev/null
+++ b/src/main/java/is/cep/cepis/CepNotFoundException.java
@@ -0,0 +1,20 @@
+package is.cep.cepis;
+
+/**
+ * The CEP is well-formed but does not exist in the DNE database.
+ *
+ * <p>Definitive: the client does not retry it, and neither should you.
+ */
+public class CepNotFoundException extends CepisException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Create the exception.
+ *
+ * @param message what went wrong
+ */
+ public CepNotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/is/cep/cepis/Cepis.java b/src/main/java/is/cep/cepis/Cepis.java
new file mode 100644
index 0000000..fb8b742
--- /dev/null
+++ b/src/main/java/is/cep/cepis/Cepis.java
@@ -0,0 +1,109 @@
+package is.cep.cepis;
+
+import java.util.List;
+
+/**
+ * Java client for the public CEP API at <a href="https://cep.is">cep.is</a>.
+ *
+ * <p>For a script, the static methods here are enough:
+ *
+ * <pre>{@code
+ * Address address = Cepis.lookup("01001-000");
+ * System.out.println(address.localidade() + " " + address.uf());
+ * }</pre>
+ *
+ * <p>In an application, build a {@link Client} once and reuse it — that is the
+ * only way to change the configuration, and it keeps the connection pool:
+ *
+ * <pre>{@code
+ * Client client = Client.builder()
+ * .baseUrl("http://localhost:8080")
+ * .totalTimeout(Duration.ofSeconds(15))
+ * .build();
+ * }</pre>
+ *
+ * <p>Errors are explicit and split by decision — whether retrying helps or not.
+ * See {@link CepisException}.
+ *
+ * <p>A note on names: the API surface is English, but the <b>data fields keep
+ * the API's own Portuguese names</b> ({@code logradouro}, {@code bairro},
+ * {@code localidade}), with only the casing adapted. What you read in the JSON
+ * is what you access on the object.
+ *
+ * <p>The static methods share one default client, built lazily on first use.
+ * It is safe to share because {@link Client} keeps no state between calls;
+ * anyone needing different settings should build their own rather than expect
+ * to reconfigure this one, which applies process-wide.
+ */
+public final class Cepis {
+
+ /** The version of this library. */
+ public static final String VERSION = "0.1.0";
+
+ private Cepis() {
+ }
+
+ private static final class Default {
+ private static final Client CLIENT = Client.create();
+
+ private Default() {
+ }
+ }
+
+ /**
+ * The shared client behind the static methods.
+ *
+ * @return the default client
+ */
+ public static Client defaultClient() {
+ return Default.CLIENT;
+ }
+
+ /**
+ * Look up a CEP using the default configuration.
+ *
+ * @param cep the CEP, masked or not
+ * @return the address
+ * @see Client#lookup(String)
+ */
+ public static Address lookup(String cep) {
+ return Default.CLIENT.lookup(cep);
+ }
+
+ /**
+ * Look up many CEPs in one batch, using the default configuration.
+ *
+ * <p>Read {@link Client#lookupMany(List)}, especially the warning that
+ * batches carry no coordinate.
+ *
+ * @param ceps the CEPs, masked or not
+ * @return one entry per input CEP, in order, {@code null} where there is no
+ * address
+ * @see Client#lookupMany(List)
+ */
+ public static List<Address> lookupMany(List<String> ceps) {
+ return Default.CLIENT.lookupMany(ceps);
+ }
+
+ /**
+ * Query the API's health using the default configuration.
+ *
+ * @return the API's health
+ * @see Client#health()
+ */
+ public static Health health() {
+ return Default.CLIENT.health();
+ }
+
+ /**
+ * Return the CEP's eight digits, accepting the mask.
+ *
+ * @param cep the CEP, masked or not
+ * @return the eight digits
+ * @throws InvalidCepException unless exactly eight digits remain
+ * @see Client#normalizeCep(String)
+ */
+ public static String normalizeCep(String cep) {
+ return Client.normalizeCep(cep);
+ }
+}
diff --git a/src/main/java/is/cep/cepis/CepisException.java b/src/main/java/is/cep/cepis/CepisException.java
new file mode 100644
index 0000000..8dbcdcb
--- /dev/null
+++ b/src/main/java/is/cep/cepis/CepisException.java
@@ -0,0 +1,49 @@
+package is.cep.cepis;
+
+/**
+ * The base of every error this library raises. Catch it to handle any failure.
+ *
+ * <p>Below it, the split is by <b>the decision the caller has to make</b>, not
+ * by the HTTP status that caused it:
+ *
+ * <ul>
+ * <li>{@link InvalidCepException} and {@link CepNotFoundException} are
+ * problems with the <i>request</i>. Retrying is pointless — the same CEP
+ * will fail again.</li>
+ * <li>{@link NetworkException}, {@link ServerErrorException} and
+ * {@link RateLimitedException} are problems with <i>transport</i>.
+ * Retrying may help, and the client already retried on its own before
+ * raising any of them.</li>
+ * <li>{@link InvalidResponseException} means a success status came back with
+ * a body nobody can read.</li>
+ * </ul>
+ *
+ * <p>These are unchecked. Java allows one superclass, and a single base that
+ * catches everything is worth more than each error also being an
+ * {@link IllegalArgumentException} or a {@link java.util.NoSuchElementException};
+ * a checked hierarchy would additionally put {@code throws} on every call site
+ * that only ever looks a CEP up.
+ */
+public class CepisException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Create the exception.
+ *
+ * @param message what went wrong
+ */
+ public CepisException(String message) {
+ super(message);
+ }
+
+ /**
+ * Create the exception with an underlying cause.
+ *
+ * @param message what went wrong
+ * @param cause the failure behind it, possibly {@code null}
+ */
+ public CepisException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/is/cep/cepis/Client.java b/src/main/java/is/cep/cepis/Client.java
new file mode 100644
index 0000000..afc5705
--- /dev/null
+++ b/src/main/java/is/cep/cepis/Client.java
@@ -0,0 +1,613 @@
+package is.cep.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.CharacterCodingException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.regex.Pattern;
+
+/**
+ * Client for the cep.is API.
+ *
+ * <p>Build one with {@link #create()} or {@link #builder()} and reuse it: the
+ * configuration is immutable, no method keeps state between calls, and the
+ * underlying {@link HttpClient} pools connections, so sharing one instance
+ * across threads is both safe and faster than creating them.
+ *
+ * <pre>{@code
+ * Client client = Client.builder()
+ * .baseUrl("http://localhost:8080")
+ * .timeout(Duration.ofSeconds(5))
+ * .build();
+ *
+ * Address address = client.lookup("01001-000");
+ * }</pre>
+ *
+ * <p>Retries cover 429, the transient 5xx and network failures, with
+ * exponential backoff and full jitter. 400 and 404 are never retried: both are
+ * definitive answers about the request, so retrying returns the same thing more
+ * slowly while burning someone else's quota.
+ */
+public final class Client {
+
+ /** Where the public API lives. */
+ public static final String DEFAULT_URL = "https://cep.is";
+
+ /** How many CEPs the batch endpoint accepts in one request. */
+ public static final int MAX_BATCH = 500;
+
+ private static final Set<Integer> RETRIABLE_STATUS = Set.of(429, 500, 502, 503, 504);
+ private static final Set<Integer> OK_ONLY = Set.of(200);
+ private static final Set<Integer> HEALTH_STATUS = Set.of(200, 503);
+ private static final Pattern EIGHT_DIGITS = Pattern.compile("[0-9]{8}");
+ private static final Pattern DELTA_SECONDS = Pattern.compile("[0-9]+(\\.[0-9]+)?");
+ private static final String ACCEPT = "application/json, text/csv;q=0.9, */*;q=0.1";
+
+ private final String baseUrl;
+ private final Duration timeout;
+ private final int maxAttempts;
+ private final Duration backoffBase;
+ private final Duration backoffMax;
+ private final Duration totalTimeout;
+ private final String userAgent;
+ private final HttpClient http;
+
+ private Client(Builder builder) {
+ this.baseUrl = builder.baseUrl;
+ this.timeout = builder.timeout;
+ this.maxAttempts = builder.maxAttempts;
+ this.backoffBase = builder.backoffBase;
+ this.backoffMax = builder.backoffMax;
+ this.totalTimeout = builder.totalTimeout;
+ this.userAgent = builder.userAgent != null
+ ? builder.userAgent
+ : "cepis-java/" + Cepis.VERSION + " (+https://cep.is)";
+ this.http = HttpClient.newBuilder()
+ .connectTimeout(this.timeout)
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+ }
+
+ /**
+ * A client with the default configuration, pointed at {@link #DEFAULT_URL}.
+ *
+ * @return a new client
+ */
+ public static Client create() {
+ return builder().build();
+ }
+
+ /**
+ * Start configuring a client.
+ *
+ * @return a new builder holding the defaults
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Return the CEP's eight digits, accepting the mask.
+ *
+ * <p>{@code "01001-000"}, {@code "01001000"} and {@code " 01001-000 "} all
+ * become {@code "01001000"}.
+ *
+ * <p>This runs <b>before</b> any request: a seven-digit CEP is the caller's
+ * bug, and spending a round trip for the server to say so wastes both
+ * sides' time.
+ *
+ * @param value the CEP, masked or not
+ * @return the eight digits
+ * @throws InvalidCepException unless exactly eight digits remain
+ */
+ public static String normalizeCep(String value) {
+ if (value == null) {
+ throw new InvalidCepException("the CEP must not be null");
+ }
+ String cleaned = value.strip()
+ .replace("-", "")
+ .replace(".", "")
+ .replace(" ", "");
+ if (!EIGHT_DIGITS.matcher(cleaned).matches()) {
+ throw new InvalidCepException("the CEP must be 8 digits, got \"" + value + "\"");
+ }
+ return cleaned;
+ }
+
+ /**
+ * Look up a single CEP.
+ *
+ * <p>This is the only call that carries a coordinate. About 57% of CEPs
+ * have one; when they do not, {@link Address#geo()} is {@code null}.
+ *
+ * @param cep the CEP, masked or not
+ * @return the address
+ * @throws InvalidCepException if the CEP is not eight digits, in which case
+ * no request is made, or if the API rejects it with a 400
+ * @throws CepNotFoundException if it is well-formed but not in the database
+ * @throws RateLimitedException if the API answered 429 on every attempt
+ * @throws ServerErrorException if the API answered 5xx on every attempt
+ * @throws NetworkException if the request never completed
+ * @throws InvalidResponseException if the body cannot be read
+ */
+ public Address lookup(String cep) {
+ String number = normalizeCep(cep);
+ Response response = request("/" + number, OK_ONLY);
+
+ if (response.status() == 404) {
+ throw new CepNotFoundException(
+ "CEP " + number + " does not exist in the DNE database");
+ }
+ if (response.status() == 400) {
+ throw new InvalidCepException(
+ errorMessage(response.body(), "CEP " + number + " was rejected by the API"));
+ }
+ return Address.fromJson(jsonObject(response.body()));
+ }
+
+ /**
+ * Look up many CEPs in one batch.
+ *
+ * <p>The returned list has <b>the same size and the same order</b> as the
+ * input. A CEP that does not exist becomes {@code null} at its position —
+ * it does not vanish from the list, and it does not throw. That is the
+ * right difference from {@link #lookup(String)}: in a batch of a thousand,
+ * one bad CEP must not cost the other nine hundred and ninety-nine.
+ *
+ * <p>A malformed CEP also becomes {@code null}, for the same reason. If you
+ * need to know <i>which</i> one was malformed, validate first with
+ * {@link #normalizeCep(String)}.
+ *
+ * <p>Input longer than {@value #MAX_BATCH} is chunked into that many CEPs
+ * per request automatically.
+ *
+ * <p><b>Batches carry no coordinate.</b> The endpoint behind them is CSV,
+ * and the CSV has no {@code geo} column. Every address returned here has
+ * {@link Address#geo()} {@code null}, including for CEPs that do have one.
+ * Need the coordinate? Use {@link #lookup(String)}, one at a time.
+ *
+ * @param ceps the CEPs, masked or not
+ * @return one entry per input CEP, in order, {@code null} where there is no
+ * address; the list itself is unmodifiable
+ * @throws RateLimitedException if the API answered 429 on every attempt
+ * @throws ServerErrorException if the API answered 5xx on every attempt
+ * @throws NetworkException if a request never completed
+ */
+ public List<Address> lookupMany(List<String> ceps) {
+ Objects.requireNonNull(ceps, "ceps");
+ if (ceps.isEmpty()) {
+ return List.of();
+ }
+ List<Address> result = new ArrayList<>(ceps.size());
+ for (int start = 0; start < ceps.size(); start += MAX_BATCH) {
+ int end = Math.min(start + MAX_BATCH, ceps.size());
+ result.addAll(lookupChunk(ceps.subList(start, end)));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ /**
+ * Query {@code GET /health}: does the API answer, and with which database.
+ *
+ * <p>An API reporting itself degraded does not throw — that arrives in
+ * {@link Health#status()}, and checking health is exactly when you want the
+ * diagnosis rather than an exception. Network failures still throw.
+ *
+ * @return the API's health
+ * @throws NetworkException if the request never completed
+ * @throws InvalidResponseException if the body cannot be read
+ */
+ public Health health() {
+ Response response = request("/health", HEALTH_STATUS);
+ return Health.fromJson(jsonObject(response.body()));
+ }
+
+ /**
+ * Where the API lives, without a trailing slash.
+ *
+ * @return the base URL
+ */
+ public String baseUrl() {
+ return baseUrl;
+ }
+
+ /**
+ * How long a single attempt may take.
+ *
+ * <p>It bounds the connection and the exchange of one attempt, not the whole
+ * call: with three attempts and the waits between them, the worst case is
+ * roughly three times this plus the backoffs. {@link #totalTimeout()} is the
+ * cap on the whole call.
+ *
+ * @return the per-attempt timeout
+ */
+ public Duration timeout() {
+ return timeout;
+ }
+
+ /**
+ * How many attempts in total, counting the first.
+ *
+ * @return the attempt budget
+ */
+ public int maxAttempts() {
+ return maxAttempts;
+ }
+
+ /**
+ * The base of the exponential wait between attempts.
+ *
+ * @return the backoff base
+ */
+ public Duration backoffBase() {
+ return backoffBase;
+ }
+
+ /**
+ * The cap on each individual wait, a server-sent {@code Retry-After}
+ * included.
+ *
+ * @return the backoff ceiling
+ */
+ public Duration backoffMax() {
+ return backoffMax;
+ }
+
+ /**
+ * The cap on the whole call, attempts and waits included.
+ *
+ * @return the total timeout, or {@code null} when {@link #maxAttempts()} is
+ * the only bound
+ */
+ public Duration totalTimeout() {
+ return totalTimeout;
+ }
+
+ /**
+ * The {@code User-Agent} this client sends.
+ *
+ * @return the user agent
+ */
+ public String userAgent() {
+ return userAgent;
+ }
+
+ private List<Address> lookupChunk(List<String> ceps) {
+ String query = "/csv?ceps=" + URLEncoder.encode(
+ String.join(",", ceps), StandardCharsets.UTF_8);
+ Response response = request(query, OK_ONLY);
+
+ String text = new String(response.body(), StandardCharsets.UTF_8);
+ List<Address> rows = new ArrayList<>(ceps.size());
+ for (Map<String, String> row : Csv.parse(text)) {
+ String localidade = row.getOrDefault("localidade", "");
+ rows.add(localidade.isBlank() ? null : Address.fromJson(row));
+ }
+ while (rows.size() < ceps.size()) {
+ rows.add(null);
+ }
+ return rows.size() > ceps.size() ? rows.subList(0, ceps.size()) : rows;
+ }
+
+ private Response request(String path, Set<Integer> acceptedStatus) {
+ URI uri = URI.create(baseUrl + path);
+ boolean bounded = totalTimeout != null;
+ long deadline = bounded ? System.nanoTime() + totalTimeout.toNanos() : 0L;
+
+ int attempts = 0;
+ int lastStatus = 0;
+ Duration lastRetryAfter = null;
+ IOException lastFailure = null;
+
+ for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+ if (bounded && System.nanoTime() >= deadline) {
+ break;
+ }
+ attempts = attempt;
+
+ HttpRequest httpRequest = HttpRequest.newBuilder(uri)
+ .GET()
+ .header("User-Agent", userAgent)
+ .header("Accept", ACCEPT)
+ .timeout(timeout)
+ .build();
+
+ try {
+ HttpResponse<byte[]> httpResponse =
+ http.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray());
+ int status = httpResponse.statusCode();
+ if (acceptedStatus.contains(status) || !RETRIABLE_STATUS.contains(status)) {
+ return new Response(status, httpResponse.body());
+ }
+ lastStatus = status;
+ lastRetryAfter = parseRetryAfter(
+ httpResponse.headers().firstValue("Retry-After").orElse(null));
+ lastFailure = null;
+ } catch (IOException unreachable) {
+ lastFailure = unreachable;
+ lastStatus = 0;
+ lastRetryAfter = null;
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw new NetworkException("interrupted while requesting " + uri, interrupted);
+ }
+
+ if (attempt == maxAttempts || !waitBeforeRetry(attempt, lastRetryAfter, deadline,
+ bounded)) {
+ break;
+ }
+ }
+
+ String suffix = " (after " + attempts + " attempt(s))";
+ if (lastStatus == 429) {
+ throw new RateLimitedException("rate limit reached at " + uri + suffix,
+ lastRetryAfter);
+ }
+ if (lastStatus != 0) {
+ throw new ServerErrorException(
+ "the API answered " + lastStatus + " at " + uri + suffix, lastStatus);
+ }
+ throw new NetworkException("could not reach " + uri + suffix + ": " + lastFailure,
+ lastFailure);
+ }
+
+ private boolean waitBeforeRetry(int attempt, Duration retryAfter, long deadline,
+ boolean bounded) {
+ long waitMillis = backoffMillis(attempt, retryAfter);
+ if (bounded) {
+ long remaining = (deadline - System.nanoTime()) / 1_000_000L;
+ if (remaining <= 0) {
+ return false;
+ }
+ waitMillis = Math.min(waitMillis, remaining);
+ }
+ if (waitMillis <= 0) {
+ return true;
+ }
+ try {
+ Thread.sleep(waitMillis);
+ return true;
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+
+ private long backoffMillis(int attempt, Duration retryAfter) {
+ long ceiling = backoffMax.toMillis();
+ if (retryAfter != null) {
+ return Math.min(retryAfter.toMillis(), ceiling);
+ }
+ long exponential = backoffBase.toMillis() << Math.min(attempt - 1, 32);
+ return (long) (ThreadLocalRandom.current().nextDouble() * Math.min(exponential, ceiling));
+ }
+
+ static Duration parseRetryAfter(String value) {
+ if (value == null) {
+ return null;
+ }
+ String header = value.strip();
+ if (DELTA_SECONDS.matcher(header).matches()) {
+ return Duration.ofMillis((long) (Double.parseDouble(header) * 1000L));
+ }
+ try {
+ ZonedDateTime target =
+ ZonedDateTime.parse(header, DateTimeFormatter.RFC_1123_DATE_TIME);
+ Duration wait = Duration.between(ZonedDateTime.now(target.getZone()), target);
+ return wait.isNegative() ? Duration.ZERO : wait;
+ } catch (DateTimeParseException notADate) {
+ return null;
+ }
+ }
+
+ private static Map<String, Object> jsonObject(byte[] body) {
+ Object data;
+ try {
+ data = Json.parse(Json.decodeUtf8(body));
+ } catch (CharacterCodingException | Json.SyntaxException unreadable) {
+ throw new InvalidResponseException(
+ "the response is not valid JSON — something between you and the API is "
+ + "answering instead of it: " + preview(body), unreadable);
+ }
+ if (!(data instanceof Map)) {
+ throw new InvalidResponseException("expected a JSON object, got "
+ + (data == null ? "null" : data.getClass().getSimpleName()));
+ }
+ @SuppressWarnings("unchecked")
+ Map<String, Object> object = (Map<String, Object>) data;
+ return object;
+ }
+
+ private static String errorMessage(byte[] body, String fallback) {
+ try {
+ Object data = Json.parse(Json.decodeUtf8(body));
+ if (data instanceof Map<?, ?> object
+ && object.get("mensagem") instanceof String message
+ && !message.isEmpty()) {
+ return message;
+ }
+ } catch (CharacterCodingException | Json.SyntaxException unreadable) {
+ return fallback;
+ }
+ return fallback;
+ }
+
+ private static String preview(byte[] body) {
+ int length = Math.min(body.length, 120);
+ return new String(body, 0, length, StandardCharsets.UTF_8);
+ }
+
+ private record Response(int status, byte[] body) {
+ }
+
+ /**
+ * Builds a {@link Client}. Every setting has a default; the one that
+ * usually changes between environments is {@link #baseUrl(String)}.
+ */
+ public static final class Builder {
+
+ private String baseUrl = DEFAULT_URL;
+ private Duration timeout = Duration.ofSeconds(10);
+ private int maxAttempts = 3;
+ private Duration backoffBase = Duration.ofMillis(500);
+ private Duration backoffMax = Duration.ofSeconds(8);
+ private Duration totalTimeout;
+ private String userAgent;
+
+ private Builder() {
+ }
+
+ /**
+ * Where the API lives.
+ *
+ * <p>Point it at a local instance or at a test server. A trailing slash
+ * is stripped, so it cannot produce doubled separators that some
+ * proxies normalise and others answer 404 to.
+ *
+ * @param baseUrl the base URL, default {@value Client#DEFAULT_URL}
+ * @return this builder
+ */
+ public Builder baseUrl(String baseUrl) {
+ Objects.requireNonNull(baseUrl, "baseUrl");
+ String trimmed = baseUrl.strip();
+ while (trimmed.endsWith("/")) {
+ trimmed = trimmed.substring(0, trimmed.length() - 1);
+ }
+ this.baseUrl = trimmed;
+ return this;
+ }
+
+ /**
+ * How long a single attempt may take, connection included.
+ *
+ * <p>It is not a cap on the whole call; that is
+ * {@link #totalTimeout(Duration)}.
+ *
+ * @param timeout the per-attempt timeout, default 10 seconds
+ * @return this builder
+ */
+ public Builder timeout(Duration timeout) {
+ this.timeout = Objects.requireNonNull(timeout, "timeout");
+ return this;
+ }
+
+ /**
+ * How many attempts in total, counting the first.
+ *
+ * <p>{@code 1} disables retrying. The default of {@code 3} covers the
+ * common case, one transient failure, without turning a real outage
+ * into a long wait.
+ *
+ * @param maxAttempts the attempt budget, default 3
+ * @return this builder
+ */
+ public Builder maxAttempts(int maxAttempts) {
+ this.maxAttempts = maxAttempts;
+ return this;
+ }
+
+ /**
+ * The base of the exponential wait between attempts.
+ *
+ * <p>The actual wait is drawn from {@code [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 backoffBase the backoff base, default 500 milliseconds
+ * @return this builder
+ */
+ public Builder backoffBase(Duration backoffBase) {
+ this.backoffBase = Objects.requireNonNull(backoffBase, "backoffBase");
+ return this;
+ }
+
+ /**
+ * The cap on each individual wait.
+ *
+ * <p>It applies to a server-sent {@code Retry-After} too, or a
+ * {@code Retry-After: 3600} from a misconfigured proxy would park the
+ * thread for an hour.
+ *
+ * @param backoffMax the backoff ceiling, default 8 seconds
+ * @return this builder
+ */
+ public Builder backoffMax(Duration backoffMax) {
+ this.backoffMax = Objects.requireNonNull(backoffMax, "backoffMax");
+ return this;
+ }
+
+ /**
+ * The cap on the whole call, attempts and waits included.
+ *
+ * <p>{@code null} leaves {@link #maxAttempts(int)} as the only bound.
+ * Set it in code serving a web request: without it the worst case is
+ * {@code maxAttempts * timeout} plus the sum of the backoffs.
+ *
+ * @param totalTimeout the total budget, or {@code null}; default
+ * {@code null}
+ * @return this builder
+ */
+ public Builder totalTimeout(Duration totalTimeout) {
+ this.totalTimeout = totalTimeout;
+ return this;
+ }
+
+ /**
+ * Overrides the {@code User-Agent}.
+ *
+ * <p>The default identifies the library and its version, which is what
+ * lets the server diagnose an old client misbehaving.
+ *
+ * @param userAgent the user agent, or {@code null} for the default
+ * @return this builder
+ */
+ public Builder userAgent(String userAgent) {
+ this.userAgent = userAgent;
+ return this;
+ }
+
+ /**
+ * Build the client.
+ *
+ * @return a new client
+ * @throws IllegalArgumentException if a setting is out of range
+ */
+ public Client build() {
+ if (baseUrl.isEmpty()) {
+ throw new IllegalArgumentException("baseUrl must not be empty");
+ }
+ if (maxAttempts < 1) {
+ throw new IllegalArgumentException("maxAttempts must be >= 1");
+ }
+ if (timeout.isNegative() || timeout.isZero()) {
+ throw new IllegalArgumentException("timeout must be > 0");
+ }
+ if (backoffBase.isNegative()) {
+ throw new IllegalArgumentException("backoffBase must be >= 0");
+ }
+ if (backoffMax.isNegative()) {
+ throw new IllegalArgumentException("backoffMax must be >= 0");
+ }
+ if (totalTimeout != null && (totalTimeout.isNegative() || totalTimeout.isZero())) {
+ throw new IllegalArgumentException("totalTimeout must be > 0 when set");
+ }
+ return new Client(this);
+ }
+ }
+}
diff --git a/src/main/java/is/cep/cepis/Csv.java b/src/main/java/is/cep/cepis/Csv.java
new file mode 100644
index 0000000..481a6fd
--- /dev/null
+++ b/src/main/java/is/cep/cepis/Csv.java
@@ -0,0 +1,102 @@
+package is.cep.cepis;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An RFC 4180 reader for the batch endpoint.
+ *
+ * <p>Values may contain commas, quotes and newlines, so they arrive
+ * double-quoted with {@code ""} escaping; splitting on commas would corrupt
+ * exactly the addresses that need care. A leading byte order mark is dropped,
+ * and CR, LF and CRLF all end a record.
+ */
+final class Csv {
+
+ private Csv() {
+ }
+
+ /**
+ * Parse a CSV document whose first record is the header.
+ *
+ * <p>Each record becomes a map from column name to value. A record with
+ * fewer fields than the header has the missing ones filled with
+ * {@code ""}; extra fields are dropped.
+ *
+ * @param text the document
+ * @return one map per data record, in order
+ */
+ static List<Map<String, String>> parse(String text) {
+ List<List<String>> records = split(text);
+ if (records.isEmpty()) {
+ return List.of();
+ }
+ List<String> header = records.get(0);
+ List<Map<String, String>> rows = new ArrayList<>(records.size() - 1);
+ for (List<String> record : records.subList(1, records.size())) {
+ Map<String, String> row = new LinkedHashMap<>();
+ for (int i = 0; i < header.size(); i++) {
+ row.put(header.get(i), i < record.size() ? record.get(i) : "");
+ }
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ private static List<List<String>> split(String text) {
+ List<List<String>> records = new ArrayList<>();
+ List<String> record = new ArrayList<>();
+ StringBuilder field = new StringBuilder();
+ boolean quoted = false;
+ boolean pending = false;
+
+ int i = text.startsWith("\uFEFF") ? 1 : 0;
+ for (; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (quoted) {
+ if (c != '"') {
+ field.append(c);
+ } else if (i + 1 < text.length() && text.charAt(i + 1) == '"') {
+ field.append('"');
+ i++;
+ } else {
+ quoted = false;
+ }
+ continue;
+ }
+ switch (c) {
+ case '"':
+ quoted = true;
+ pending = true;
+ break;
+ case ',':
+ record.add(field.toString());
+ field.setLength(0);
+ pending = true;
+ break;
+ case '\r':
+ case '\n':
+ if (c == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n') {
+ i++;
+ }
+ record.add(field.toString());
+ field.setLength(0);
+ records.add(record);
+ record = new ArrayList<>();
+ pending = false;
+ break;
+ default:
+ field.append(c);
+ pending = true;
+ break;
+ }
+ }
+ if (pending || field.length() > 0) {
+ record.add(field.toString());
+ records.add(record);
+ }
+ return records;
+ }
+}
diff --git a/src/main/java/is/cep/cepis/Geo.java b/src/main/java/is/cep/cepis/Geo.java
new file mode 100644
index 0000000..7c1ee6a
--- /dev/null
+++ b/src/main/java/is/cep/cepis/Geo.java
@@ -0,0 +1,69 @@
+package is.cep.cepis;
+
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * The CEP's coordinate, when the database has one.
+ *
+ * <p>The point is the <b>median</b> of the coordinates of that CEP's
+ * addresses, not the mean: one badly geocoded address 40&nbsp;km away shifts a
+ * mean and does not move a median.
+ *
+ * <p>Always check {@link #precisao()} before using {@link #lat()} and
+ * {@link #lon()} for distance:
+ *
+ * <ul>
+ * <li>{@code "logradouro"} — the point is a stretch of street.
+ * {@link #raioM()} lands around 100–150&nbsp;m. Good for maps and for
+ * distance.</li>
+ * <li>{@code "localidade"} — the CEP <i>is</i> the whole municipality (a
+ * city-wide general CEP), and the point is its centroid.
+ * {@link #raioM()} runs from 7&nbsp;km to 40&nbsp;km. Computing shipping
+ * from this is wrong by tens of kilometres.</li>
+ * </ul>
+ *
+ * <p>{@link #raioM()} is the radius containing 90% of that CEP's addresses. It
+ * exists precisely so that error cannot pass unnoticed.
+ *
+ * @param lat the latitude, in degrees
+ * @param lon the longitude, in degrees
+ * @param raioM the radius in metres, the API's {@code raio_m}
+ * @param precisao what the point is, {@code "logradouro"} or
+ * {@code "localidade"}
+ */
+public record Geo(double lat, double lon, int raioM, String precisao) {
+
+ /**
+ * Canonical constructor.
+ *
+ * @throws NullPointerException if {@code precisao} is {@code null}
+ */
+ public Geo {
+ Objects.requireNonNull(precisao, "precisao");
+ }
+
+ /**
+ * Whether the coordinate points at a street rather than a municipality.
+ *
+ * <p>Shorthand for {@code "logradouro".equals(precisao())}, which is the
+ * check to make before using the point to measure distance.
+ *
+ * @return {@code true} when the point is a stretch of street
+ */
+ public boolean isPrecise() {
+ return "logradouro".equals(precisao);
+ }
+
+ static Geo fromJson(Map<?, ?> block) {
+ Object precisao = block.get("precisao");
+ if (precisao == null) {
+ throw new IllegalArgumentException("the geo block has no precisao");
+ }
+ return new Geo(
+ Json.toDouble(block.get("lat")),
+ Json.toDouble(block.get("lon")),
+ Json.toInt(block.get("raio_m")),
+ String.valueOf(precisao));
+ }
+}
diff --git a/src/main/java/is/cep/cepis/Health.java b/src/main/java/is/cep/cepis/Health.java
new file mode 100644
index 0000000..722ab5d
--- /dev/null
+++ b/src/main/java/is/cep/cepis/Health.java
@@ -0,0 +1,38 @@
+package is.cep.cepis;
+
+import java.util.Map;
+
+/**
+ * The answer of {@code GET /health}.
+ *
+ * <p>Good for two things: checking that the API answers, and knowing
+ * <b>which version of the database</b> is live. The DNE database changes every
+ * two weeks; {@link #dne()} identifies it.
+ *
+ * @param status {@code "ok"} when the API reports itself healthy
+ * @param dne the identifier of the fortnightly database currently loaded
+ * @param ceps how many CEPs that database holds
+ * @param geo how many of them carry a coordinate
+ * @param baseGeradaEm when the database was generated, the API's
+ * {@code base_gerada_em}, an ISO 8601 instant in UTC
+ */
+public record Health(String status, String dne, long ceps, long geo, String baseGeradaEm) {
+
+ /**
+ * Whether the API reports itself healthy.
+ *
+ * @return {@code true} when {@link #status()} is {@code "ok"}
+ */
+ public boolean ok() {
+ return "ok".equals(status);
+ }
+
+ static Health fromJson(Map<String, ?> data) {
+ return new Health(
+ Json.string(data, "status"),
+ Json.string(data, "dne"),
+ Json.count(data, "ceps"),
+ Json.count(data, "geo"),
+ Json.string(data, "base_gerada_em"));
+ }
+}
diff --git a/src/main/java/is/cep/cepis/InvalidCepException.java b/src/main/java/is/cep/cepis/InvalidCepException.java
new file mode 100644
index 0000000..8558ec5
--- /dev/null
+++ b/src/main/java/is/cep/cepis/InvalidCepException.java
@@ -0,0 +1,25 @@
+package is.cep.cepis;
+
+/**
+ * The CEP is not eight digits.
+ *
+ * <p>Usually raised <b>without</b> a request ever being made: the client checks
+ * the format before going to the network, because a seven-digit CEP is the
+ * caller's bug and a round trip for the server to say so wastes both sides'
+ * time. It also carries the API's own message when a CEP that passed local
+ * validation is rejected with a 400 — there the server knows something this
+ * client does not.
+ */
+public class InvalidCepException extends CepisException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Create the exception.
+ *
+ * @param message what went wrong
+ */
+ public InvalidCepException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/is/cep/cepis/InvalidResponseException.java b/src/main/java/is/cep/cepis/InvalidResponseException.java
new file mode 100644
index 0000000..778e5eb
--- /dev/null
+++ b/src/main/java/is/cep/cepis/InvalidResponseException.java
@@ -0,0 +1,32 @@
+package is.cep.cepis;
+
+/**
+ * A success status came back with a body that cannot be parsed.
+ *
+ * <p>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.
+ */
+public class InvalidResponseException extends CepisException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Create the exception.
+ *
+ * @param message what went wrong
+ */
+ public InvalidResponseException(String message) {
+ super(message);
+ }
+
+ /**
+ * Create the exception with an underlying cause.
+ *
+ * @param message what went wrong
+ * @param cause the parse failure behind it
+ */
+ public InvalidResponseException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/is/cep/cepis/Json.java b/src/main/java/is/cep/cepis/Json.java
new file mode 100644
index 0000000..9a5f17b
--- /dev/null
+++ b/src/main/java/is/cep/cepis/Json.java
@@ -0,0 +1,386 @@
+package is.cep.cepis;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A small strict JSON reader, plus the coercions the models need.
+ *
+ * <p>The JDK ships no JSON parser, and the shapes this API returns are narrow
+ * enough that a recursive-descent reader is smaller than the plumbing needed
+ * to depend on one. Strict is deliberate: trailing content, unterminated
+ * strings and bad escapes are errors, because the failure this catches in
+ * practice is a captive portal or a proxy answering instead of the API.
+ *
+ * <p>Objects become {@link LinkedHashMap} (insertion ordered), arrays
+ * {@link ArrayList}, strings {@link String}, integers {@link Long}, reals
+ * {@link Double}, and {@code true}/{@code false}/{@code null} their obvious
+ * Java counterparts.
+ */
+final class Json {
+
+ /** Thrown when the input is not well-formed JSON. */
+ static final class SyntaxException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ SyntaxException(String message) {
+ super(message);
+ }
+ }
+
+ private static final int MAX_DEPTH = 64;
+
+ private final String text;
+ private int pos;
+
+ private Json(String text) {
+ this.text = text;
+ }
+
+ /**
+ * Decode a body as UTF-8, rejecting malformed bytes instead of replacing
+ * them: a body that is not UTF-8 did not come from this API.
+ *
+ * @param body the raw bytes
+ * @return the decoded text
+ * @throws CharacterCodingException if the bytes are not valid UTF-8
+ */
+ static String decodeUtf8(byte[] body) throws CharacterCodingException {
+ return StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(body))
+ .toString();
+ }
+
+ /**
+ * Parse one complete JSON document.
+ *
+ * @param text the document
+ * @return the decoded value
+ * @throws SyntaxException if the document is not well-formed
+ */
+ static Object parse(String text) {
+ Json parser = new Json(text);
+ parser.skipWhitespace();
+ Object value = parser.parseValue(0);
+ parser.skipWhitespace();
+ if (parser.pos != text.length()) {
+ throw parser.error("unexpected content after the top-level value");
+ }
+ return value;
+ }
+
+ private Object parseValue(int depth) {
+ if (depth > MAX_DEPTH) {
+ throw error("nesting deeper than " + MAX_DEPTH + " levels");
+ }
+ char c = peek();
+ switch (c) {
+ case '{':
+ return parseObject(depth);
+ case '[':
+ return parseArray(depth);
+ case '"':
+ return parseString();
+ case 't':
+ expect("true");
+ return Boolean.TRUE;
+ case 'f':
+ expect("false");
+ return Boolean.FALSE;
+ case 'n':
+ expect("null");
+ return null;
+ default:
+ return parseNumber();
+ }
+ }
+
+ private Map<String, Object> parseObject(int depth) {
+ pos++;
+ Map<String, Object> object = new LinkedHashMap<>();
+ skipWhitespace();
+ if (peek() == '}') {
+ pos++;
+ return object;
+ }
+ while (true) {
+ skipWhitespace();
+ if (peek() != '"') {
+ throw error("expected a string key");
+ }
+ String key = parseString();
+ skipWhitespace();
+ if (next() != ':') {
+ throw error("expected ':' after the key " + key);
+ }
+ skipWhitespace();
+ object.put(key, parseValue(depth + 1));
+ skipWhitespace();
+ char c = next();
+ if (c == '}') {
+ return object;
+ }
+ if (c != ',') {
+ throw error("expected ',' or '}' in the object");
+ }
+ }
+ }
+
+ private List<Object> parseArray(int depth) {
+ pos++;
+ List<Object> array = new ArrayList<>();
+ skipWhitespace();
+ if (peek() == ']') {
+ pos++;
+ return array;
+ }
+ while (true) {
+ skipWhitespace();
+ array.add(parseValue(depth + 1));
+ skipWhitespace();
+ char c = next();
+ if (c == ']') {
+ return array;
+ }
+ if (c != ',') {
+ throw error("expected ',' or ']' in the array");
+ }
+ }
+ }
+
+ private String parseString() {
+ pos++;
+ StringBuilder out = new StringBuilder();
+ while (true) {
+ char c = next();
+ if (c == '"') {
+ return out.toString();
+ }
+ if (c != '\\') {
+ if (c < 0x20) {
+ throw error("unescaped control character in a string");
+ }
+ out.append(c);
+ continue;
+ }
+ char escape = next();
+ switch (escape) {
+ case '"':
+ out.append('"');
+ break;
+ case '\\':
+ out.append('\\');
+ break;
+ case '/':
+ out.append('/');
+ break;
+ case 'b':
+ out.append('\b');
+ break;
+ case 'f':
+ out.append('\f');
+ break;
+ case 'n':
+ out.append('\n');
+ break;
+ case 'r':
+ out.append('\r');
+ break;
+ case 't':
+ out.append('\t');
+ break;
+ case 'u':
+ out.append(parseHexEscape());
+ break;
+ default:
+ throw error("unknown escape \\" + escape);
+ }
+ }
+ }
+
+ private char parseHexEscape() {
+ if (pos + 4 > text.length()) {
+ throw error("truncated \\u escape");
+ }
+ int value = 0;
+ for (int i = 0; i < 4; i++) {
+ int digit = Character.digit(text.charAt(pos + i), 16);
+ if (digit < 0) {
+ throw error("invalid \\u escape");
+ }
+ value = value * 16 + digit;
+ }
+ pos += 4;
+ return (char) value;
+ }
+
+ private Object parseNumber() {
+ int start = pos;
+ if (peek() == '-') {
+ pos++;
+ }
+ int digits = consumeDigits();
+ if (digits == 0) {
+ throw error("expected a number");
+ }
+ if (digits > 1 && text.charAt(start + (text.charAt(start) == '-' ? 1 : 0)) == '0') {
+ throw error("a number may not have a leading zero");
+ }
+ boolean real = false;
+ if (pos < text.length() && text.charAt(pos) == '.') {
+ real = true;
+ pos++;
+ if (consumeDigits() == 0) {
+ throw error("expected digits after the decimal point");
+ }
+ }
+ if (pos < text.length() && (text.charAt(pos) == 'e' || text.charAt(pos) == 'E')) {
+ real = true;
+ pos++;
+ if (pos < text.length() && (text.charAt(pos) == '+' || text.charAt(pos) == '-')) {
+ pos++;
+ }
+ if (consumeDigits() == 0) {
+ throw error("expected digits in the exponent");
+ }
+ }
+ String literal = text.substring(start, pos);
+ if (!real) {
+ try {
+ return Long.valueOf(literal);
+ } catch (NumberFormatException overflow) {
+ return Double.valueOf(literal);
+ }
+ }
+ return Double.valueOf(literal);
+ }
+
+ private int consumeDigits() {
+ int start = pos;
+ while (pos < text.length() && text.charAt(pos) >= '0' && text.charAt(pos) <= '9') {
+ pos++;
+ }
+ return pos - start;
+ }
+
+ private void expect(String literal) {
+ if (!text.startsWith(literal, pos)) {
+ throw error("expected " + literal);
+ }
+ pos += literal.length();
+ }
+
+ private void skipWhitespace() {
+ while (pos < text.length()) {
+ char c = text.charAt(pos);
+ if (c != ' ' && c != '\t' && c != '\n' && c != '\r') {
+ return;
+ }
+ pos++;
+ }
+ }
+
+ private char peek() {
+ if (pos >= text.length()) {
+ throw error("the document ended too early");
+ }
+ return text.charAt(pos);
+ }
+
+ private char next() {
+ char c = peek();
+ pos++;
+ return c;
+ }
+
+ private SyntaxException error(String message) {
+ return new SyntaxException(message + " (at offset " + pos + ")");
+ }
+
+ /**
+ * Read a field as a string, the way the API models it.
+ *
+ * <p>A field the database lacks arrives as {@code ""}, so an absent or
+ * unusable field yields {@code ""} too rather than {@code null}.
+ *
+ * @param data the decoded object
+ * @param key the field name
+ * @return the field's value, or {@code ""}
+ */
+ static String string(Map<String, ?> data, String key) {
+ Object value = data.get(key);
+ if (value instanceof String s) {
+ return s;
+ }
+ if (value instanceof Number || value instanceof Boolean) {
+ return String.valueOf(value);
+ }
+ return "";
+ }
+
+ /**
+ * Read a field as a count, tolerating one sent as a string.
+ *
+ * @param data the decoded object
+ * @param key the field name
+ * @return the field's value, or {@code 0} when absent or unusable
+ */
+ static long count(Map<String, ?> data, String key) {
+ Object value = data.get(key);
+ if (value instanceof Number n) {
+ return n.longValue();
+ }
+ if (value instanceof String s) {
+ try {
+ return Long.parseLong(s.trim());
+ } catch (NumberFormatException notANumber) {
+ return 0L;
+ }
+ }
+ return 0L;
+ }
+
+ /**
+ * Coerce a decoded value to a {@code double}.
+ *
+ * @param value the decoded value
+ * @return the value as a {@code double}
+ * @throws IllegalArgumentException if it is not a number
+ * @throws NumberFormatException if it is a string that is not a number
+ */
+ static double toDouble(Object value) {
+ if (value instanceof Number n) {
+ return n.doubleValue();
+ }
+ if (value instanceof String s) {
+ return Double.parseDouble(s.trim());
+ }
+ throw new IllegalArgumentException("not a number: " + value);
+ }
+
+ /**
+ * Coerce a decoded value to an {@code int}.
+ *
+ * @param value the decoded value
+ * @return the value as an {@code int}
+ * @throws IllegalArgumentException if it is not a number
+ * @throws NumberFormatException if it is a string that is not an integer
+ */
+ static int toInt(Object value) {
+ if (value instanceof Number n) {
+ return n.intValue();
+ }
+ if (value instanceof String s) {
+ return Integer.parseInt(s.trim());
+ }
+ throw new IllegalArgumentException("not an integer: " + value);
+ }
+}
diff --git a/src/main/java/is/cep/cepis/Main.java b/src/main/java/is/cep/cepis/Main.java
new file mode 100644
index 0000000..edf2d91
--- /dev/null
+++ b/src/main/java/is/cep/cepis/Main.java
@@ -0,0 +1,122 @@
+package is.cep.cepis;
+
+import java.io.FileDescriptor;
+import java.io.FileOutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A minimal command line, for one concrete case: confirming quickly that an
+ * instance answers before pointing code at it.
+ *
+ * <pre>{@code
+ * java -jar cepis-0.1.0.jar --base-url http://localhost:8080 01001000
+ * }</pre>
+ *
+ * <p>A single CEP goes through the unit lookup, which carries the coordinate;
+ * several go through the batch, which is one request instead of N and has no
+ * coordinate. It is not the library's main interface; for that, use
+ * {@link Cepis} and {@link Client}.
+ */
+public final class Main {
+
+ private static final PrintStream OUT =
+ new PrintStream(new FileOutputStream(FileDescriptor.out), true,
+ StandardCharsets.UTF_8);
+ private static final PrintStream ERR =
+ new PrintStream(new FileOutputStream(FileDescriptor.err), true,
+ StandardCharsets.UTF_8);
+
+ private Main() {
+ }
+
+ /**
+ * Run the command line.
+ *
+ * @param args one or more CEPs, plus the optional {@code --base-url} and
+ * {@code --timeout} flags
+ */
+ public static void main(String[] args) {
+ System.exit(run(args));
+ }
+
+ static int run(String[] args) {
+ String baseUrl = Client.DEFAULT_URL;
+ Duration timeout = Duration.ofSeconds(10);
+ List<String> ceps = new ArrayList<>();
+
+ for (int i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case "--base-url":
+ if (++i == args.length) {
+ return usage("--base-url needs a URL");
+ }
+ baseUrl = args[i];
+ break;
+ case "--timeout":
+ if (++i == args.length) {
+ return usage("--timeout needs a number of seconds");
+ }
+ timeout = Duration.ofMillis((long) (Double.parseDouble(args[i]) * 1000));
+ break;
+ case "-h":
+ case "--help":
+ return usage(null);
+ default:
+ ceps.add(args[i]);
+ break;
+ }
+ }
+
+ if (ceps.isEmpty()) {
+ return usage("give at least one CEP");
+ }
+
+ Client client = Client.builder().baseUrl(baseUrl).timeout(timeout).build();
+ try {
+ if (ceps.size() == 1) {
+ print(Cepis.normalizeCep(ceps.get(0)), client.lookup(ceps.get(0)));
+ return 0;
+ }
+ List<Address> addresses = client.lookupMany(ceps);
+ for (int i = 0; i < ceps.size(); i++) {
+ print(ceps.get(i), addresses.get(i));
+ }
+ return 0;
+ } catch (CepisException failure) {
+ ERR.println("error: " + failure.getMessage());
+ return 1;
+ }
+ }
+
+ private static void print(String requested, Address address) {
+ if (address == null) {
+ OUT.println(requested + "\tnot found");
+ return;
+ }
+ StringBuilder line = new StringBuilder()
+ .append(address.formattedCep())
+ .append('\t').append(address.logradouro())
+ .append('\t').append(address.bairro())
+ .append('\t').append(address.localidade())
+ .append('\t').append(address.uf());
+ Geo geo = address.geo();
+ if (geo != null) {
+ line.append('\t').append(geo.lat()).append(',').append(geo.lon())
+ .append('\t').append(geo.raioM()).append("m")
+ .append('\t').append(geo.precisao());
+ }
+ OUT.println(line);
+ }
+
+ private static int usage(String problem) {
+ if (problem != null) {
+ ERR.println("error: " + problem);
+ }
+ ERR.println("usage: cepis [--base-url URL] [--timeout SECONDS] CEP [CEP ...]");
+ return problem == null ? 0 : 2;
+ }
+}
diff --git a/src/main/java/is/cep/cepis/NetworkException.java b/src/main/java/is/cep/cepis/NetworkException.java
new file mode 100644
index 0000000..d1decb4
--- /dev/null
+++ b/src/main/java/is/cep/cepis/NetworkException.java
@@ -0,0 +1,22 @@
+package is.cep.cepis;
+
+/**
+ * The request never completed: DNS, refused connection, timeout, TLS.
+ *
+ * <p>Raised only after every configured attempt has failed. The underlying
+ * failure is the cause.
+ */
+public class NetworkException extends CepisException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Create the exception.
+ *
+ * @param message what went wrong
+ * @param cause the failure behind it, possibly {@code null}
+ */
+ public NetworkException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/is/cep/cepis/RateLimitedException.java b/src/main/java/is/cep/cepis/RateLimitedException.java
new file mode 100644
index 0000000..ef89482
--- /dev/null
+++ b/src/main/java/is/cep/cepis/RateLimitedException.java
@@ -0,0 +1,39 @@
+package is.cep.cepis;
+
+import java.time.Duration;
+import java.util.Optional;
+
+/**
+ * The API, or the edge in front of it, answered 429 on every attempt.
+ */
+public class RateLimitedException extends CepisException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final Duration retryAfter;
+
+ /**
+ * Create the exception.
+ *
+ * @param message what went wrong
+ * @param retryAfter what the server asked for, or {@code null} if it did
+ * not say
+ */
+ public RateLimitedException(String message, Duration retryAfter) {
+ super(message);
+ this.retryAfter = retryAfter;
+ }
+
+ /**
+ * How long the server asked the caller to wait.
+ *
+ * <p>The client already honoured this across the attempts it made, capped
+ * at {@link Client#backoffMax()}; it is exposed here for callers who want
+ * to reschedule the work.
+ *
+ * @return the delay the server asked for, empty if it did not say
+ */
+ public Optional<Duration> retryAfter() {
+ return Optional.ofNullable(retryAfter);
+ }
+}
diff --git a/src/main/java/is/cep/cepis/ServerErrorException.java b/src/main/java/is/cep/cepis/ServerErrorException.java
new file mode 100644
index 0000000..2e1e1a3
--- /dev/null
+++ b/src/main/java/is/cep/cepis/ServerErrorException.java
@@ -0,0 +1,31 @@
+package is.cep.cepis;
+
+/**
+ * The API answered 5xx on every attempt.
+ */
+public class ServerErrorException extends CepisException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final int status;
+
+ /**
+ * Create the exception.
+ *
+ * @param message what went wrong
+ * @param status the HTTP status seen on the last attempt
+ */
+ public ServerErrorException(String message, int status) {
+ super(message);
+ this.status = status;
+ }
+
+ /**
+ * The HTTP status seen on the last attempt.
+ *
+ * @return the status code
+ */
+ public int status() {
+ return status;
+ }
+}
diff --git a/src/test/java/is/cep/cepis/Assertions.java b/src/test/java/is/cep/cepis/Assertions.java
new file mode 100644
index 0000000..a9a9d5e
--- /dev/null
+++ b/src/test/java/is/cep/cepis/Assertions.java
@@ -0,0 +1,92 @@
+package is.cep.cepis;
+
+import java.util.Objects;
+
+/** The handful of assertions this suite needs. */
+public final class Assertions {
+
+ private Assertions() {
+ }
+
+ /** Fail unconditionally. */
+ public static void fail(String message) {
+ throw new AssertionError(message);
+ }
+
+ /** Assert that a condition holds. */
+ public static void assertTrue(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message + ": expected true");
+ }
+ }
+
+ /** Assert that a condition does not hold. */
+ public static void assertFalse(boolean condition, String message) {
+ if (condition) {
+ throw new AssertionError(message + ": expected false");
+ }
+ }
+
+ /** Assert that two values are equal. */
+ public static void assertEquals(Object expected, Object actual, String message) {
+ if (!Objects.equals(expected, actual)) {
+ throw new AssertionError(message + ": expected <" + expected + ">, got <" + actual
+ + ">");
+ }
+ }
+
+ /** Assert that two numbers are equal. */
+ public static void assertEquals(long expected, long actual, String message) {
+ if (expected != actual) {
+ throw new AssertionError(message + ": expected <" + expected + ">, got <" + actual
+ + ">");
+ }
+ }
+
+ /** Assert that two reals are equal within a tolerance. */
+ public static void assertEquals(double expected, double actual, double delta,
+ String message) {
+ if (Math.abs(expected - actual) > delta) {
+ throw new AssertionError(message + ": expected <" + expected + ">, got <" + actual
+ + ">");
+ }
+ }
+
+ /** Assert that a value is null. */
+ public static void assertNull(Object value, String message) {
+ if (value != null) {
+ throw new AssertionError(message + ": expected null, got <" + value + ">");
+ }
+ }
+
+ /** Assert that a value is not null. */
+ public static void assertNotNull(Object value, String message) {
+ if (value == null) {
+ throw new AssertionError(message + ": expected a value, got null");
+ }
+ }
+
+ /** Assert that some text contains a fragment. */
+ public static void assertContains(String haystack, String needle, String message) {
+ if (haystack == null || !haystack.contains(needle)) {
+ throw new AssertionError(message + ": expected <" + haystack + "> to contain <"
+ + needle + ">");
+ }
+ }
+
+ /** Assert that the body throws the expected exception, and return it. */
+ public static <T extends Throwable> T assertThrows(Class<T> expected, Runnable body,
+ String message) {
+ try {
+ body.run();
+ } catch (Throwable thrown) {
+ if (expected.isInstance(thrown)) {
+ return expected.cast(thrown);
+ }
+ throw new AssertionError(message + ": expected " + expected.getSimpleName()
+ + ", got " + thrown, thrown);
+ }
+ throw new AssertionError(message + ": expected " + expected.getSimpleName()
+ + ", nothing was thrown");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/BatchTest.java b/src/test/java/is/cep/cepis/BatchTest.java
new file mode 100644
index 0000000..4b18c35
--- /dev/null
+++ b/src/test/java/is/cep/cepis/BatchTest.java
@@ -0,0 +1,119 @@
+package is.cep.cepis;
+
+import static is.cep.cepis.Assertions.assertEquals;
+import static is.cep.cepis.Assertions.assertNotNull;
+import static is.cep.cepis.Assertions.assertNull;
+
+import is.cep.cepis.FakeServer.Reply;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.IntStream;
+
+/** The batch endpoint: order, gaps, chunking and the missing coordinate. */
+public final class BatchTest extends HttpTest {
+
+ /** A missing CEP becomes null at its position; it does not vanish. */
+ public void testPreservesOrderAndMarksMissing() {
+ server.respond("/csv?ceps=01001000%2C99999999%2C20040020", Reply.of(200, 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")));
+
+ List<Address> result = client.lookupMany(
+ List.of("01001000", "99999999", "20040020"));
+
+ assertEquals(3, result.size(), "one entry per input");
+ assertNotNull(result.get(0), "the first was found");
+ assertEquals("São Paulo", result.get(0).localidade(), "localidade");
+ assertNull(result.get(1), "the missing one is null");
+ assertNotNull(result.get(2), "the third was found");
+ assertEquals("RJ", result.get(2).uf(), "uf");
+ }
+
+ /** The batch carries no coordinate, ever. */
+ public void testBatchNeverCarriesGeo() {
+ server.respond("/csv?ceps=01001000", Reply.of(200, csv(
+ "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,"
+ + "7107")));
+ List<Address> result = client.lookupMany(List.of("01001000"));
+ assertNull(result.get(0).geo(), "no geo column, no geo");
+ }
+
+ /** Values with commas are quoted, and splitting on commas would corrupt them. */
+ public void testFieldWithCommaIsParsed() {
+ server.respond("/csv?ceps=01001000", Reply.of(200, csv(
+ "01001000,\"Rua Um, Dois\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1")));
+ List<Address> result = client.lookupMany(List.of("01001000"));
+ assertNotNull(result.get(0), "found");
+ assertEquals("Rua Um, Dois", result.get(0).logradouro(), "the comma survived");
+ }
+
+ /** An embedded quote arrives doubled. */
+ public void testFieldWithQuoteIsParsed() {
+ server.respond("/csv?ceps=01001000", Reply.of(200, csv(
+ "01001000,\"Rua \"\"A\"\"\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1")));
+ List<Address> result = client.lookupMany(List.of("01001000"));
+ assertEquals("Rua \"A\"", result.get(0).logradouro(), "the quotes survived");
+ }
+
+ /** An empty input costs no request. */
+ public void testEmptyListMakesNoRequest() {
+ assertEquals(0, client.lookupMany(List.of()).size(), "an empty result");
+ assertEquals(0, server.calls().size(), "no request was made");
+ }
+
+ /** 501 CEPs must become 2 requests, not 1 the API would reject. */
+ public void testChunksAboveTheApiLimit() {
+ List<String> ceps = IntStream.range(0, 501)
+ .mapToObj(i -> String.format("%08d", i))
+ .toList();
+ AtomicInteger requests = new AtomicInteger();
+ server.handler(path -> {
+ int nth = requests.incrementAndGet();
+ List<String> slice = nth == 1 ? ceps.subList(0, 500) : ceps.subList(500, 501);
+ List<String> rows = new ArrayList<>(slice.size());
+ for (String cep : slice) {
+ rows.add(cep + ",,,,,Cidade,SP,São Paulo,Sudeste,1,,11,1");
+ }
+ return Reply.of(200, csv(rows.toArray(new String[0])));
+ });
+
+ List<Address> result = client.lookupMany(ceps);
+
+ assertEquals(2, requests.get(), "two requests");
+ assertEquals(501, result.size(), "one entry per input");
+ assertNotNull(result.get(500), "the 501st came from the second chunk");
+ }
+
+ /** Padding beats shifting: a shift pairs the wrong address, silently. */
+ public void testTruncatedResponseDoesNotShift() {
+ server.respond("/csv?ceps=01001000%2C20040020", Reply.of(200, csv(
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1")));
+ List<Address> result = client.lookupMany(List.of("01001000", "20040020"));
+ assertEquals(2, result.size(), "the length matches the input");
+ assertNotNull(result.get(0), "the first is still the first");
+ assertNull(result.get(1), "the absent one is null, not shifted");
+ }
+
+ /** A response longer than the request is trimmed rather than trusted. */
+ public void testOverlongResponseIsTrimmed() {
+ server.respond("/csv?ceps=01001000", Reply.of(200, csv(
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ "20040020,,,,,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,2,,21,2")));
+ List<Address> result = client.lookupMany(List.of("01001000"));
+ assertEquals(1, result.size(), "one in, one out");
+ }
+
+ /** A malformed CEP is null at its position rather than an exception. */
+ public void testMalformedCepIsNullNotAnError() {
+ server.respond("/csv?ceps=01001000%2Cnope", Reply.of(200, csv(
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ "nope,,,,,,,,,,,,")));
+ List<Address> result = client.lookupMany(List.of("01001000", "nope"));
+ assertEquals(2, result.size(), "nothing was dropped");
+ assertNull(result.get(1), "the malformed one is null");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/ConfigurationTest.java b/src/test/java/is/cep/cepis/ConfigurationTest.java
new file mode 100644
index 0000000..95fe5ca
--- /dev/null
+++ b/src/test/java/is/cep/cepis/ConfigurationTest.java
@@ -0,0 +1,52 @@
+package is.cep.cepis;
+
+import static is.cep.cepis.Assertions.assertContains;
+import static is.cep.cepis.Assertions.assertEquals;
+import static is.cep.cepis.Assertions.assertNull;
+import static is.cep.cepis.Assertions.assertThrows;
+
+import java.time.Duration;
+
+/** The builder and its defaults. */
+public final class ConfigurationTest {
+
+ /** A trailing slash cannot produce a doubled separator. */
+ public void testTrailingSlashIsNotDuplicated() {
+ assertEquals("http://x", Client.builder().baseUrl("http://x/").build().baseUrl(),
+ "one slash");
+ assertEquals("http://x", Client.builder().baseUrl("http://x///").build().baseUrl(),
+ "several slashes");
+ }
+
+ /** The defaults are the documented ones. */
+ public void testDefaults() {
+ Client client = Client.create();
+ assertEquals(Client.DEFAULT_URL, client.baseUrl(), "baseUrl");
+ assertEquals(Duration.ofSeconds(10), client.timeout(), "timeout");
+ assertEquals(3, client.maxAttempts(), "maxAttempts");
+ assertEquals(Duration.ofMillis(500), client.backoffBase(), "backoffBase");
+ assertEquals(Duration.ofSeconds(8), client.backoffMax(), "backoffMax");
+ assertNull(client.totalTimeout(), "totalTimeout is unset");
+ assertEquals(500, Client.MAX_BATCH, "MAX_BATCH");
+ }
+
+ /** Settings out of range are refused at build time. */
+ public void testInvalidParameters() {
+ assertThrows(IllegalArgumentException.class,
+ () -> Client.builder().maxAttempts(0).build(), "maxAttempts");
+ assertThrows(IllegalArgumentException.class,
+ () -> Client.builder().timeout(Duration.ZERO).build(), "timeout");
+ assertThrows(IllegalArgumentException.class,
+ () -> Client.builder().totalTimeout(Duration.ZERO).build(), "totalTimeout");
+ assertThrows(IllegalArgumentException.class,
+ () -> Client.builder().baseUrl("").build(), "baseUrl");
+ }
+
+ /** The default User-Agent identifies the library and its version. */
+ public void testUserAgentIdentifiesTheLibrary() {
+ assertContains(Client.create().userAgent(), "cepis-java/" + Cepis.VERSION,
+ "the default user agent");
+ assertEquals("mine/1.0", Client.builder().userAgent("mine/1.0").build().userAgent(),
+ "an override");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/CsvTest.java b/src/test/java/is/cep/cepis/CsvTest.java
new file mode 100644
index 0000000..61b5e09
--- /dev/null
+++ b/src/test/java/is/cep/cepis/CsvTest.java
@@ -0,0 +1,60 @@
+package is.cep.cepis;
+
+import static is.cep.cepis.Assertions.assertEquals;
+
+import java.util.List;
+import java.util.Map;
+
+/** The hand-written CSV reader. */
+public final class CsvTest {
+
+ /** The header names the columns. */
+ public void testMapsColumnsByHeader() {
+ List<Map<String, String>> rows = Csv.parse("a,b,c\n1,2,3\n");
+ assertEquals(1, rows.size(), "one record");
+ assertEquals("1", rows.get(0).get("a"), "a");
+ assertEquals("3", rows.get(0).get("c"), "c");
+ }
+
+ /** Quoted values may contain commas, quotes and newlines. */
+ public void testQuoting() {
+ List<Map<String, String>> rows = Csv.parse(
+ "a,b\n\"one, two\",\"say \"\"hi\"\"\"\n\"line\nbreak\",x\n");
+ assertEquals("one, two", rows.get(0).get("a"), "an embedded comma");
+ assertEquals("say \"hi\"", rows.get(0).get("b"), "an embedded quote");
+ assertEquals("line\nbreak", rows.get(1).get("a"), "an embedded newline");
+ }
+
+ /** Empty values stay empty, and a trailing empty field is not dropped. */
+ public void testEmptyFields() {
+ List<Map<String, String>> rows = Csv.parse("a,b,c\n1,,\n");
+ assertEquals("", rows.get(0).get("b"), "b");
+ assertEquals("", rows.get(0).get("c"), "the trailing field exists");
+ }
+
+ /** A short record is padded rather than misaligned. */
+ public void testShortRecordIsPadded() {
+ List<Map<String, String>> rows = Csv.parse("a,b,c\n1\n");
+ assertEquals(3, rows.get(0).size(), "three columns");
+ assertEquals("", rows.get(0).get("c"), "the missing one is empty");
+ }
+
+ /** CRLF, LF and a final record without a newline all work. */
+ public void testLineEndings() {
+ assertEquals(2, Csv.parse("a\r\n1\r\n2\r\n").size(), "CRLF");
+ assertEquals(2, Csv.parse("a\n1\n2").size(), "no trailing newline");
+ assertEquals(2, Csv.parse("a\r1\r2\r").size(), "bare CR");
+ }
+
+ /** A byte order mark does not become part of the first column's name. */
+ public void testByteOrderMarkIsDropped() {
+ List<Map<String, String>> rows = Csv.parse("\uFEFFcep,uf\n01001000,SP\n");
+ assertEquals("01001000", rows.get(0).get("cep"), "the header is clean");
+ }
+
+ /** Nothing in, nothing out. */
+ public void testEmptyDocument() {
+ assertEquals(0, Csv.parse("").size(), "empty");
+ assertEquals(0, Csv.parse("a,b,c\n").size(), "header only");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/FakeServer.java b/src/test/java/is/cep/cepis/FakeServer.java
new file mode 100644
index 0000000..da1f1a1
--- /dev/null
+++ b/src/test/java/is/cep/cepis/FakeServer.java
@@ -0,0 +1,120 @@
+package is.cep.cepis;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.Function;
+
+/**
+ * A throwaway HTTP server on a random loopback port, with programmable routes
+ * and a request log.
+ *
+ * <p>The tests point a real {@link Client} at it, so the code path they
+ * exercise is the one that runs in production, request construction and error
+ * body reading included. It doubles as the test that {@code baseUrl} works: if
+ * it did not, none of the suite would run.
+ */
+public final class FakeServer implements AutoCloseable {
+
+ /** One canned response. */
+ public record Reply(int status, String body, Map<String, String> headers) {
+
+ /** A reply with no extra headers. */
+ public static Reply of(int status, String body) {
+ return new Reply(status, body, Map.of());
+ }
+
+ /** A reply carrying one extra header. */
+ public static Reply of(int status, String body, String header, String value) {
+ return new Reply(status, body, Map.of(header, value));
+ }
+ }
+
+ private final HttpServer server;
+ private final ExecutorService executor;
+ private final Map<String, Deque<Reply>> routes = new HashMap<>();
+ private final List<String> calls = new ArrayList<>();
+ private Function<String, Reply> handler;
+
+ /** Start the server. */
+ public FakeServer() {
+ try {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ } catch (IOException cannotBind) {
+ throw new IllegalStateException("could not start the test server", cannotBind);
+ }
+ executor = Executors.newCachedThreadPool();
+ server.setExecutor(executor);
+ server.createContext("/", this::serve);
+ server.start();
+ }
+
+ /** Where the server listens. */
+ public String url() {
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ /**
+ * Register the replies for a path. Each request consumes the next one; the
+ * last repeats once the list runs out, which is how retrying is tested.
+ */
+ public synchronized void respond(String path, Reply... replies) {
+ routes.put(path, new ArrayDeque<>(List.of(replies)));
+ }
+
+ /** Answer every request with this function instead of the routes. */
+ public synchronized void handler(Function<String, Reply> handler) {
+ this.handler = handler;
+ }
+
+ /** How many requests so far began with the prefix. */
+ public synchronized int count(String prefix) {
+ return (int) calls.stream().filter(call -> call.startsWith(prefix)).count();
+ }
+
+ /** Every request path seen so far, in order. */
+ public synchronized List<String> calls() {
+ return List.copyOf(calls);
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ executor.shutdownNow();
+ }
+
+ private synchronized Reply next(String path) {
+ calls.add(path);
+ if (handler != null) {
+ return handler.apply(path);
+ }
+ Deque<Reply> queue = routes.get(path);
+ if (queue == null || queue.isEmpty()) {
+ return Reply.of(404, "{\"erro\":\"cep_nao_encontrado\"}");
+ }
+ return queue.size() > 1 ? queue.pop() : queue.peek();
+ }
+
+ private void serve(HttpExchange exchange) throws IOException {
+ String query = exchange.getRequestURI().getRawQuery();
+ String path = exchange.getRequestURI().getRawPath() + (query == null ? "" : "?" + query);
+ Reply reply = next(path);
+ byte[] body = reply.body().getBytes(StandardCharsets.UTF_8);
+ reply.headers().forEach((name, value) -> exchange.getResponseHeaders().add(name, value));
+ exchange.sendResponseHeaders(reply.status(), body.length == 0 ? -1 : body.length);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.write(body);
+ }
+ }
+}
diff --git a/src/test/java/is/cep/cepis/HealthTest.java b/src/test/java/is/cep/cepis/HealthTest.java
new file mode 100644
index 0000000..7b1691a
--- /dev/null
+++ b/src/test/java/is/cep/cepis/HealthTest.java
@@ -0,0 +1,40 @@
+package is.cep.cepis;
+
+import static is.cep.cepis.Assertions.assertEquals;
+import static is.cep.cepis.Assertions.assertFalse;
+import static is.cep.cepis.Assertions.assertTrue;
+
+import is.cep.cepis.FakeServer.Reply;
+
+/** The health endpoint. */
+public final class HealthTest extends HttpTest {
+
+ /** A healthy API reports which database is live. */
+ public void testReadsHealth() {
+ server.respond("/health", Reply.of(200,
+ "{\"status\":\"ok\",\"dne\":\"26071\",\"ceps\":1596168,\"geo\":894489,"
+ + "\"base_gerada_em\":\"2026-07-24T03:48:28Z\"}"));
+ Health health = client.health();
+ assertTrue(health.ok(), "ok");
+ assertEquals("26071", health.dne(), "dne");
+ assertEquals(1596168L, health.ceps(), "ceps");
+ assertEquals(894489L, health.geo(), "geo");
+ assertEquals("2026-07-24T03:48:28Z", health.baseGeradaEm(), "baseGeradaEm");
+ }
+
+ /** Checking health is when you want a diagnosis, not an exception. */
+ public void testDegradedDoesNotThrow() {
+ server.respond("/health", Reply.of(503,
+ "{\"status\":\"degradado\",\"dne\":\"\",\"ceps\":0,\"geo\":0}"));
+ Health health = client.health();
+ assertFalse(health.ok(), "not ok");
+ assertEquals("degradado", health.status(), "the diagnosis reached the caller");
+ }
+
+ /** A 503 on /health is not retried into a ServerError. */
+ public void testDegradedIsNotRetried() {
+ server.respond("/health", Reply.of(503, "{\"status\":\"degradado\"}"));
+ client.health();
+ assertEquals(1, server.count("/health"), "exactly one request");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/HttpTest.java b/src/test/java/is/cep/cepis/HttpTest.java
new file mode 100644
index 0000000..42038d4
--- /dev/null
+++ b/src/test/java/is/cep/cepis/HttpTest.java
@@ -0,0 +1,59 @@
+package is.cep.cepis;
+
+import java.time.Duration;
+
+/**
+ * Fixture with a fake server and a client whose backoff is near zero.
+ *
+ * <p>The backoff is tiny because what is under test is the retry logic, not
+ * the ability of {@link Thread#sleep(long)} to sleep.
+ */
+public abstract class HttpTest {
+
+ /** The fake API, replaced before each test method. */
+ protected FakeServer server;
+
+ /** A client pointed at {@link #server}. */
+ protected Client client;
+
+ /** The JSON the API returns for 01001000, without a coordinate. */
+ protected static final String SE_ADDRESS = """
+ {"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"}""";
+
+ /** The header row of the batch endpoint. */
+ protected static final String CSV_HEADER =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,"
+ + "ibge,gia,ddd,siafi";
+
+ /** Start the server and build the client. */
+ public void setUp() {
+ server = new FakeServer();
+ client = Client.builder()
+ .baseUrl(server.url())
+ .timeout(Duration.ofSeconds(5))
+ .backoffBase(Duration.ofMillis(1))
+ .backoffMax(Duration.ofMillis(10))
+ .build();
+ }
+
+ /** Stop the server. */
+ public void tearDown() {
+ if (server != null) {
+ server.close();
+ }
+ }
+
+ /** The address JSON with an extra field spliced in. */
+ protected static String addressWith(String field, String json) {
+ return SE_ADDRESS.substring(0, SE_ADDRESS.length() - 1)
+ + ",\"" + field + "\":" + json + "}";
+ }
+
+ /** A CSV document with the standard header and these rows. */
+ protected static String csv(String... rows) {
+ return CSV_HEADER + "\n" + String.join("\n", rows) + "\n";
+ }
+}
diff --git a/src/test/java/is/cep/cepis/JsonTest.java b/src/test/java/is/cep/cepis/JsonTest.java
new file mode 100644
index 0000000..130594f
--- /dev/null
+++ b/src/test/java/is/cep/cepis/JsonTest.java
@@ -0,0 +1,96 @@
+package is.cep.cepis;
+
+import static is.cep.cepis.Assertions.assertEquals;
+import static is.cep.cepis.Assertions.assertThrows;
+import static is.cep.cepis.Assertions.assertTrue;
+
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+/** The hand-written JSON reader. */
+public final class JsonTest {
+
+ private static Map<?, ?> object(String text) {
+ return (Map<?, ?>) Json.parse(text);
+ }
+
+ /** The shapes the API returns. */
+ public void testParsesTheApiShapes() {
+ Map<?, ?> parsed = object(
+ "{\"cep\":\"01001000\",\"geo\":{\"lat\":-23.567202,\"lon\":-46.649659,"
+ + "\"raio_m\":1084,\"precisao\":\"logradouro\"}}");
+ assertEquals("01001000", parsed.get("cep"), "a string field");
+ Map<?, ?> geo = (Map<?, ?>) parsed.get("geo");
+ assertEquals(-23.567202, (Double) geo.get("lat"), 1e-9, "a negative real");
+ assertEquals(1084L, geo.get("raio_m"), "an integer stays integral");
+ }
+
+ /** Escapes, including the ones a naive reader gets wrong. */
+ public void testParsesEscapes() {
+ Map<?, ?> parsed = object(
+ "{\"a\":\"quote \\\" backslash \\\\ slash \\/ tab \\t newline \\n\"}");
+ assertEquals("quote \" backslash \\ slash / tab \t newline \n", parsed.get("a"),
+ "every escape");
+ }
+
+ /** A \\u escape, and a surrogate pair for an astral character. */
+ public void testParsesUnicodeEscapes() {
+ Map<?, ?> parsed = object("{\"a\":\"S\\u00e3o\",\"b\":\"\\ud83d\\ude00\"}");
+ assertEquals("São", parsed.get("a"), "a basic plane escape");
+ assertEquals("\uD83D\uDE00", parsed.get("b"), "a surrogate pair");
+ assertEquals(1, ((String) parsed.get("b")).codePointCount(0, 2), "one code point");
+ }
+
+ /** UTF-8 bytes on the wire become the right characters. */
+ public void testDecodesUtf8() throws Exception {
+ byte[] body = "{\"a\":\"Praça da Sé\"}".getBytes(StandardCharsets.UTF_8);
+ assertEquals("Praça da Sé", object(Json.decodeUtf8(body)).get("a"), "multibyte text");
+ }
+
+ /** Bytes that are not UTF-8 did not come from this API. */
+ public void testRejectsInvalidUtf8() {
+ byte[] body = {'{', '"', 'a', '"', ':', '"', (byte) 0xFF, '"', '}'};
+ try {
+ Json.decodeUtf8(body);
+ Assertions.fail("invalid UTF-8 must not decode");
+ } catch (CharacterCodingException expected) {
+ assertTrue(true, "rejected");
+ }
+ }
+
+ /** Arrays, nesting, booleans and null. */
+ public void testParsesTheRestOfTheGrammar() {
+ Map<?, ?> parsed = object(
+ "{\"a\":[1,2.5,true,false,null,{\"b\":[]}],\"c\":1e3,\"d\":-0.5E-2}");
+ List<?> array = (List<?>) parsed.get("a");
+ assertEquals(6, array.size(), "six elements");
+ assertEquals(1L, array.get(0), "an integer");
+ assertEquals(Boolean.TRUE, array.get(2), "true");
+ assertTrue(array.get(4) == null, "null");
+ assertEquals(1000.0, (Double) parsed.get("c"), 1e-9, "an exponent");
+ assertEquals(-0.005, (Double) parsed.get("d"), 1e-12, "a negative exponent");
+ }
+
+ /** Whitespace between every token. */
+ public void testIgnoresWhitespace() {
+ assertEquals("b", object(" { \"a\" : \"b\" } ").get("a"), "spaced out");
+ }
+
+ /** Malformed documents are errors, not guesses. */
+ public void testRejectsMalformedDocuments() {
+ for (String text : new String[] {
+ "", "{", "{\"a\"}", "{\"a\":}", "{\"a\":1,}", "{'a':1}", "{\"a\":1}trailing",
+ "[1,]", "{\"a\":01}", "{\"a\":\"unterminated", "{\"a\":\\}", "nul",
+ }) {
+ assertThrows(Json.SyntaxException.class, () -> Json.parse(text), text);
+ }
+ }
+
+ /** Nesting deep enough to blow the stack is refused first. */
+ public void testRejectsDeepNesting() {
+ String deep = "[".repeat(200) + "]".repeat(200);
+ assertThrows(Json.SyntaxException.class, () -> Json.parse(deep), "too deep");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/LookupTest.java b/src/test/java/is/cep/cepis/LookupTest.java
new file mode 100644
index 0000000..8934dca
--- /dev/null
+++ b/src/test/java/is/cep/cepis/LookupTest.java
@@ -0,0 +1,127 @@
+package is.cep.cepis;
+
+import static is.cep.cepis.Assertions.assertContains;
+import static is.cep.cepis.Assertions.assertEquals;
+import static is.cep.cepis.Assertions.assertFalse;
+import static is.cep.cepis.Assertions.assertNotNull;
+import static is.cep.cepis.Assertions.assertNull;
+import static is.cep.cepis.Assertions.assertThrows;
+import static is.cep.cepis.Assertions.assertTrue;
+
+import is.cep.cepis.FakeServer.Reply;
+
+/** The single lookup. */
+public final class LookupTest extends HttpTest {
+
+ /** A 200 becomes an address. */
+ public void testSuccessfulLookup() {
+ server.respond("/01001000", Reply.of(200, SE_ADDRESS));
+ Address address = client.lookup("01001-000");
+ assertEquals("São Paulo", address.localidade(), "localidade");
+ assertEquals("Praça da Sé", address.logradouro(), "logradouro");
+ assertEquals("SP", address.uf(), "uf");
+ assertEquals("01001-000", address.formattedCep(), "formattedCep");
+ assertNull(address.geo(), "geo is absent");
+ }
+
+ /** The coordinate is read when the API sends one. */
+ public void testReadsTheCoordinateWhenPresent() {
+ server.respond("/01310100", Reply.of(200, addressWith("geo",
+ "{\"lat\":-23.567202,\"lon\":-46.649659,\"raio_m\":120,"
+ + "\"precisao\":\"logradouro\"}")));
+ Address address = client.lookup("01310100");
+ assertNotNull(address.geo(), "geo is present");
+ assertEquals(-23.567202, address.geo().lat(), 1e-9, "lat");
+ assertEquals(-46.649659, address.geo().lon(), 1e-9, "lon");
+ assertEquals(120, address.geo().raioM(), "raioM");
+ assertTrue(address.geo().isPrecise(), "a street coordinate is precise");
+ }
+
+ /** A municipality centroid is not a precise coordinate. */
+ public void testMunicipalityCoordinateIsNotPrecise() {
+ server.respond("/01001000", Reply.of(200, addressWith("geo",
+ "{\"lat\":-23.5,\"lon\":-46.6,\"raio_m\":22000,"
+ + "\"precisao\":\"localidade\"}")));
+ Address address = client.lookup("01001000");
+ assertNotNull(address.geo(), "geo is present");
+ assertFalse(address.geo().isPrecise(), "a centroid is not precise");
+ }
+
+ /** A malformed geo block costs the coordinate, not the address. */
+ public void testMalformedGeoDoesNotLoseTheAddress() {
+ server.respond("/01001000",
+ Reply.of(200, addressWith("geo", "{\"lat\":\"not a number\"}")));
+ Address address = client.lookup("01001000");
+ assertNull(address.geo(), "geo was dropped");
+ assertEquals("São Paulo", address.localidade(), "the address survived");
+ }
+
+ /** A field the API grows is preserved rather than fatal. */
+ public void testNewApiFieldDoesNotBreak() {
+ server.respond("/01001000",
+ Reply.of(200, addressWith("campo_do_futuro", "\"value\"")));
+ Address address = client.lookup("01001000");
+ assertEquals("value", address.raw().get("campo_do_futuro"), "raw keeps it");
+ assertEquals("São Paulo", address.localidade(), "the address still parses");
+ }
+
+ /** raw() is unmodifiable. */
+ public void testRawIsUnmodifiable() {
+ server.respond("/01001000", Reply.of(200, SE_ADDRESS));
+ Address address = client.lookup("01001000");
+ assertThrows(UnsupportedOperationException.class,
+ () -> address.raw().put("x", "y"), "raw is read-only");
+ }
+
+ /** A 404 is a missing CEP. */
+ public void testNotFound() {
+ server.respond("/99999999", Reply.of(404,
+ "{\"erro\":\"cep_nao_encontrado\",\"mensagem\":\"CEP não existe\"}"));
+ CepNotFoundException failure = assertThrows(CepNotFoundException.class,
+ () -> client.lookup("99999999"), "a missing CEP");
+ assertContains(failure.getMessage(), "99999999", "the message names the CEP");
+ }
+
+ /** A 404 is definitive; retrying it only burns someone's quota. */
+ public void testNotFoundIsNotRetried() {
+ server.respond("/99999999", Reply.of(404, "{\"erro\":\"cep_nao_encontrado\"}"));
+ assertThrows(CepNotFoundException.class, () -> client.lookup("99999999"), "missing");
+ assertEquals(1, server.count("/99999999"), "exactly one request");
+ }
+
+ /** A malformed CEP never leaves the machine. */
+ public void testInvalidCepNeverLeavesTheMachine() {
+ assertThrows(InvalidCepException.class, () -> client.lookup("123"), "too short");
+ assertEquals(0, server.calls().size(), "no request was made");
+ }
+
+ /** A 400 carries the API's own message. */
+ public void testApi400BecomesInvalidCep() {
+ server.respond("/00000000", Reply.of(400,
+ "{\"erro\":\"cep_invalido\",\"mensagem\":\"rejected by the API\"}"));
+ InvalidCepException failure = assertThrows(InvalidCepException.class,
+ () -> client.lookup("00000000"), "a rejected CEP");
+ assertContains(failure.getMessage(), "rejected by the API", "the API's message");
+ }
+
+ /** A 400 is definitive too. */
+ public void testInvalidIsNotRetried() {
+ server.respond("/00000000", Reply.of(400, "{\"erro\":\"cep_invalido\"}"));
+ assertThrows(InvalidCepException.class, () -> client.lookup("00000000"), "rejected");
+ assertEquals(1, server.count("/00000000"), "exactly one request");
+ }
+
+ /** A captive portal answering instead of the API is diagnosed, not parsed. */
+ public void testResponseThatIsNotJson() {
+ server.respond("/01001000", Reply.of(200, "<html>captive portal</html>"));
+ assertThrows(InvalidResponseException.class,
+ () -> client.lookup("01001000"), "an unreadable body");
+ }
+
+ /** A JSON array where an object was promised is not an address. */
+ public void testResponseThatIsNotAnObject() {
+ server.respond("/01001000", Reply.of(200, "[]"));
+ assertThrows(InvalidResponseException.class,
+ () -> client.lookup("01001000"), "an array is not an address");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/ModelTest.java b/src/test/java/is/cep/cepis/ModelTest.java
new file mode 100644
index 0000000..26deb5a
--- /dev/null
+++ b/src/test/java/is/cep/cepis/ModelTest.java
@@ -0,0 +1,78 @@
+package is.cep.cepis;
+
+import static is.cep.cepis.Assertions.assertEquals;
+import static is.cep.cepis.Assertions.assertFalse;
+import static is.cep.cepis.Assertions.assertNull;
+import static is.cep.cepis.Assertions.assertTrue;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** The records, away from any HTTP. */
+public final class ModelTest {
+
+ private static Address address(Geo geo, Map<String, Object> raw) {
+ return new Address("01001000", "Praça da Sé", "", "", "Sé", "São Paulo", "SP",
+ "São Paulo", "Sudeste", "3550308", "1004", "11", "7107", geo, raw);
+ }
+
+ /** Formatting is presentation, and lives on the model. */
+ public void testFormattedCep() {
+ assertEquals("01001-000", address(null, Map.of()).formattedCep(), "masked");
+ }
+
+ /** The raw response takes no part in equality. */
+ public void testRawIsNotPartOfEquality() {
+ Address one = address(null, Map.of("campo_do_futuro", "a"));
+ Address two = address(null, Map.of());
+ assertEquals(one, two, "the same place is the same address");
+ assertEquals(one.hashCode(), two.hashCode(), "and hashes alike");
+ assertFalse(one.toString().contains("campo_do_futuro"), "toString stays readable");
+ }
+
+ /** The coordinate does take part in equality. */
+ public void testGeoIsPartOfEquality() {
+ Address one = address(new Geo(-23.5, -46.6, 120, "logradouro"), Map.of());
+ Address two = address(null, Map.of());
+ assertFalse(one.equals(two), "a coordinate is part of the address");
+ }
+
+ /** A JSON null in the raw response does not break the copy. */
+ public void testRawToleratesNullValues() {
+ Map<String, Object> raw = new LinkedHashMap<>();
+ raw.put("cep", "01001000");
+ raw.put("novo", null);
+ Address parsed = Address.fromJson(raw);
+ assertTrue(parsed.raw().containsKey("novo"), "the null field is kept");
+ assertNull(parsed.raw().get("novo"), "as null");
+ }
+
+ /** A field the API lacks is an empty string, never null. */
+ public void testMissingFieldsAreEmptyStrings() {
+ Address parsed = Address.fromJson(Map.of("cep", "01001000"));
+ assertEquals("", parsed.logradouro(), "logradouro");
+ assertEquals("", parsed.gia(), "gia");
+ assertNull(parsed.geo(), "geo");
+ }
+
+ /** A geo block missing precisao is not a coordinate. */
+ public void testGeoNeedsEveryField() {
+ Address parsed = Address.fromJson(Map.of("cep", "01001000",
+ "geo", Map.of("lat", -23.5, "lon", -46.6, "raio_m", 120)));
+ assertNull(parsed.geo(), "an incomplete block is dropped");
+ }
+
+ /** The precision check is the one that matters before measuring distance. */
+ public void testIsPrecise() {
+ assertTrue(new Geo(-23.5, -46.6, 120, "logradouro").isPrecise(), "a street");
+ assertFalse(new Geo(-23.5, -46.6, 22000, "localidade").isPrecise(), "a municipality");
+ }
+
+ /** Health tolerates a partial body rather than failing the check. */
+ public void testHealthDefaults() {
+ Health health = Health.fromJson(Map.of("status", "ok"));
+ assertTrue(health.ok(), "ok");
+ assertEquals(0L, health.ceps(), "ceps");
+ assertEquals("", health.baseGeradaEm(), "baseGeradaEm");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/NormalizeTest.java b/src/test/java/is/cep/cepis/NormalizeTest.java
new file mode 100644
index 0000000..b5dd729
--- /dev/null
+++ b/src/test/java/is/cep/cepis/NormalizeTest.java
@@ -0,0 +1,38 @@
+package is.cep.cepis;
+
+import static is.cep.cepis.Assertions.assertEquals;
+import static is.cep.cepis.Assertions.assertThrows;
+
+/** Local validation, which runs before any request. */
+public final class NormalizeTest {
+
+ /** The mask is optional and stripped. */
+ public void testAcceptsMaskedAndUnmasked() {
+ for (String value : new String[] {"01001000", "01001-000", " 01001-000 ", "01001.000"}) {
+ assertEquals("01001000", Client.normalizeCep(value), value);
+ }
+ }
+
+ /** Anything that is not eight digits is the caller's bug. */
+ public void testRejectsBadFormat() {
+ for (String value : new String[] {"", "123", "0100100a", "010010000", "abcdefgh"}) {
+ assertThrows(InvalidCepException.class, () -> Client.normalizeCep(value), value);
+ }
+ }
+
+ /** Null is rejected the same way, not with a NullPointerException. */
+ public void testRejectsNull() {
+ assertThrows(InvalidCepException.class, () -> Client.normalizeCep(null), "null");
+ }
+
+ /** Non-ASCII digits are not digits. */
+ public void testRejectsNonAsciiDigits() {
+ assertThrows(InvalidCepException.class,
+ () -> Client.normalizeCep("٠١٠٠١٠٠٠"), "arabic-indic digits");
+ }
+
+ /** Everything is catchable as one type. */
+ public void testIsACepisException() {
+ assertThrows(CepisException.class, () -> Cepis.normalizeCep("123"), "one base type");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/RetryTest.java b/src/test/java/is/cep/cepis/RetryTest.java
new file mode 100644
index 0000000..702a1ff
--- /dev/null
+++ b/src/test/java/is/cep/cepis/RetryTest.java
@@ -0,0 +1,105 @@
+package is.cep.cepis;
+
+import static is.cep.cepis.Assertions.assertEquals;
+import static is.cep.cepis.Assertions.assertThrows;
+import static is.cep.cepis.Assertions.assertTrue;
+
+import is.cep.cepis.FakeServer.Reply;
+import java.time.Duration;
+
+/** The retry policy: what is retried, how long it waits, and what it never retries. */
+public final class RetryTest extends HttpTest {
+
+ /** A transient 500 is retried and the second attempt is used. */
+ public void testRetriesOn500ThenSucceeds() {
+ server.respond("/01001000",
+ Reply.of(500, "{\"erro\":\"interno\"}"),
+ Reply.of(200, SE_ADDRESS));
+ Address address = client.lookup("01001000");
+ assertEquals("São Paulo", address.localidade(), "the retry succeeded");
+ assertEquals(2, server.count("/01001000"), "two requests");
+ }
+
+ /** Three attempts is the documented default. */
+ public void testExhaustsAttemptsThenThrows() {
+ server.respond("/01001000", Reply.of(503, "{\"erro\":\"interno\"}"));
+ ServerErrorException failure = assertThrows(ServerErrorException.class,
+ () -> client.lookup("01001000"), "the API is down");
+ assertEquals(503, failure.status(), "the status is exposed");
+ assertEquals(3, server.count("/01001000"), "three attempts");
+ }
+
+ /** maxAttempts of one disables retrying. */
+ public void testMaxAttemptsOneDisablesRetry() {
+ Client single = Client.builder()
+ .baseUrl(server.url())
+ .maxAttempts(1)
+ .timeout(Duration.ofSeconds(5))
+ .build();
+ server.respond("/01001000", Reply.of(500, "{}"));
+ assertThrows(ServerErrorException.class, () -> single.lookup("01001000"), "500");
+ assertEquals(1, server.count("/01001000"), "exactly one attempt");
+ }
+
+ /** A 429 is a rate limit, and Retry-After reaches the caller. */
+ public void test429BecomesRateLimitedWithRetryAfter() {
+ server.respond("/01001000", Reply.of(429, "{}", "Retry-After", "7"));
+ RateLimitedException failure = assertThrows(RateLimitedException.class,
+ () -> client.lookup("01001000"), "rate limited");
+ assertEquals(Duration.ofSeconds(7), failure.retryAfter().orElse(null), "retryAfter");
+ }
+
+ /** A 429 with no Retry-After leaves the caller nothing to reschedule with. */
+ public void test429WithoutRetryAfter() {
+ server.respond("/01001000", Reply.of(429, "{}"));
+ RateLimitedException failure = assertThrows(RateLimitedException.class,
+ () -> client.lookup("01001000"), "rate limited");
+ assertTrue(failure.retryAfter().isEmpty(), "the server did not say");
+ }
+
+ /** An absurd Retry-After from a bad proxy must not park the thread. */
+ public void testRetryAfterHonoursTheCap() {
+ server.respond("/01001000", Reply.of(429, "{}", "Retry-After", "3600"));
+ long started = System.nanoTime();
+ assertThrows(RateLimitedException.class, () -> client.lookup("01001000"), "429");
+ long elapsed = (System.nanoTime() - started) / 1_000_000L;
+ assertTrue(elapsed < 5_000, "capped at backoffMax, took " + elapsed + "ms");
+ }
+
+ /** Retry-After also arrives as an HTTP date. */
+ public void testRetryAfterAcceptsAnHttpDate() {
+ Duration parsed = Client.parseRetryAfter("Wed, 21 Oct 2026 07:28:00 GMT");
+ assertTrue(parsed != null, "an HTTP date is understood");
+ assertTrue(parsed.toSeconds() >= 0, "never negative");
+ assertEquals(null, Client.parseRetryAfter("not a date at all"), "nonsense is ignored");
+ assertEquals(Duration.ofSeconds(120), Client.parseRetryAfter(" 120 "), "seconds");
+ }
+
+ /** Nothing listens on port 1, so the connection is refused outright. */
+ public void testNetworkErrorWhenNothingListens() {
+ Client unreachable = Client.builder()
+ .baseUrl("http://127.0.0.1:1")
+ .maxAttempts(2)
+ .backoffBase(Duration.ofMillis(1))
+ .timeout(Duration.ofSeconds(1))
+ .build();
+ assertThrows(NetworkException.class, () -> unreachable.lookup("01001000"), "refused");
+ }
+
+ /** The deadline cuts the run short of the fifty configured attempts. */
+ public void testTotalTimeoutBoundsTheWholeCall() {
+ Client bounded = Client.builder()
+ .baseUrl(server.url())
+ .maxAttempts(50)
+ .backoffBase(Duration.ofMillis(50))
+ .backoffMax(Duration.ofMillis(50))
+ .totalTimeout(Duration.ofMillis(300))
+ .build();
+ server.respond("/01001000", Reply.of(500, "{}"));
+ long started = System.nanoTime();
+ assertThrows(ServerErrorException.class, () -> bounded.lookup("01001000"), "500");
+ long elapsed = (System.nanoTime() - started) / 1_000_000L;
+ assertTrue(elapsed < 3_000, "the deadline held, took " + elapsed + "ms");
+ assertTrue(server.count("/01001000") < 50, "it stopped short of 50 attempts");
+ }
+}
diff --git a/src/test/java/is/cep/cepis/TestRunner.java b/src/test/java/is/cep/cepis/TestRunner.java
new file mode 100644
index 0000000..a32ec57
--- /dev/null
+++ b/src/test/java/is/cep/cepis/TestRunner.java
@@ -0,0 +1,108 @@
+package is.cep.cepis;
+
+import java.io.FileDescriptor;
+import java.io.FileOutputStream;
+import java.io.PrintStream;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Runs the suite.
+ *
+ * <p>Every public no-argument method whose name starts with {@code test} is a
+ * test; {@code setUp} and {@code tearDown} run around each one, on a fresh
+ * instance. That is JUnit's model, minus JUnit — the library takes no
+ * dependencies, and the build is plain {@code javac}, so the suite is not the
+ * place to introduce a jar to download.
+ */
+public final class TestRunner {
+
+ private static final PrintStream OUT =
+ new PrintStream(new FileOutputStream(FileDescriptor.out), true,
+ StandardCharsets.UTF_8);
+
+ private static final List<Class<?>> SUITES = List.of(
+ NormalizeTest.class,
+ JsonTest.class,
+ CsvTest.class,
+ ModelTest.class,
+ ConfigurationTest.class,
+ LookupTest.class,
+ RetryTest.class,
+ BatchTest.class,
+ HealthTest.class);
+
+ private TestRunner() {
+ }
+
+ /**
+ * Run every suite and exit non-zero if anything failed.
+ *
+ * @param args ignored
+ * @throws ReflectiveOperationException if a suite cannot be instantiated
+ */
+ public static void main(String[] args) throws ReflectiveOperationException {
+ int passed = 0;
+ List<String> failures = new ArrayList<>();
+ long started = System.nanoTime();
+
+ for (Class<?> suite : SUITES) {
+ OUT.println(suite.getSimpleName());
+ for (Method test : testsOf(suite)) {
+ Object instance = suite.getDeclaredConstructor().newInstance();
+ String name = suite.getSimpleName() + "." + test.getName();
+ try {
+ invoke(instance, "setUp");
+ test.invoke(instance);
+ passed++;
+ OUT.println(" ok " + test.getName());
+ } catch (InvocationTargetException failure) {
+ failures.add(name);
+ OUT.println(" FAIL " + test.getName());
+ failure.getCause().printStackTrace(OUT);
+ } catch (RuntimeException | IllegalAccessException failure) {
+ failures.add(name);
+ OUT.println(" ERROR " + test.getName());
+ failure.printStackTrace(OUT);
+ } finally {
+ invoke(instance, "tearDown");
+ }
+ }
+ }
+
+ long millis = (System.nanoTime() - started) / 1_000_000L;
+ OUT.println();
+ OUT.println(passed + " passed, " + failures.size() + " failed, in " + millis + "ms");
+ for (String failure : failures) {
+ OUT.println(" FAILED " + failure);
+ }
+ System.exit(failures.isEmpty() ? 0 : 1);
+ }
+
+ private static List<Method> testsOf(Class<?> suite) {
+ return Arrays.stream(suite.getDeclaredMethods())
+ .filter(method -> method.getName().startsWith("test"))
+ .filter(method -> method.getParameterCount() == 0)
+ .sorted(Comparator.comparing(Method::getName))
+ .toList();
+ }
+
+ private static void invoke(Object instance, String name) {
+ Method method;
+ try {
+ method = instance.getClass().getMethod(name);
+ } catch (NoSuchMethodException absent) {
+ return;
+ }
+ try {
+ method.invoke(instance);
+ } catch (ReflectiveOperationException failure) {
+ throw new IllegalStateException(name + " failed", failure);
+ }
+ }
+}