summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:33:37 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:33:37 -0300
commit487b4c5628c02130580c675925a1d64b09a5f6cb (patch)
tree240338af0c2c246bb2c689464f63d70b1f27f240 /src
parent4a8c3b675df4b1a6b29904fc2af60605002a7f9e (diff)
downloadrust-0.2.0.tar.gz
Carry the municipality of a CEP that does not existHEADv0.2.0main
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.
Diffstat (limited to 'src')
-rw-r--r--src/client.rs27
-rw-r--r--src/error.rs49
-rw-r--r--src/lib.rs17
-rw-r--r--src/model.rs81
4 files changed, 169 insertions, 5 deletions
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),
diff --git a/src/lib.rs b/src/lib.rs
index 84262d7..686520f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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,