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