summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/java/is/cep/cepis/CepNotFoundException.java42
-rw-r--r--src/main/java/is/cep/cepis/Cepis.java2
-rw-r--r--src/main/java/is/cep/cepis/Client.java43
-rw-r--r--src/main/java/is/cep/cepis/Faixa.java87
-rw-r--r--src/main/java/is/cep/cepis/Json.java4
-rw-r--r--src/main/java/is/cep/cepis/Main.java2
-rw-r--r--src/test/java/is/cep/cepis/HttpTest.java10
-rw-r--r--src/test/java/is/cep/cepis/LookupTest.java73
-rw-r--r--src/test/java/is/cep/cepis/ModelTest.java27
9 files changed, 282 insertions, 8 deletions
diff --git a/src/main/java/is/cep/cepis/CepNotFoundException.java b/src/main/java/is/cep/cepis/CepNotFoundException.java
index d6865d7..e641b5d 100644
--- a/src/main/java/is/cep/cepis/CepNotFoundException.java
+++ b/src/main/java/is/cep/cepis/CepNotFoundException.java
@@ -1,20 +1,60 @@
package is.cep.cepis;
+import java.util.Optional;
+
/**
* 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.
+ *
+ * <p>{@link #faixa()} may name the municipality the CEP would have belonged
+ * to. The API sends it because a CEP that fails is rarely random: it is
+ * usually someone who knows the city and guessed the number — the classic
+ * guess being the {@code -000}, which 957 municipalities never received.
+ *
+ * <p>It is <b>context for the error, not an address</b>. This is still a
+ * not-found; handing the caller the municipality as though it were the result
+ * turns a clean failure into a wrong answer. It is empty whenever the CEP fell
+ * in a band no municipality owns, and the field is simply absent from the
+ * response then, never null.
*/
public class CepNotFoundException extends CepisException {
private static final long serialVersionUID = 1L;
+ private final transient Faixa faixa;
+
/**
- * Create the exception.
+ * Create the exception, with no municipality behind the CEP.
*
* @param message what went wrong
*/
public CepNotFoundException(String message) {
+ this(message, null);
+ }
+
+ /**
+ * Create the exception, naming the municipality that owns the band.
+ *
+ * @param message what went wrong
+ * @param faixa the municipality the CEP would have belonged to, or
+ * {@code null} when no municipality owns that band
+ */
+ public CepNotFoundException(String message, Faixa faixa) {
super(message);
+ this.faixa = faixa;
+ }
+
+ /**
+ * The municipality the CEP would have belonged to.
+ *
+ * <p>Read {@link Faixa} before showing any of it: the interval it carries
+ * is sparse, and its start is not the start the Correios publish.
+ *
+ * @return the band's municipality, empty when the CEP fell outside every
+ * municipality's band
+ */
+ public Optional<Faixa> faixa() {
+ return Optional.ofNullable(faixa);
}
}
diff --git a/src/main/java/is/cep/cepis/Cepis.java b/src/main/java/is/cep/cepis/Cepis.java
index fb8b742..8e6c980 100644
--- a/src/main/java/is/cep/cepis/Cepis.java
+++ b/src/main/java/is/cep/cepis/Cepis.java
@@ -38,7 +38,7 @@ import java.util.List;
public final class Cepis {
/** The version of this library. */
- public static final String VERSION = "0.1.0";
+ public static final String VERSION = "0.2.0";
private Cepis() {
}
diff --git a/src/main/java/is/cep/cepis/Client.java b/src/main/java/is/cep/cepis/Client.java
index afc5705..77dba71 100644
--- a/src/main/java/is/cep/cepis/Client.java
+++ b/src/main/java/is/cep/cepis/Client.java
@@ -135,11 +135,17 @@ public final class Client {
* <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}.
*
+ * <p>A CEP that does not exist throws, as it always did. What is new is
+ * that the exception may name the municipality whose band the CEP fell in,
+ * in {@link CepNotFoundException#faixa()} — context for the failure, never
+ * a substitute for the address.
+ *
* @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 CepNotFoundException if it is well-formed but not in the
+ * database, possibly carrying the band's municipality
* @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
@@ -150,8 +156,8 @@ public final class Client {
Response response = request("/" + number, OK_ONLY);
if (response.status() == 404) {
- throw new CepNotFoundException(
- "CEP " + number + " does not exist in the DNE database");
+ Faixa faixa = faixaOf(response.body());
+ throw new CepNotFoundException(notFoundMessage(number, faixa), faixa);
}
if (response.status() == 400) {
throw new InvalidCepException(
@@ -433,6 +439,37 @@ public final class Client {
return object;
}
+ /**
+ * Read the {@code faixa} block a 404 may carry.
+ *
+ * <p>Everything here is best effort. A 404 with no {@code faixa}, with a
+ * {@code faixa} that is not an object, or with a body that is not JSON at
+ * all is still a plain not-found: this returns {@code null} and never
+ * throws, because turning a definitive answer about the request into a
+ * parse failure would hide the real one.
+ */
+ private static Faixa faixaOf(byte[] body) {
+ try {
+ Object data = Json.parse(Json.decodeUtf8(body));
+ if (data instanceof Map<?, ?> object
+ && object.get("faixa") instanceof Map<?, ?> block) {
+ return Faixa.fromJson(block);
+ }
+ } catch (CharacterCodingException | RuntimeException unusable) {
+ return null;
+ }
+ return null;
+ }
+
+ private static String notFoundMessage(String number, Faixa faixa) {
+ String missing = "CEP " + number + " does not exist in the DNE database";
+ if (faixa == null) {
+ return missing;
+ }
+ return missing + " — that band belongs to " + faixa.describe() + ", which has "
+ + faixa.ceps() + " CEP(s) between " + faixa.cepIni() + " and " + faixa.cepFim();
+ }
+
private static String errorMessage(byte[] body, String fallback) {
try {
Object data = Json.parse(Json.decodeUtf8(body));
diff --git a/src/main/java/is/cep/cepis/Faixa.java b/src/main/java/is/cep/cepis/Faixa.java
new file mode 100644
index 0000000..6f92f6e
--- /dev/null
+++ b/src/main/java/is/cep/cepis/Faixa.java
@@ -0,0 +1,87 @@
+package is.cep.cepis;
+
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * The municipality a CEP that does not exist would have belonged to.
+ *
+ * <p>It comes attached to {@link CepNotFoundException#faixa()}, and it is
+ * <b>context for an error, never an address</b>. The CEP asked for still does
+ * not exist; this only says which municipality owns the band it fell into.
+ * Treating it as a result turns a not-found into a wrong answer.
+ *
+ * <p>It is worth having because a CEP that fails is rarely random: it is
+ * usually someone who knows the city and guessed the number. The classic guess
+ * is the {@code -000}, which 957 municipalities never received.
+ *
+ * <p>Two things about {@link #cepIni()} and {@link #cepFim()}:
+ *
+ * <ul>
+ * <li>They are the smallest and the largest CEP that <b>actually exist</b>
+ * there, not the bounds the Correios publish. The DNE's own start for
+ * Areal is {@code 25845000} — precisely the CEP that just failed.</li>
+ * <li>The interval is <b>sparse</b>. Areal's 118 CEPs are spread over some
+ * 9000 numbers, so the overwhelming majority of what lies between the two
+ * does not exist. That is what {@link #ceps()} is for. Never present the
+ * pair as a list, or as a range, of valid CEPs.</li>
+ * </ul>
+ *
+ * @param localidade the municipality that owns the band
+ * @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 cepIni the smallest CEP that exists there, the API's {@code cep_ini}
+ * @param cepFim the largest CEP that exists there, the API's {@code cep_fim}
+ * @param ceps how many CEPs exist between the two, the rest of the interval
+ * being empty
+ */
+public record Faixa(
+ String localidade,
+ String uf,
+ String estado,
+ String regiao,
+ String ibge,
+ String cepIni,
+ String cepFim,
+ long ceps) {
+
+ /**
+ * Canonical constructor.
+ *
+ * @throws NullPointerException if {@code localidade} is {@code null}
+ */
+ public Faixa {
+ Objects.requireNonNull(localidade, "localidade");
+ }
+
+ /**
+ * How the municipality is usually written, {@code "Areal/RJ"}.
+ *
+ * <p>Presentation, and it lives here so it does not become a repeated
+ * concatenation in every message that reports a CEP nobody has.
+ *
+ * @return the municipality and its state, or {@link #localidade()} alone
+ * when there is no state code
+ */
+ public String describe() {
+ return uf == null || uf.isEmpty() ? localidade : localidade + "/" + uf;
+ }
+
+ static Faixa fromJson(Map<?, ?> block) {
+ String localidade = Json.string(block, "localidade");
+ if (localidade.isBlank()) {
+ throw new IllegalArgumentException("the faixa block has no localidade");
+ }
+ return new Faixa(
+ localidade,
+ Json.string(block, "uf"),
+ Json.string(block, "estado"),
+ Json.string(block, "regiao"),
+ Json.string(block, "ibge"),
+ Json.string(block, "cep_ini"),
+ Json.string(block, "cep_fim"),
+ Json.count(block, "ceps"));
+ }
+}
diff --git a/src/main/java/is/cep/cepis/Json.java b/src/main/java/is/cep/cepis/Json.java
index 9a5f17b..eac5beb 100644
--- a/src/main/java/is/cep/cepis/Json.java
+++ b/src/main/java/is/cep/cepis/Json.java
@@ -315,7 +315,7 @@ final class Json {
* @param key the field name
* @return the field's value, or {@code ""}
*/
- static String string(Map<String, ?> data, String key) {
+ static String string(Map<?, ?> data, String key) {
Object value = data.get(key);
if (value instanceof String s) {
return s;
@@ -333,7 +333,7 @@ final class Json {
* @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) {
+ static long count(Map<?, ?> data, String key) {
Object value = data.get(key);
if (value instanceof Number n) {
return n.longValue();
diff --git a/src/main/java/is/cep/cepis/Main.java b/src/main/java/is/cep/cepis/Main.java
index edf2d91..6523b1e 100644
--- a/src/main/java/is/cep/cepis/Main.java
+++ b/src/main/java/is/cep/cepis/Main.java
@@ -13,7 +13,7 @@ import java.util.List;
* instance answers before pointing code at it.
*
* <pre>{@code
- * java -jar cepis-0.1.0.jar --base-url http://localhost:8080 01001000
+ * java -jar cepis-0.2.0.jar --base-url http://localhost:8080 01001000
* }</pre>
*
* <p>A single CEP goes through the unit lookup, which carries the coordinate;
diff --git a/src/test/java/is/cep/cepis/HttpTest.java b/src/test/java/is/cep/cepis/HttpTest.java
index 42038d4..a6594a2 100644
--- a/src/test/java/is/cep/cepis/HttpTest.java
+++ b/src/test/java/is/cep/cepis/HttpTest.java
@@ -23,6 +23,16 @@ public abstract class HttpTest {
"estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004",\
"ddd":"11","siafi":"7107"}""";
+ /**
+ * The 404 the API returns for 25845000, a CEP that does not exist in a
+ * band that Areal/RJ does own.
+ */
+ protected static final String NOT_FOUND_WITH_FAIXA = """
+ {"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE",\
+ "faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro",\
+ "regiao":"Sudeste","ibge":"3300225","cep_ini":"25845001",\
+ "cep_fim":"25849899","ceps":118}}""";
+
/** The header row of the batch endpoint. */
protected static final String CSV_HEADER =
"cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,"
diff --git a/src/test/java/is/cep/cepis/LookupTest.java b/src/test/java/is/cep/cepis/LookupTest.java
index 8934dca..46253e1 100644
--- a/src/test/java/is/cep/cepis/LookupTest.java
+++ b/src/test/java/is/cep/cepis/LookupTest.java
@@ -80,6 +80,79 @@ public final class LookupTest extends HttpTest {
CepNotFoundException failure = assertThrows(CepNotFoundException.class,
() -> client.lookup("99999999"), "a missing CEP");
assertContains(failure.getMessage(), "99999999", "the message names the CEP");
+ assertTrue(failure.faixa().isEmpty(), "no band was sent");
+ }
+
+ /** A 404 may name the municipality whose band the CEP fell in. */
+ public void testNotFoundCarriesTheBand() {
+ server.respond("/25845000", Reply.of(404, NOT_FOUND_WITH_FAIXA));
+ CepNotFoundException failure = assertThrows(CepNotFoundException.class,
+ () -> client.lookup("25845-000"), "a missing CEP in a known band");
+ Faixa faixa = failure.faixa().orElseThrow();
+ assertEquals("Areal", faixa.localidade(), "localidade");
+ assertEquals("RJ", faixa.uf(), "uf");
+ assertEquals("Rio de Janeiro", faixa.estado(), "estado");
+ assertEquals("Sudeste", faixa.regiao(), "regiao");
+ assertEquals("3300225", faixa.ibge(), "ibge");
+ assertEquals("25845001", faixa.cepIni(), "cep_ini is the first that exists");
+ assertEquals("25849899", faixa.cepFim(), "cep_fim is the last that exists");
+ assertEquals(118L, faixa.ceps(), "how many exist in between");
+ assertEquals("Areal/RJ", faixa.describe(), "describe");
+ }
+
+ /** The band is context for the failure, not a result. */
+ public void testTheBandDoesNotBecomeAnAddress() {
+ server.respond("/25845000", Reply.of(404, NOT_FOUND_WITH_FAIXA));
+ CepNotFoundException failure = assertThrows(CepNotFoundException.class,
+ () -> client.lookup("25845000"), "still a not-found");
+ assertContains(failure.getMessage(), "25845000", "the message names the CEP");
+ assertContains(failure.getMessage(), "Areal", "and the municipality");
+ }
+
+ /** A 404 without the band is the plain not-found it always was. */
+ public void testNotFoundWithoutTheBand() {
+ server.respond("/99999999", Reply.of(404,
+ "{\"erro\":\"cep_nao_encontrado\",\"mensagem\":\"CEP não existe\"}"));
+ CepNotFoundException failure = assertThrows(CepNotFoundException.class,
+ () -> client.lookup("99999999"), "a CEP no municipality owns");
+ assertTrue(failure.faixa().isEmpty(), "the field is absent, not null");
+ assertFalse(failure.getMessage().contains("band belongs to"),
+ "and the message says nothing about a municipality");
+ }
+
+ /** An explicit null band is absence too, not a parse failure. */
+ public void testNullBandIsAbsence() {
+ server.respond("/99999999",
+ Reply.of(404, "{\"erro\":\"cep_nao_encontrado\",\"faixa\":null}"));
+ CepNotFoundException failure = assertThrows(CepNotFoundException.class,
+ () -> client.lookup("99999999"), "a missing CEP");
+ assertTrue(failure.faixa().isEmpty(), "null is no band");
+ }
+
+ /** A malformed band costs the band, never the not-found. */
+ public void testMalformedBandStillNotFound() {
+ server.respond("/99999999", Reply.of(404,
+ "{\"erro\":\"cep_nao_encontrado\",\"faixa\":{\"uf\":\"RJ\",\"ceps\":\"many\"}}"));
+ CepNotFoundException failure = assertThrows(CepNotFoundException.class,
+ () -> client.lookup("99999999"), "a missing CEP");
+ assertTrue(failure.faixa().isEmpty(), "a band with no localidade is dropped");
+ }
+
+ /** A band that is not even an object is dropped just as quietly. */
+ public void testBandOfTheWrongShapeStillNotFound() {
+ server.respond("/99999999",
+ Reply.of(404, "{\"erro\":\"cep_nao_encontrado\",\"faixa\":\"Areal\"}"));
+ CepNotFoundException failure = assertThrows(CepNotFoundException.class,
+ () -> client.lookup("99999999"), "a missing CEP");
+ assertTrue(failure.faixa().isEmpty(), "a string is no band");
+ }
+
+ /** A 404 whose body is not JSON at all is still a clean not-found. */
+ public void testNotFoundWithAnUnreadableBody() {
+ server.respond("/99999999", Reply.of(404, "<html>gateway</html>"));
+ CepNotFoundException failure = assertThrows(CepNotFoundException.class,
+ () -> client.lookup("99999999"), "the status decides, not the body");
+ assertTrue(failure.faixa().isEmpty(), "nothing to read");
}
/** A 404 is definitive; retrying it only burns someone's quota. */
diff --git a/src/test/java/is/cep/cepis/ModelTest.java b/src/test/java/is/cep/cepis/ModelTest.java
index 26deb5a..c8a3269 100644
--- a/src/test/java/is/cep/cepis/ModelTest.java
+++ b/src/test/java/is/cep/cepis/ModelTest.java
@@ -3,6 +3,7 @@ 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.assertThrows;
import static is.cep.cepis.Assertions.assertTrue;
import java.util.LinkedHashMap;
@@ -68,6 +69,32 @@ public final class ModelTest {
assertFalse(new Geo(-23.5, -46.6, 22000, "localidade").isPrecise(), "a municipality");
}
+ /** A band block without a municipality is not a band. */
+ public void testFaixaNeedsALocalidade() {
+ assertThrows(IllegalArgumentException.class,
+ () -> Faixa.fromJson(Map.of("uf", "RJ", "ceps", 118)),
+ "the municipality is the whole point of the block");
+ }
+
+ /** A partial band keeps what it has instead of being dropped. */
+ public void testFaixaToleratesAPartialBlock() {
+ Faixa faixa = Faixa.fromJson(Map.of("localidade", "Areal"));
+ assertEquals("", faixa.uf(), "uf");
+ assertEquals("", faixa.cepIni(), "cepIni");
+ assertEquals(0L, faixa.ceps(), "ceps");
+ assertEquals("Areal", faixa.describe(), "describe without a state");
+ }
+
+ /** The bounds are strings, so a CEP starting in zero survives. */
+ public void testFaixaBoundsKeepTheirLeadingZeros() {
+ Faixa faixa = Faixa.fromJson(Map.of("localidade", "São Paulo", "uf", "SP",
+ "cep_ini", "01001001", "cep_fim", "05999999", "ceps", 3421));
+ assertEquals("01001001", faixa.cepIni(), "cepIni");
+ assertEquals("05999999", faixa.cepFim(), "cepFim");
+ assertEquals(3421L, faixa.ceps(), "the interval is sparse, and this is its census");
+ assertEquals("São Paulo/SP", faixa.describe(), "describe");
+ }
+
/** Health tolerates a partial body rather than failing the check. */
public void testHealthDefaults() {
Health health = Health.fromJson(Map.of("status", "ok"));