From 2795e86ccf5a15a7601611aaa12be1e0758c963a Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:27:46 -0300 Subject: Carry the band's municipality on a not-found CEP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CEP that fails is almost never random: it is someone who knows the city and guessed the number, usually the -000 that 957 municipalities never received. The API now answers those 404s with a faixa block naming the municipality that owns the band, so CepNotFoundException exposes it and says it in its message. It stays an error. The band is context, not an address, and its cep_ini and cep_fim are the smallest and largest CEP that actually exist there — not the bounds the Correios publish, and not a dense interval — so the type documents both traps rather than inviting callers to walk the range. Reading it is best effort: a missing, malformed or oddly shaped faixa is dropped, because turning a definitive answer about the request into a parse failure would hide the answer. --- .../java/is/cep/cepis/CepNotFoundException.java | 42 ++++++++++- src/main/java/is/cep/cepis/Cepis.java | 2 +- src/main/java/is/cep/cepis/Client.java | 43 ++++++++++- src/main/java/is/cep/cepis/Faixa.java | 87 ++++++++++++++++++++++ src/main/java/is/cep/cepis/Json.java | 4 +- src/main/java/is/cep/cepis/Main.java | 2 +- src/test/java/is/cep/cepis/HttpTest.java | 10 +++ src/test/java/is/cep/cepis/LookupTest.java | 73 ++++++++++++++++++ src/test/java/is/cep/cepis/ModelTest.java | 27 +++++++ 9 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 src/main/java/is/cep/cepis/Faixa.java (limited to 'src') 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. * *

Definitive: the client does not retry it, and neither should you. + * + *

{@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. + * + *

It is context for the error, not an address. 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. + * + *

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() { + 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 { *

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}. * + *

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. + * + *

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. + * + *

It comes attached to {@link CepNotFoundException#faixa()}, and it is + * context for an error, never an address. 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. + * + *

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. + * + *

Two things about {@link #cepIni()} and {@link #cepFim()}: + * + *

+ * + * @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"}. + * + *

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 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 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. * *

{@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
  * }
* *

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, "gateway")); + 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")); -- cgit v1.3