diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:20:42 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:20:42 -0300 |
| commit | 329f679cd359bcab92adf3366cf17711836d0c79 (patch) | |
| tree | 68f956deb2e0d5c3a25da20260ee3c0678a64cd6 /src/main | |
| download | java-0.1.0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src/main')
| -rw-r--r-- | src/main/java/is/cep/cepis/Address.java | 184 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/CepNotFoundException.java | 20 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Cepis.java | 109 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/CepisException.java | 49 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Client.java | 613 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Csv.java | 102 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Geo.java | 69 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Health.java | 38 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/InvalidCepException.java | 25 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/InvalidResponseException.java | 32 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Json.java | 386 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Main.java | 122 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/NetworkException.java | 22 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/RateLimitedException.java | 39 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/ServerErrorException.java | 31 |
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 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 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 km to 40 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; + } +} |