diff options
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.md | 45 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/CepNotFoundException.java | 42 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Cepis.java | 2 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Client.java | 43 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Faixa.java | 87 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Json.java | 4 | ||||
| -rw-r--r-- | src/main/java/is/cep/cepis/Main.java | 2 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/HttpTest.java | 10 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/LookupTest.java | 73 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/ModelTest.java | 27 |
11 files changed, 325 insertions, 12 deletions
@@ -3,7 +3,7 @@ JAVAC ?= javac JAR ?= jar JAVADOC ?= javadoc -VERSION := 0.1.0 +VERSION := 0.2.0 MAIN_CLASS := is.cep.cepis.Main TEST_RUNNER := is.cep.cepis.TestRunner @@ -26,7 +26,7 @@ cd cepis-java make build ``` -That writes `build/cepis-0.1.0.jar`; put it on the classpath. +That writes `build/cepis-0.2.0.jar`; put it on the classpath. ## Usage @@ -162,7 +162,7 @@ Client client = Client.builder().baseUrl("http://localhost:8080").build(); From the command line, to confirm quickly that it answers: ```sh -java -jar build/cepis-0.1.0.jar --base-url http://localhost:8080 01001000 +java -jar build/cepis-0.2.0.jar --base-url http://localhost:8080 01001000 ``` ## Errors @@ -174,7 +174,7 @@ caused it: | error | when | does retrying help? | | --- | --- | --- | | `InvalidCepException` | not 8 digits | no | -| `CepNotFoundException` | well-formed, not in the database | no | +| `CepNotFoundException` | well-formed, not in the database; may carry `faixa()` | no | | `RateLimitedException` | 429; carries `retryAfter()` | yes, later | | `ServerErrorException` | 5xx; carries `status()` | yes, later | | `NetworkException` | DNS, connection, timeout, TLS | yes, later | @@ -198,6 +198,45 @@ more. 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. +### The municipality behind a CEP that does not exist + +A CEP that fails is rarely random: it is usually someone who knows the city and +guessed the number — the classic guess being the `-000`, which 957 +municipalities never received. So a `CepNotFoundException` may name the +municipality whose band the CEP fell in: + +```java +try { + Address address = Cepis.lookup("25845-000"); +} catch (CepNotFoundException e) { + e.faixa().ifPresent(faixa -> { + faixa.describe(); // "Areal/RJ" + faixa.localidade(); // "Areal" + faixa.ibge(); // "3300225" + faixa.cepIni(); // "25845001" + faixa.cepFim(); // "25849899" + faixa.ceps(); // 118 + }); +} +``` + +Three things to keep straight, because getting any of them wrong is worse than +not having the field at all: + +- **It is still a 404.** This is context for the error, not an address. Do not + let it turn a not-found into a success — the CEP asked for does not exist. +- **`cepIni` and `cepFim` are the smallest and the largest CEP that *actually + exist* there**, not the bounds the Correios publish. The DNE's own start for + Areal is `25845000`, which is exactly the CEP that just failed. +- **The interval is sparse.** Areal's 118 CEPs are spread over some 9000 + numbers, so almost nothing between the two exists. That is what `ceps()` is + for. Never present the pair as a range, or a list, of valid CEPs. + +`faixa()` is empty whenever the CEP fell in a band no municipality owns; the API +omits the field in that case rather than sending null. A `faixa` that is +malformed, empty or of the wrong shape is dropped the same way: the not-found +itself is never at risk of becoming a parse failure. + ## Development Everything runs through `make` and the JDK. There is nothing to install first 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")); |