summaryrefslogtreecommitdiffstats
path: root/src/main
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/main
downloadjava-17e590eb67054a2cab9a32afc3fd032b18fc8964.tar.gz
Initial importv0.1.0
Diffstat (limited to 'src/main')
-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
15 files changed, 1841 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;
+ }
+}