diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:33:37 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:33:37 -0300 |
| commit | 487b4c5628c02130580c675925a1d64b09a5f6cb (patch) | |
| tree | 240338af0c2c246bb2c689464f63d70b1f27f240 | |
| parent | 4a8c3b675df4b1a6b29904fc2af60605002a7f9e (diff) | |
| download | rust-main.tar.gz | |
A 404 from the API now brings a "faixa" naming the municipality the CEP
would have belonged to, and NotFound carries it. A CEP that fails is
rarely a random number: it is usually someone who knows the city and
guessed the digits, so naming the city turns a dead end into an answer
the caller can show.
It stays an error. The range it carries is the CEPs that actually exist
there, not the bounds the Correios publish — those start at the very CEP
that just failed — and it is sparse, which is what "ceps" measures. The
field is decoration on a failure, so parsing it is entirely defensive:
absent, null or malformed all yield None rather than costing a clean
not-found.
Breaking: NotFound gained a field, hence 0.2.0.
| -rw-r--r-- | Cargo.lock | 2 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | README.md | 49 | ||||
| -rw-r--r-- | src/client.rs | 27 | ||||
| -rw-r--r-- | src/error.rs | 49 | ||||
| -rw-r--r-- | src/lib.rs | 17 | ||||
| -rw-r--r-- | src/model.rs | 81 | ||||
| -rw-r--r-- | tests/api.rs | 99 |
8 files changed, 317 insertions, 9 deletions
@@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "cepis" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde_json", "ureq", @@ -1,6 +1,6 @@ [package] name = "cepis" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.85" description = "Client for the public CEP API at cep.is (official Correios/DNE data)" @@ -164,7 +164,7 @@ make**, not by the HTTP status that caused it: | variant | when | does retrying help? | | --- | --- | --- | | `InvalidCep` | not 8 digits | no | -| `NotFound` | well-formed, not in the database | no | +| `NotFound` | well-formed, not in the database; may carry a `faixa` | no | | `RateLimited` | 429; carries `retry_after` | yes, later | | `Server` | 5xx; carries `status` | yes, later | | `Network` | DNS, connection, timeout, TLS | yes, later | @@ -183,6 +183,53 @@ match cepis::lookup(&cep_from_form) { 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. +### A CEP that does not exist, and where it would be + +A failing CEP is rarely a random number: it is usually someone who knows the +city and guessed the digits, and the classic guess is the `-000` that 957 +municipalities never received. So `NotFound` carries the municipality that CEP +*would* belong to, whenever the API can place it: + +```rust +if let Err(err) = cepis::lookup("25845000") { + match err.faixa() { + Some(f) => println!("no such CEP in {}", f.municipality()), // "Areal/RJ" + None => println!("no such CEP"), + } +} +``` + +```rust +if let Some(f) = err.faixa() { + f.localidade; // "Areal" + f.uf; // "RJ" + f.estado; // "Rio de Janeiro" + f.regiao; // "Sudeste" + f.ibge; // "3300225" + f.cep_ini; // "25845001" + f.cep_fim; // "25849899" + f.ceps; // 118 +} +``` + +Three things not to get wrong: + +- **It is still an error.** The response is still a 404 and the CEP still does + not exist. This is context for the failure, never an address. +- **`cep_ini`/`cep_fim` are not the Correios range.** They are the smallest and + largest CEP that *actually exist* in the municipality. The DNE's own start + for Areal is `25845000` — exactly the CEP that just failed. Show the official + bounds and you suggest the CEP is valid. +- **The range is sparse.** Areal's 118 CEPs are scattered over the ~4,900 + numbers between the two ends; almost none of that interval exists either. + That is what `ceps` is for — the pair is not a list, or a count, of valid + CEPs. + +`faixa` is **omitted, never null**, when the CEP falls in a band no +municipality owns, so `err.faixa()` is `None` there. It is also `None` if the +field cannot be read: an unparseable extra never turns a clean not-found into a +parse error. + ## Development Everything runs through `make`, which shells out to `cargo`: diff --git a/src/client.rs b/src/client.rs index 8d73f06..4e6afcd 100644 --- a/src/client.rs +++ b/src/client.rs @@ -12,7 +12,7 @@ use serde_json::{Map, Value}; use crate::csv; use crate::error::{Error, Result}; -use crate::model::{Address, Health}; +use crate::model::{Address, Faixa, Health}; use crate::retry::{jitter, parse_retry_after}; /// Where the public API lives. @@ -270,7 +270,9 @@ impl Client { /// # Errors /// /// - [`Error::InvalidCep`] — not 8 digits (no request is made). - /// - [`Error::NotFound`] — well-formed, but not in the database. + /// - [`Error::NotFound`] — well-formed, but not in the database. It carries + /// a [`Faixa`] naming the municipality the CEP would belong to, whenever + /// the API can place it in one. /// - [`Error::RateLimited`] — 429 after every attempt. /// - [`Error::Server`] — 5xx after every attempt. /// - [`Error::Network`] — the request never completed. @@ -280,7 +282,10 @@ impl Client { let (status, body) = self.request(&format!("/{number}"), None, &[200])?; if status == 404 { - return Err(Error::NotFound { cep: number }); + return Err(Error::NotFound { + cep: number, + faixa: faixa(&body), + }); } if status == 400 { return Err(Error::invalid_cep(error_message( @@ -548,6 +553,22 @@ fn error_message(body: &[u8], fallback: &str) -> String { .unwrap_or_else(|| fallback.to_owned()) } +/// Pull the `faixa` block out of a 404 body, if there is a readable one. +/// +/// Every step is allowed to fail into `None`: a body that is not JSON, a +/// missing or null `faixa`, an object that names no municipality. The caller is +/// on its way to returning [`Error::NotFound`] either way, and an unparseable +/// extra field must not turn a correct not-found into an +/// [`Error::InvalidResponse`]. +fn faixa(body: &[u8]) -> Option<Box<Faixa>> { + serde_json::from_slice::<Value>(body) + .ok() + .as_ref() + .and_then(|v| v.get("faixa")) + .and_then(Faixa::from_json) + .map(Box::new) +} + fn preview(body: &[u8]) -> String { let text = String::from_utf8_lossy(body); let cut = text diff --git a/src/error.rs b/src/error.rs index 40a3049..b771ded 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,11 +15,17 @@ //! That split is why `NotFound` is not "the HTTP 404 variant": the client also //! returns `InvalidCep` without making any request at all, when the CEP is //! malformed before it ever leaves the machine. +//! +//! [`Error::NotFound`] may carry a [`Faixa`] — the municipality the CEP would +//! have belonged to. That is context on the failure and changes nothing about +//! it: the answer is still 404 and the CEP still does not exist. use std::error::Error as StdError; use std::fmt; use std::time::Duration; +use crate::model::Faixa; + /// The result of every fallible operation in this crate. pub type Result<T> = std::result::Result<T, Error>; @@ -45,6 +51,17 @@ pub enum Error { NotFound { /// The CEP that was looked up, unmasked. cep: String, + /// The municipality this CEP *would* belong to, when the API knows it. + /// + /// Still an error, and still a 404 — see [`Faixa`] before showing any + /// of it to a user. `None` when the CEP falls in a band no municipality + /// owns, and also whenever the field cannot be read: an unparseable + /// `faixa` never costs the not-found. + /// + /// Boxed to keep [`Error`] small: it travels in the `Err` of every + /// [`Result`] in this crate, including the ones that never carry a + /// `faixa`. [`Error::faixa`] hides the box at the call site. + faixa: Option<Box<Faixa>>, }, /// The API (or the edge in front of it) answered 429 on every attempt. @@ -102,6 +119,28 @@ impl Error { ) } + /// The municipality of a [`Error::NotFound`], when the API named one. + /// + /// `None` for every other variant, and for a not-found the API could not + /// place. It is context for the failure, not a result: the CEP asked for + /// still does not exist. + /// + /// ```no_run + /// # let cep_from_form = "25845000"; + /// if let Err(err) = cepis::lookup(cep_from_form) { + /// match err.faixa() { + /// Some(faixa) => println!("no such CEP in {}", faixa.municipality()), + /// None => println!("no such CEP"), + /// } + /// } + /// ``` + pub fn faixa(&self) -> Option<&Faixa> { + match self { + Error::NotFound { faixa, .. } => faixa.as_deref(), + _ => None, + } + } + pub(crate) fn invalid_cep(message: impl Into<String>) -> Self { Error::InvalidCep { message: message.into(), @@ -129,9 +168,17 @@ impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Error::InvalidCep { message } => f.write_str(message), - Error::NotFound { cep } => { + Error::NotFound { cep, faixa: None } => { write!(f, "CEP {cep} does not exist in the DNE database") } + Error::NotFound { + cep, + faixa: Some(faixa), + } => write!( + f, + "CEP {cep} does not exist in the DNE database (it would be in {})", + faixa.municipality() + ), Error::RateLimited { message, .. } => f.write_str(message), Error::Server { message, .. } => f.write_str(message), Error::Network { message, .. } => f.write_str(message), @@ -44,6 +44,21 @@ //! } //! ``` //! +//! A CEP that does not exist is rarely a random number — usually someone knew +//! the city and guessed the digits. So [`Error::NotFound`] carries the +//! municipality that CEP would have belonged to, when the API can name one: +//! +//! ```no_run +//! # let cep_from_form = "25845000"; +//! if let Err(err @ cepis::Error::NotFound { .. }) = cepis::lookup(cep_from_form) { +//! match err.faixa() { +//! // "no such CEP in Areal/RJ" — still not found, just better phrased +//! Some(faixa) => println!("no such CEP in {}", faixa.municipality()), +//! None => println!("no such CEP"), +//! } +//! } +//! ``` +//! //! Note on field names: the API surface is English, but the **data fields keep //! the API's own Portuguese names** (`logradouro`, `bairro`, `localidade`). //! What you read in the JSON is what you access on the value. @@ -65,7 +80,7 @@ pub use crate::client::{ Client, ClientBuilder, DEFAULT_URL, MAX_BATCH, RETRIABLE_STATUS, normalize_cep, }; pub use crate::error::{Error, Result}; -pub use crate::model::{Address, Geo, Health}; +pub use crate::model::{Address, Faixa, Geo, Health}; /// The version of this library, as sent in the `User-Agent`. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/model.rs b/src/model.rs index 59cacbe..9092239 100644 --- a/src/model.rs +++ b/src/model.rs @@ -64,6 +64,87 @@ impl Geo { } } +/// The municipality a CEP that does not exist would have belonged to. +/// +/// This is **context for a not-found**, never an address. It rides on +/// [`Error::NotFound`](crate::Error::NotFound), the response is still a 404, and +/// nothing here says the CEP that was looked up exists — it does not. +/// +/// It is there because a CEP that fails is rarely a random number: it is +/// usually someone who knows the city and guessed the digits, and the classic +/// guess is the `-000` that 957 municipalities never received. +/// +/// # The range is not the Correios range +/// +/// [`cep_ini`](Faixa::cep_ini) and [`cep_fim`](Faixa::cep_fim) are the smallest +/// and the largest CEP that **actually exist** in that municipality, not the +/// bounds the Correios publish for it. For Areal/RJ the published start is +/// `25845000` — which is precisely the CEP that just failed. Showing the +/// official bounds would therefore suggest the CEP is valid. +/// +/// # The range is sparse +/// +/// The two ends are not a span of usable numbers. Areal's 118 CEPs are spread +/// over the roughly 4,900 numbers between `25845001` and `25849899`; the vast +/// majority of that interval does not exist either. That is what +/// [`ceps`](Faixa::ceps) is for — never present the pair as a list, or as a +/// count, of valid CEPs. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Faixa { + /// Municipality. + pub localidade: String, + /// Federative unit, two letters: `"RJ"`. + pub uf: String, + /// Federative unit spelled out: `"Rio de Janeiro"`. + pub estado: String, + /// Region: `"Sudeste"`, `"Nordeste"`, and so on. + pub regiao: String, + /// IBGE municipality code — the key to join with public datasets. + pub ibge: String, + /// Smallest CEP that exists in the municipality, unmasked. + pub cep_ini: String, + /// Largest CEP that exists in the municipality, unmasked. + pub cep_fim: String, + /// How many CEPs exist between those two ends, the ends included. + /// + /// Read next to [`cep_ini`](Faixa::cep_ini) and [`cep_fim`](Faixa::cep_fim) + /// it is the measure of how sparse the interval is: 118 across ~4,900 + /// numbers for Areal/RJ. + pub ceps: u64, +} + +impl Faixa { + /// `"Areal/RJ"` — the municipality in the shortest form worth showing. + pub fn municipality(&self) -> String { + format!("{}/{}", self.localidade, self.uf) + } + + /// Build from the `faixa` block of a 404 body, or `None`. + /// + /// Anything short of an object naming a municipality yields `None`: + /// absent, null, empty, or malformed. The caller is already returning + /// [`Error::NotFound`](crate::Error::NotFound) — turning a clean not-found + /// into a parse failure over decoration would be a bad trade. + pub(crate) fn from_json(value: &Value) -> Option<Faixa> { + value.as_object()?; + let localidade = text(value, "localidade"); + if localidade.is_empty() { + return None; + } + Some(Faixa { + localidade, + uf: text(value, "uf"), + estado: text(value, "estado"), + regiao: text(value, "regiao"), + ibge: text(value, "ibge"), + cep_ini: text(value, "cep_ini"), + cep_fim: text(value, "cep_fim"), + ceps: count(value, "ceps"), + }) + } +} + /// A CEP and everything the database knows about it. /// /// Fields the database does not have for that CEP come back as empty strings, diff --git a/tests/api.rs b/tests/api.rs index c794687..349c951 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -25,6 +25,13 @@ const SE_ADDRESS: &str = r#"{ "ddd": "11", "siafi": "7107" }"#; +/// The `faixa` a 404 carries when the CEP falls inside a municipality. +/// +/// Areal/RJ, and the numbers are the real ones: 118 CEPs scattered over the +/// ~4,900 between the two ends, and a `cep_ini` one above the CEP being looked +/// up — the DNE's own start for Areal is `25845000`, which does not exist. +const FAIXA: &str = r#","faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro","regiao":"Sudeste","ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}"#; + const CSV_HEADER: &str = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"; @@ -180,6 +187,12 @@ fn csv_body(rows: &[String]) -> String { format!("{}\n{}\n", CSV_HEADER, rows.join("\n")) } +/// A 404 body, with `extra` spliced in as further fields — a `faixa`, whole or +/// broken. +fn not_found_with(extra: &str) -> String { + format!(r#"{{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"{extra}}}"#) +} + fn with_geo(precisao: &str, raio_m: i64) -> String { format!( r#"{{"cep":"01310100","logradouro":"Avenida Paulista","complemento":"","unidade":"","bairro":"Bela Vista","localidade":"São Paulo","uf":"SP","estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107","geo":{{"lat":-23.5,"lon":-46.6,"raio_m":{raio_m},"precisao":"{precisao}"}}}}"# @@ -295,8 +308,92 @@ fn not_found() { let err = server.client().lookup("99999999").unwrap_err(); - assert!(matches!(&err, Error::NotFound { cep } if cep == "99999999")); + assert!(matches!(&err, Error::NotFound { cep, .. } if cep == "99999999")); + assert!(!err.is_retriable()); + assert!(err.faixa().is_none()); + assert_eq!( + err.to_string(), + "CEP 99999999 does not exist in the DNE database" + ); +} + +#[test] +fn not_found_names_the_municipality_when_the_api_does() { + let server = Server::start(); + server.respond("/25845000", vec![Reply::new(404, ¬_found_with(FAIXA))]); + + let err = server.client().lookup("25845000").unwrap_err(); + let faixa = err.faixa().expect("faixa present"); + + assert_eq!(faixa.localidade, "Areal"); + assert_eq!(faixa.uf, "RJ"); + assert_eq!(faixa.estado, "Rio de Janeiro"); + assert_eq!(faixa.regiao, "Sudeste"); + assert_eq!(faixa.ibge, "3300225"); + assert_eq!(faixa.municipality(), "Areal/RJ"); + + // The ends are the CEPs that exist, not the Correios bounds: the published + // start for Areal is 25845000 — the CEP that just failed. + assert_eq!(faixa.cep_ini, "25845001"); + assert_eq!(faixa.cep_fim, "25849899"); + assert_eq!(faixa.ceps, 118); +} + +#[test] +fn a_faixa_is_still_a_not_found() { + let server = Server::start(); + server.respond("/25845000", vec![Reply::new(404, ¬_found_with(FAIXA))]); + + let err = server.client().lookup("25845000").unwrap_err(); + + assert!(matches!(&err, Error::NotFound { cep, .. } if cep == "25845000")); assert!(!err.is_retriable()); + assert_eq!( + err.to_string(), + "CEP 25845000 does not exist in the DNE database (it would be in Areal/RJ)" + ); +} + +#[test] +fn an_unreadable_faixa_does_not_cost_the_not_found() { + for broken in [ + r#","faixa":null"#, + r#","faixa":{}"#, + r#","faixa":[]"#, + r#","faixa":"Areal""#, + r#","faixa":{"uf":"RJ","ceps":118}"#, + ] { + let server = Server::start(); + server.respond("/25845000", vec![Reply::new(404, ¬_found_with(broken))]); + + let err = server.client().lookup("25845000").unwrap_err(); + + assert!( + matches!(&err, Error::NotFound { cep, .. } if cep == "25845000"), + "expected NotFound for {broken}, got {err:?}" + ); + assert!(err.faixa().is_none(), "expected no faixa for {broken}"); + } +} + +#[test] +fn a_faixa_missing_the_extras_still_names_the_municipality() { + let server = Server::start(); + server.respond( + "/25845000", + vec![Reply::new( + 404, + ¬_found_with(r#","faixa":{"localidade":"Areal"}"#), + )], + ); + + let err = server.client().lookup("25845000").unwrap_err(); + let faixa = err.faixa().expect("faixa present"); + + assert_eq!(faixa.localidade, "Areal"); + assert_eq!(faixa.uf, ""); + assert_eq!(faixa.cep_ini, ""); + assert_eq!(faixa.ceps, 0); } #[test] |