diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:17:46 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:17:46 -0300 |
| commit | 4a8c3b675df4b1a6b29904fc2af60605002a7f9e (patch) | |
| tree | 5c3dcc6594603d242b6a284c1601fd1ffbfcc8f4 /src | |
| download | rust-4a8c3b675df4b1a6b29904fc2af60605002a7f9e.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/cepis.rs | 100 | ||||
| -rw-r--r-- | src/client.rs | 559 | ||||
| -rw-r--r-- | src/csv.rs | 99 | ||||
| -rw-r--r-- | src/error.rs | 150 | ||||
| -rw-r--r-- | src/lib.rs | 100 | ||||
| -rw-r--r-- | src/model.rs | 227 | ||||
| -rw-r--r-- | src/retry.rs | 143 |
7 files changed, 1378 insertions, 0 deletions
diff --git a/src/bin/cepis.rs b/src/bin/cepis.rs new file mode 100644 index 0000000..252cb90 --- /dev/null +++ b/src/bin/cepis.rs @@ -0,0 +1,100 @@ +//! Minimal CLI: `cepis 01001000`. +//! +//! It exists for one concrete case — quickly confirming a local instance +//! answers before pointing code at it: +//! +//! ```sh +//! cepis --base-url http://localhost:8080 01001000 +//! ``` +//! +//! A single CEP goes through the unit lookup, which carries `geo`; several go +//! through the batch, which is one request instead of N and has no `geo`. +//! +//! It is not the library's main interface; for that, use the `cepis` crate. + +use std::process::ExitCode; +use std::time::Duration; + +use cepis::{Client, DEFAULT_URL}; +use serde_json::Value; + +const USAGE: &str = "\ +usage: cepis [--base-url URL] [--timeout SECONDS] CEP [CEP ...] + +Look CEPs up against the cep.is API. + + --base-url URL API URL (default: https://cep.is) — point it at a local instance + --timeout SECONDS per-socket timeout (default: 10) + -h, --help show this message"; + +fn main() -> ExitCode { + let mut base_url = DEFAULT_URL.to_owned(); + let mut timeout = Duration::from_secs(10); + let mut ceps: Vec<String> = Vec::new(); + let mut args = std::env::args().skip(1); + + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + "--base-url" => match args.next() { + Some(value) => base_url = value, + None => return fail("--base-url needs a URL"), + }, + "--timeout" => match args.next().map(|v| v.parse::<f64>()) { + Some(Ok(seconds)) if seconds > 0.0 => timeout = Duration::from_secs_f64(seconds), + _ => return fail("--timeout needs a positive number of seconds"), + }, + other if other.starts_with('-') => return fail(&format!("unknown option {other}")), + other => ceps.push(other.to_owned()), + } + } + + if ceps.is_empty() { + eprintln!("{USAGE}"); + return ExitCode::FAILURE; + } + + let client = Client::builder() + .base_url(base_url) + .timeout(timeout) + .build(); + + let addresses = if ceps.len() == 1 { + match client.lookup(&ceps[0]) { + Ok(address) => vec![Some(address)], + Err(err) => return fail(&err.to_string()), + } + } else { + match client.lookup_many(&ceps) { + Ok(found) => found, + Err(err) => return fail(&err.to_string()), + } + }; + + let output: Vec<Value> = addresses + .into_iter() + .map(|a| a.map(|a| a.raw).unwrap_or(Value::Null)) + .collect(); + + let rendered = if output.len() == 1 { + serde_json::to_string_pretty(&output[0]) + } else { + serde_json::to_string_pretty(&output) + }; + + match rendered { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => fail(&err.to_string()), + } +} + +fn fail(message: &str) -> ExitCode { + eprintln!("error: {message}"); + ExitCode::FAILURE +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..8d73f06 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,559 @@ +//! The HTTP client. +//! +//! Retry policy: 429 and the transient 5xx in [`RETRIABLE_STATUS`], plus +//! network failures, are retried with exponentially backed-off, jittered waits. +//! 400 and 404 are deliberately absent from that set — both are definitive +//! answers about the request, so retrying returns the same thing more slowly +//! while burning someone else's quota. + +use std::time::{Duration, Instant}; + +use serde_json::{Map, Value}; + +use crate::csv; +use crate::error::{Error, Result}; +use crate::model::{Address, Health}; +use crate::retry::{jitter, parse_retry_after}; + +/// Where the public API lives. +pub const DEFAULT_URL: &str = "https://cep.is"; + +/// How many CEPs the batch endpoint accepts in one request. +pub const MAX_BATCH: usize = 500; + +/// The statuses worth trying again: rate limiting and the transient 5xx. +pub const RETRIABLE_STATUS: [u16; 5] = [429, 500, 502, 503, 504]; + +const ACCEPT: &str = "application/json, text/csv;q=0.9, */*;q=0.1"; + +/// Return the CEP's 8 digits, accepting the mask. +/// +/// `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`. +/// +/// Fails with [`Error::InvalidCep`] unless exactly 8 digits remain. This runs +/// **before** any request: a 7-digit CEP is the caller's bug, and spending a +/// round trip for the server to say so wastes both sides' time. +/// +/// ``` +/// assert_eq!(cepis::normalize_cep("01001-000").unwrap(), "01001000"); +/// assert!(cepis::normalize_cep("123").is_err()); +/// ``` +pub fn normalize_cep(value: &str) -> Result<String> { + let cleaned: String = value + .trim() + .chars() + .filter(|c| !matches!(c, '-' | '.' | ' ')) + .collect(); + + if cleaned.len() == 8 && cleaned.bytes().all(|b| b.is_ascii_digit()) { + Ok(cleaned) + } else { + Err(Error::invalid_cep(format!( + "CEP must be 8 digits, got {value:?}" + ))) + } +} + +/// Builder for a [`Client`]. +/// +/// Every value has a default; set only what differs. See [`Client`] for what +/// each one means. +#[derive(Debug, Clone)] +pub struct ClientBuilder { + base_url: String, + timeout: Duration, + max_attempts: u32, + backoff_base: Duration, + backoff_max: Duration, + total_timeout: Option<Duration>, + user_agent: String, +} + +impl Default for ClientBuilder { + fn default() -> Self { + ClientBuilder { + base_url: DEFAULT_URL.to_owned(), + timeout: Duration::from_secs(10), + max_attempts: 3, + backoff_base: Duration::from_millis(500), + backoff_max: Duration::from_secs(8), + total_timeout: None, + user_agent: format!("cepis-rust/{} (+https://cep.is)", crate::VERSION), + } + } +} + +impl ClientBuilder { + /// Where the API lives. + /// + /// Point it at a local instance (the API repo brings one up on + /// `http://localhost:8080` with `just up`) or at a test server. It is the + /// only setting that usually changes between environments. A trailing slash + /// is stripped, so it cannot produce doubled separators that some proxies + /// normalise and others 404. + pub fn base_url(mut self, value: impl Into<String>) -> Self { + self.base_url = value.into(); + self + } + + /// How long to wait **per socket operation** (connect, and each read). + /// + /// This is not a cap on the whole call — that cap is + /// [`total_timeout`](ClientBuilder::total_timeout). + pub fn timeout(mut self, value: Duration) -> Self { + self.timeout = value; + self + } + + /// How many attempts in total, counting the first. + /// + /// `1` disables retrying. The default of `3` covers the common case (one + /// transient failure) without turning a real outage into a long wait. + pub fn max_attempts(mut self, value: u32) -> Self { + self.max_attempts = value; + self + } + + /// Base of the exponential wait. + /// + /// The actual wait is drawn from `[0, base * 2^(n-1)]` — *full jitter*. The + /// randomness is not decoration: without it, N clients that fail together + /// come back together and reproduce the spike that took the service down. + pub fn backoff_base(mut self, value: Duration) -> Self { + self.backoff_base = value; + self + } + + /// Cap on each individual wait. + /// + /// It applies to a server-sent `Retry-After` too, or a `Retry-After: 3600` + /// from a misconfigured proxy would park the thread for an hour. + pub fn backoff_max(mut self, value: Duration) -> Self { + self.backoff_max = value; + self + } + + /// Cap on the whole call, attempts and waits included. + /// + /// `None` leaves [`max_attempts`](ClientBuilder::max_attempts) as the only + /// bound. Set it in code serving a web request: without it the worst case + /// is `max_attempts * timeout` plus the sum of the backoffs. + pub fn total_timeout(mut self, value: Option<Duration>) -> Self { + self.total_timeout = value; + self + } + + /// Override the `User-Agent`. + /// + /// The default identifies the library and its version, which is what lets + /// the server diagnose an old client misbehaving. + pub fn user_agent(mut self, value: impl Into<String>) -> Self { + self.user_agent = value.into(); + self + } + + /// Build the client. + /// + /// # Panics + /// + /// If `max_attempts` is 0 or `timeout` is zero — both are configuration + /// mistakes that can only produce a client which never completes a call. + pub fn build(self) -> Client { + assert!(self.max_attempts >= 1, "max_attempts must be >= 1"); + assert!(!self.timeout.is_zero(), "timeout must be > 0"); + + let config = ureq::Agent::config_builder() + .http_status_as_error(false) + .user_agent(self.user_agent.as_str()) + .accept(ACCEPT) + .accept_encoding("identity") + .timeout_connect(Some(self.timeout)) + .timeout_send_request(Some(self.timeout)) + .timeout_recv_response(Some(self.timeout)) + .timeout_recv_body(Some(self.timeout)) + .build(); + + Client { + base_url: self.base_url.trim_end_matches('/').to_owned(), + timeout: self.timeout, + max_attempts: self.max_attempts, + backoff_base: self.backoff_base, + backoff_max: self.backoff_max, + total_timeout: self.total_timeout, + user_agent: self.user_agent, + agent: ureq::Agent::new_with_config(config), + } + } +} + +/// Client for the cep.is API. +/// +/// Build one and reuse it: the configuration is immutable, no method keeps +/// state between calls, and the underlying connection pool is shared — so a +/// second lookup on the same host skips the TLS handshake. +/// +/// ```no_run +/// use std::time::Duration; +/// +/// let client = cepis::Client::builder() +/// .timeout(Duration::from_secs(5)) +/// .total_timeout(Some(Duration::from_secs(15))) +/// .build(); +/// +/// let address = client.lookup("01001000")?; +/// println!("{} {}", address.localidade, address.uf); +/// # Ok::<_, cepis::Error>(()) +/// ``` +#[derive(Debug, Clone)] +pub struct Client { + base_url: String, + timeout: Duration, + max_attempts: u32, + backoff_base: Duration, + backoff_max: Duration, + total_timeout: Option<Duration>, + user_agent: String, + agent: ureq::Agent, +} + +impl Default for Client { + fn default() -> Self { + Client::new() + } +} + +impl Client { + /// A client with the default configuration, pointing at <https://cep.is>. + pub fn new() -> Client { + ClientBuilder::default().build() + } + + /// Start configuring a client. + pub fn builder() -> ClientBuilder { + ClientBuilder::default() + } + + /// The API URL in use, without a trailing slash. + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// The `User-Agent` sent with every request. + pub fn user_agent(&self) -> &str { + &self.user_agent + } + + /// The per-socket-operation timeout. + pub fn timeout(&self) -> Duration { + self.timeout + } + + /// How many attempts each call makes at most, counting the first. + pub fn max_attempts(&self) -> u32 { + self.max_attempts + } + + /// The cap on the whole call, if one is set. + pub fn total_timeout(&self) -> Option<Duration> { + self.total_timeout + } + + /// Look up a single CEP. + /// + /// The CEP may be masked or not. This is the only way to obtain a + /// coordinate — see [`lookup_many`](Client::lookup_many) for why. + /// + /// A 400 from the API is surfaced as [`Error::InvalidCep`] carrying the + /// API's own message: the format already passed local validation, so the + /// server knows something this client does not. + /// + /// # Errors + /// + /// - [`Error::InvalidCep`] — not 8 digits (no request is made). + /// - [`Error::NotFound`] — well-formed, but not in the database. + /// - [`Error::RateLimited`] — 429 after every attempt. + /// - [`Error::Server`] — 5xx after every attempt. + /// - [`Error::Network`] — the request never completed. + /// - [`Error::InvalidResponse`] — the body could not be read as JSON. + pub fn lookup(&self, cep: &str) -> Result<Address> { + let number = normalize_cep(cep)?; + let (status, body) = self.request(&format!("/{number}"), None, &[200])?; + + if status == 404 { + return Err(Error::NotFound { cep: number }); + } + if status == 400 { + return Err(Error::invalid_cep(error_message( + &body, + &format!("CEP {number} rejected"), + ))); + } + + Ok(Address::from_json(json_object(&body)?)) + } + + /// Look up many CEPs in one batch. + /// + /// Returns a `Vec` of **the same length and in the same order** as the + /// input. A CEP that does not exist becomes `None` at its position — it + /// does not vanish from the list, and it is not an error. That is the right + /// difference from [`lookup`](Client::lookup): in a batch of a thousand, + /// one bad CEP must not cost the other nine hundred and ninety-nine. + /// + /// A malformed CEP also becomes `None`, for the same reason. If you need to + /// know *which* one was malformed, validate first with + /// [`normalize_cep`]. + /// + /// Input is chunked into blocks of [`MAX_BATCH`] automatically. Do not loop + /// over [`lookup`](Client::lookup) instead: 500 calls pay 500 round trips, + /// the batch pays one. + /// + /// # Warning + /// + /// The batch carries **no coordinate**. The endpoint behind it is CSV, and + /// the CSV has no `geo` column. Every [`Address`] returned here has + /// `geo: None`, including for CEPs that do have one. Need `geo`? Use + /// [`lookup`](Client::lookup), one at a time. + /// + /// # Errors + /// + /// Transport failures only — [`Error::RateLimited`], [`Error::Server`], + /// [`Error::Network`], [`Error::InvalidResponse`]. A bad CEP never fails a + /// batch. + pub fn lookup_many(&self, ceps: &[impl AsRef<str>]) -> Result<Vec<Option<Address>>> { + if ceps.is_empty() { + return Ok(Vec::new()); + } + + let mut result = Vec::with_capacity(ceps.len()); + for chunk in ceps.chunks(MAX_BATCH) { + result.extend(self.lookup_chunk(chunk)?); + } + Ok(result) + } + + /// Query `GET /health`: does the API answer, and with which database. + /// + /// An API reporting itself degraded is not an error — that arrives in + /// [`Health::status`], and checking health is exactly when you want the + /// diagnosis rather than a failure. Network failures still fail. + pub fn health(&self) -> Result<Health> { + let (_, body) = self.request("/health", None, &[200, 503])?; + Ok(Health::from_json(json_object(&body)?)) + } + + /// One chunk of at most [`MAX_BATCH`] CEPs, in a single request. + /// + /// A blank CSV row means "not found": the API fills only the `cep` column + /// and leaves the rest empty, precisely so it does not misalign whoever is + /// consuming the batch in a spreadsheet. + /// + /// The API guarantees one row per requested CEP. Should that ever fail to + /// hold — a proxy truncating, a cut response — the result is padded to the + /// input length rather than returned short, because a shifted list silently + /// pairs the wrong address with the wrong CEP. + fn lookup_chunk(&self, ceps: &[impl AsRef<str>]) -> Result<Vec<Option<Address>>> { + let joined = ceps + .iter() + .map(|c| c.as_ref()) + .collect::<Vec<_>>() + .join(","); + let (_, body) = self.request("/csv", Some(("ceps", joined)), &[200])?; + + let text = String::from_utf8_lossy(&body); + let records = csv::parse(&text); + let mut rows: Vec<Option<Address>> = Vec::with_capacity(ceps.len()); + + if let Some((header, data)) = records.split_first() { + for record in data { + let mut object = Map::new(); + for (column, value) in header.iter().zip(record.iter()) { + object.insert(column.clone(), Value::String(value.clone())); + } + let known = object + .get("localidade") + .and_then(Value::as_str) + .is_some_and(|v| !v.trim().is_empty()); + rows.push(known.then(|| Address::from_json(Value::Object(object)))); + } + } + + rows.resize(ceps.len(), None); + Ok(rows) + } + + /// Perform the GET, with retries. Returns `(status, body)`. + /// + /// *Definitive* error statuses (400, 404) come back normally, for the + /// caller to turn into the right error — only transient failures become + /// errors here, and only once the attempts are exhausted. + fn request( + &self, + path: &str, + query: Option<(&str, String)>, + accepted: &[u16], + ) -> Result<(u16, Vec<u8>)> { + let url = format!("{}{}", self.base_url, path); + let deadline = self.total_timeout.map(|cap| Instant::now() + cap); + + let mut last_status: Option<u16> = None; + let mut last_retry_after: Option<Duration> = None; + let mut last_network: Option<ureq::Error> = None; + + for attempt in 1..=self.max_attempts { + let remaining = match deadline { + Some(d) => { + let left = d.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + Some(left) + } + None => None, + }; + + let mut request = self.agent.get(&url); + if let Some((key, value)) = &query { + request = request.query(key, value); + } + if let Some(left) = remaining { + request = request.config().timeout_global(Some(left)).build(); + } + + match request.call() { + Ok(mut response) => { + let status = response.status().as_u16(); + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(parse_retry_after); + + match response.body_mut().read_to_vec() { + Ok(body) => { + if accepted.contains(&status) || !RETRIABLE_STATUS.contains(&status) { + return Ok((status, body)); + } + last_status = Some(status); + last_retry_after = retry_after; + last_network = None; + } + Err(err) => { + last_status = None; + last_retry_after = None; + last_network = Some(err); + } + } + } + Err(ureq::Error::BadUri(detail)) => { + return Err(Error::network( + format!("{url} is not a usable URL: {detail}"), + None, + )); + } + Err(err) => { + last_status = None; + last_retry_after = None; + last_network = Some(err); + } + } + + if attempt == self.max_attempts { + break; + } + + let mut wait = self.backoff(attempt, last_retry_after); + if let Some(d) = deadline { + let left = d.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + wait = wait.min(left); + } + std::thread::sleep(wait); + } + + let attempts = self.max_attempts; + match (last_status, last_network) { + (Some(429), _) => Err(Error::RateLimited { + message: format!("rate limit reached at {url} (after {attempts} attempt(s))"), + retry_after: last_retry_after, + }), + (Some(status), _) => Err(Error::Server { + message: format!( + "the API answered {status} at {url} (after {attempts} attempt(s))" + ), + status, + }), + (None, source) => { + let detail = source + .as_ref() + .map(|e| e.to_string()) + .unwrap_or_else(|| "no attempt was made within the total timeout".to_owned()); + Err(Error::network( + format!("could not reach {url} (after {attempts} attempt(s)): {detail}"), + source.map(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>), + )) + } + } + } + + /// How long to wait before the next attempt. + /// + /// The server's `Retry-After` wins over the local calculation when present: + /// it knows when it will be back, we are guessing. It still goes through + /// the `backoff_max` cap. Otherwise the wait is drawn from `[0, ceiling]` + /// rather than being the ceiling. + fn backoff(&self, attempt: u32, retry_after: Option<Duration>) -> Duration { + if let Some(asked) = retry_after { + return asked.min(self.backoff_max); + } + let ceiling = self + .backoff_base + .saturating_mul(1u32 << (attempt - 1).min(31)) + .min(self.backoff_max); + jitter(ceiling) + } +} + +/// Decode a JSON object body, or fail with [`Error::InvalidResponse`]. +fn json_object(body: &[u8]) -> Result<Value> { + let value: Value = serde_json::from_slice(body).map_err(|err| { + Error::invalid_response(format!( + "response is not valid JSON — something between you and the API is \ + answering instead of it: {}: {err}", + preview(body) + )) + })?; + + if value.is_object() { + Ok(value) + } else { + Err(Error::invalid_response(format!( + "expected a JSON object, got {}", + preview(body) + ))) + } +} + +/// Pull `mensagem` out of an error body, falling back to a default. +fn error_message(body: &[u8], fallback: &str) -> String { + serde_json::from_slice::<Value>(body) + .ok() + .as_ref() + .and_then(|v| v.get("mensagem")) + .and_then(Value::as_str) + .filter(|m| !m.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| fallback.to_owned()) +} + +fn preview(body: &[u8]) -> String { + let text = String::from_utf8_lossy(body); + let cut = text + .char_indices() + .nth(120) + .map(|(i, _)| i) + .unwrap_or(text.len()); + format!("{:?}", &text[..cut]) +} diff --git a/src/csv.rs b/src/csv.rs new file mode 100644 index 0000000..e434989 --- /dev/null +++ b/src/csv.rs @@ -0,0 +1,99 @@ +//! A small RFC 4180 reader for the `/csv` batch endpoint. +//! +//! Values may contain commas and are then double-quoted with `""` escaping, +//! which is why the batch cannot be split on `,`. The reader is deliberately +//! lenient: blank lines are skipped and a stray quote inside an unquoted field +//! is kept as data, because a malformed byte in one row must not cost the whole +//! batch. + +/// Split CSV text into records, honouring quoting, `""` escapes, embedded +/// commas and newlines, CRLF and LF line endings, and a leading BOM. +pub(crate) fn parse(input: &str) -> Vec<Vec<String>> { + let input = input.strip_prefix('\u{feff}').unwrap_or(input); + + let mut records: Vec<Vec<String>> = Vec::new(); + let mut record: Vec<String> = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + chars.next(); + field.push('"'); + } else { + quoted = false; + } + } else { + field.push(c); + } + continue; + } + + match c { + '"' if field.is_empty() => quoted = true, + ',' => record.push(std::mem::take(&mut field)), + '\n' | '\r' => { + if c == '\r' && chars.peek() == Some(&'\n') { + chars.next(); + } + record.push(std::mem::take(&mut field)); + records.push(std::mem::take(&mut record)); + } + _ => field.push(c), + } + } + + if !field.is_empty() || !record.is_empty() { + record.push(field); + records.push(record); + } + + records.retain(|r| r.iter().any(|f| !f.is_empty())); + records +} + +#[cfg(test)] +mod tests { + use super::parse; + + #[test] + fn splits_plain_rows() { + let records = parse("a,b,c\n1,2,3\n"); + assert_eq!(records, vec![vec!["a", "b", "c"], vec!["1", "2", "3"]]); + } + + #[test] + fn keeps_empty_fields() { + let records = parse("99999999,,,,,,,,,,,,\n"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].len(), 13); + } + + #[test] + fn reads_quoted_field_with_comma() { + let records = parse("cep,logradouro\n01001000,\"Rua Um, Dois\"\n"); + assert_eq!(records[1][1], "Rua Um, Dois"); + } + + #[test] + fn unescapes_doubled_quotes() { + let records = parse("a\n\"say \"\"hi\"\"\"\n"); + assert_eq!(records[1][0], "say \"hi\""); + } + + #[test] + fn accepts_embedded_newline_and_crlf() { + let records = parse("a,b\r\n\"line\nbreak\",2\r\n"); + assert_eq!(records[1][0], "line\nbreak"); + assert_eq!(records[1][1], "2"); + } + + #[test] + fn ignores_bom_and_blank_lines() { + let records = parse("\u{feff}a,b\n\n1,2"); + assert_eq!(records, vec![vec!["a", "b"], vec!["1", "2"]]); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..40a3049 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,150 @@ +//! Error type. +//! +//! Everything the library can fail with is one [`Error`], so a caller who only +//! cares that *something* went wrong matches one type and is done. +//! +//! Below that, the split is by **the decision the caller has to make**, not by +//! the HTTP status that caused it: +//! +//! - [`Error::InvalidCep`] and [`Error::NotFound`] are problems with the +//! *request*. Retrying is pointless — the same CEP will fail again. +//! - [`Error::Network`], [`Error::Server`] and [`Error::RateLimited`] are +//! problems with *transport*. Retrying may help, and the client already +//! retried on its own before returning any of them. +//! +//! 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. + +use std::error::Error as StdError; +use std::fmt; +use std::time::Duration; + +/// The result of every fallible operation in this crate. +pub type Result<T> = std::result::Result<T, Error>; + +/// Everything that can go wrong when talking to the API. +/// +/// Use [`Error::is_retriable`] when the choice is simply "give up or try again +/// later"; match on the variants when the caller needs to tell a bad CEP from a +/// bad network. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// The CEP is not 8 digits. + /// + /// Usually returned **without** a request ever being made — the client + /// checks the format before going to the network. It is also what a 400 + /// from the API becomes, in which case the message is the API's own. + InvalidCep { + /// Human-readable explanation, including the offending value. + message: String, + }, + + /// The CEP is well-formed but does not exist in the DNE database. + NotFound { + /// The CEP that was looked up, unmasked. + cep: String, + }, + + /// The API (or the edge in front of it) answered 429 on every attempt. + RateLimited { + /// Human-readable explanation. + message: String, + /// What the server asked for via `Retry-After`, or `None` if it did not + /// say. The client already honoured it across the attempts it made; it + /// is exposed here for callers who want to reschedule the work. + retry_after: Option<Duration>, + }, + + /// The API answered 5xx on every attempt. + Server { + /// Human-readable explanation. + message: String, + /// The HTTP status seen on the last attempt. + status: u16, + }, + + /// The request never completed: DNS, refused connection, timeout, TLS. + /// + /// Returned only after every configured attempt has failed. + Network { + /// Human-readable explanation. + message: String, + /// The underlying transport error, when there is one. + source: Option<Box<dyn StdError + Send + Sync>>, + }, + + /// A success status came back with a body that cannot be parsed. + /// + /// In practice this means something between you and the 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. + InvalidResponse { + /// Human-readable explanation. + message: String, + }, +} + +impl Error { + /// Whether trying the same call again later could plausibly succeed. + /// + /// `true` for [`Error::RateLimited`], [`Error::Server`] and + /// [`Error::Network`]; `false` for a bad or unknown CEP, which fails the + /// same way forever. + /// + /// The client has already retried internally by the time any of these are + /// returned, so "later" means later — not immediately. + pub fn is_retriable(&self) -> bool { + matches!( + self, + Error::RateLimited { .. } | Error::Server { .. } | Error::Network { .. } + ) + } + + pub(crate) fn invalid_cep(message: impl Into<String>) -> Self { + Error::InvalidCep { + message: message.into(), + } + } + + pub(crate) fn invalid_response(message: impl Into<String>) -> Self { + Error::InvalidResponse { + message: message.into(), + } + } + + pub(crate) fn network( + message: impl Into<String>, + source: Option<Box<dyn StdError + Send + Sync>>, + ) -> Self { + Error::Network { + message: message.into(), + source, + } + } +} + +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 } => { + write!(f, "CEP {cep} does not exist in the DNE database") + } + Error::RateLimited { message, .. } => f.write_str(message), + Error::Server { message, .. } => f.write_str(message), + Error::Network { message, .. } => f.write_str(message), + Error::InvalidResponse { message } => f.write_str(message), + } + } +} + +impl StdError for Error { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Error::Network { source, .. } => source.as_ref().map(|e| e.as_ref() as &dyn StdError), + _ => None, + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..84262d7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,100 @@ +//! Rust client for the public CEP API at [cep.is](https://cep.is). Official +//! Correios data (DNE), refreshed every two weeks. +//! +//! Quick use — for a script, the free functions are enough: +//! +//! ```no_run +//! let address = cepis::lookup("01001-000")?; +//! println!("{} {}", address.localidade, address.uf); // São Paulo SP +//! # Ok::<_, cepis::Error>(()) +//! ``` +//! +//! In an application — build a [`Client`] once and reuse it: +//! +//! ```no_run +//! use std::time::Duration; +//! +//! let client = cepis::Client::builder() +//! .timeout(Duration::from_secs(5)) +//! .total_timeout(Some(Duration::from_secs(15))) +//! .build(); +//! +//! let address = client.lookup("01001000")?; +//! # Ok::<_, cepis::Error>(()) +//! ``` +//! +//! Pointing at a local instance (the API repo brings one up with `just up`): +//! +//! ```no_run +//! let client = cepis::Client::builder() +//! .base_url("http://localhost:8080") +//! .build(); +//! # let _ = client; +//! ``` +//! +//! Errors are one type, split by decision — whether retrying helps or not: +//! +//! ```no_run +//! # let cep_from_form = "01001000"; +//! match cepis::lookup(cep_from_form) { +//! Ok(address) => println!("{}", address.localidade), +//! Err(cepis::Error::NotFound { .. }) => {} // ask the user for another CEP +//! Err(err) if err.is_retriable() => {} // network, server, rate limit +//! Err(err) => eprintln!("{err}"), +//! } +//! ``` +//! +//! 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. +//! +//! The free functions share one default client, built on first use. It keeps no +//! state between calls and is safe to share across threads. Anyone needing +//! different settings should build their own [`Client`]. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +mod client; +mod csv; +mod error; +mod model; +mod retry; + +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}; + +/// The version of this library, as sent in the `User-Agent`. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +use std::sync::OnceLock; + +fn default_client() -> &'static Client { + static DEFAULT: OnceLock<Client> = OnceLock::new(); + DEFAULT.get_or_init(Client::new) +} + +/// Look up a CEP using the default configuration. +/// +/// Shorthand for `Client::new().lookup(cep)`; see [`Client::lookup`]. +pub fn lookup(cep: &str) -> Result<Address> { + default_client().lookup(cep) +} + +/// Look up many CEPs in one batch, using the default configuration. +/// +/// Shorthand for [`Client::lookup_many`] — read that one, especially the +/// warning that batches carry no coordinate. +pub fn lookup_many(ceps: &[impl AsRef<str>]) -> Result<Vec<Option<Address>>> { + default_client().lookup_many(ceps) +} + +/// Query the API's health using the default configuration. +/// +/// Shorthand for [`Client::health`]. +pub fn health() -> Result<Health> { + default_client().health() +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..59cacbe --- /dev/null +++ b/src/model.rs @@ -0,0 +1,227 @@ +//! The data the API returns, as types. +//! +//! **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`, +//! `localidade`. Everything else in this crate is English; the fields are the +//! one deliberate exception. +//! +//! The reason is fidelity: what you read in the JSON at <https://cep.is/01001000> +//! is exactly what you access on the value. Translating to `street`/`city` +//! would mean maintaining a field-by-field mapping and forcing anyone reading +//! the API docs alongside this crate to translate back in their head. The +//! values are Portuguese either way — they come from the Correios database. + +use serde_json::Value; + +/// The CEP's coordinate, when the database has one. +/// +/// The point is the **median** of the coordinates of that CEP's addresses, not +/// the mean: one badly geocoded address 40 km away shifts a mean and does not +/// move a median. +/// +/// Always check [`precisao`](Geo::precisao) before using `lat`/`lon` for +/// distance: +/// +/// - `"logradouro"`: the point is a stretch of street. `raio_m` lands around +/// 100–150 m. Good for maps and for distance. +/// - `"localidade"`: the CEP *is* the whole municipality (a city-wide general +/// CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km. +/// Computing shipping from this is wrong by tens of kilometres. +/// +/// `raio_m` is the radius containing 90% of that CEP's addresses. It exists +/// precisely so that error cannot pass unnoticed. +#[derive(Debug, Clone, PartialEq)] +pub struct Geo { + /// Latitude in decimal degrees, WGS 84. + pub lat: f64, + /// Longitude in decimal degrees, WGS 84. + pub lon: f64, + /// Radius, in metres, containing 90% of the CEP's addresses. + pub raio_m: i64, + /// `"logradouro"` (a street) or `"localidade"` (a municipality centroid). + pub precisao: String, +} + +impl Geo { + /// `true` when the coordinate points at a street, not a municipality. + /// + /// Shorthand for `precisao == "logradouro"`, which is the check to make + /// before using the point to measure distance. + pub fn is_precise(&self) -> bool { + self.precisao == "logradouro" + } + + fn from_json(value: &Value) -> Option<Geo> { + let object = value.as_object()?; + Some(Geo { + lat: number(object.get("lat")?)?, + lon: number(object.get("lon")?)?, + raio_m: number(object.get("raio_m")?)? as i64, + precisao: match object.get("precisao")? { + Value::String(s) => s.clone(), + _ => return None, + }, + }) + } +} + +/// A CEP and everything the database knows about it. +/// +/// Fields the database does not have for that CEP come back as empty strings, +/// never absent — that is what the API returns, and changing it here would +/// create a difference between reading the JSON and reading this crate. +/// +/// [`geo`](Address::geo) is the exception: it is `None` when there is no +/// coordinate. There the difference is real, because the API **omits** the +/// whole field in that case (about 43% of CEPs) rather than sending null. +/// +/// [`raw`](Address::raw) holds the response exactly as it arrived, +/// uninterpreted, so a field the API grows before this crate is updated is not +/// lost. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Address { + /// The CEP, always unmasked: `"01001000"`. + pub cep: String, + /// Street, avenue, square — the public way. + pub logradouro: String, + /// Range or side of the street the CEP covers, when it covers only part. + pub complemento: String, + /// Name of the large user or building the CEP belongs to, when it does. + pub unidade: String, + /// Neighbourhood. + pub bairro: String, + /// Municipality. + pub localidade: String, + /// Federative unit, two letters: `"SP"`. + pub uf: String, + /// Federative unit spelled out: `"São Paulo"`. + 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, + /// GIA code, São Paulo state tax administration only. + pub gia: String, + /// Area code for telephony: `"11"`. + pub ddd: String, + /// SIAFI municipality code, federal administration. + pub siafi: String, + /// The coordinate, or `None` when the database has none for this CEP. + /// + /// Always `None` for an address that came from + /// [`lookup_many`](crate::Client::lookup_many) — batches carry no + /// coordinate at all. + pub geo: Option<Geo>, + /// The response exactly as it arrived, so a new API field is never lost. + pub raw: Value, +} + +impl Address { + /// The CEP with its mask: `01001-000`. + /// + /// The native contract always returns it unmasked; formatting is + /// presentation, and it lives here so it does not become a repeated + /// `format!` in every project that uses this. + pub fn formatted_cep(&self) -> String { + if self.cep.len() == 8 { + format!("{}-{}", &self.cep[..5], &self.cep[5..]) + } else { + self.cep.clone() + } + } + + /// Build from the JSON object the API returned. + /// + /// Unknown fields are ignored during construction but preserved in + /// [`raw`](Address::raw). A new field in the API therefore cannot break + /// this crate. + /// + /// A malformed `geo` block yields `geo: None` rather than failing the whole + /// value: it is not worth losing the address over, since the rest of the + /// response is still useful. + pub fn from_json(value: Value) -> Address { + let geo = value.get("geo").and_then(Geo::from_json); + Address { + cep: text(&value, "cep"), + logradouro: text(&value, "logradouro"), + complemento: text(&value, "complemento"), + unidade: text(&value, "unidade"), + bairro: text(&value, "bairro"), + localidade: text(&value, "localidade"), + uf: text(&value, "uf"), + estado: text(&value, "estado"), + regiao: text(&value, "regiao"), + ibge: text(&value, "ibge"), + gia: text(&value, "gia"), + ddd: text(&value, "ddd"), + siafi: text(&value, "siafi"), + geo, + raw: value, + } + } +} + +/// The response of `GET /health`. +/// +/// Good for two things: checking that the API answers, and knowing **which +/// version of the database** is live. The DNE database changes every two weeks; +/// [`dne`](Health::dne) identifies it. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Health { + /// `"ok"` when the API considers itself healthy. + pub status: String, + /// Identifier of the fortnightly DNE database currently loaded. + pub dne: String, + /// How many CEPs the database holds. + pub ceps: u64, + /// How many of those carry a coordinate. + pub geo: u64, + /// When the database was generated, RFC 3339 in UTC. + pub base_gerada_em: String, + /// The response exactly as it arrived. + pub raw: Value, +} + +impl Health { + /// `true` when the API reports itself healthy. + pub fn ok(&self) -> bool { + self.status == "ok" + } + + /// Build from the JSON object the API returned. + pub fn from_json(value: Value) -> Health { + Health { + status: text(&value, "status"), + dne: text(&value, "dne"), + ceps: count(&value, "ceps"), + geo: count(&value, "geo"), + base_gerada_em: text(&value, "base_gerada_em"), + raw: value, + } + } +} + +fn text(value: &Value, key: &str) -> String { + match value.get(key) { + Some(Value::String(s)) => s.clone(), + Some(Value::Null) | None => String::new(), + Some(other) => other.to_string(), + } +} + +fn count(value: &Value, key: &str) -> u64 { + match value.get(key) { + Some(Value::Number(n)) => n.as_u64().unwrap_or(0), + Some(Value::String(s)) => s.parse().unwrap_or(0), + _ => 0, + } +} + +fn number(value: &Value) -> Option<f64> { + match value { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.parse().ok(), + _ => None, + } +} diff --git a/src/retry.rs b/src/retry.rs new file mode 100644 index 0000000..3e69ff0 --- /dev/null +++ b/src/retry.rs @@ -0,0 +1,143 @@ +//! Backoff arithmetic and `Retry-After` parsing. + +use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hasher}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// A random `Duration` in `[0, ceiling]` — *full jitter*. +/// +/// The randomness is not decoration: without it, N clients that fail together +/// come back together and reproduce the spike that took the service down. +/// +/// The source is the standard library's hash seed mixed with the current time, +/// which is plenty for spreading retries and is not suitable for anything +/// cryptographic. +pub(crate) fn jitter(ceiling: Duration) -> Duration { + let mut hasher = RandomState::new().build_hasher(); + hasher.write_u128( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + ); + let fraction = (hasher.finish() >> 11) as f64 / (1u64 << 53) as f64; + ceiling.mul_f64(fraction) +} + +/// Parse the `Retry-After` header in both RFC 9110 forms. +/// +/// It may arrive as a number of seconds (`"120"`) or as an HTTP date +/// (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the common +/// bug, and it makes a client ignore exactly the wait request that matters +/// most. A date already in the past yields `Duration::ZERO`; anything +/// unparseable yields `None`. +pub(crate) fn parse_retry_after(value: &str) -> Option<Duration> { + let value = value.trim(); + if value.is_empty() { + return None; + } + + if let Ok(seconds) = value.parse::<f64>() { + if seconds.is_finite() { + return Some(Duration::try_from_secs_f64(seconds.max(0.0)).unwrap_or(Duration::ZERO)); + } + } + + let target = http_date_to_unix(value)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + Some(Duration::from_secs((target - now).max(0) as u64)) +} + +/// Seconds since the Unix epoch for an IMF-fixdate, the form HTTP servers must +/// send: `Wed, 21 Oct 2026 07:28:00 GMT`. The two obsolete formats RFC 9110 +/// still mentions are not accepted. +fn http_date_to_unix(value: &str) -> Option<i64> { + let rest = value.split_once(", ").map(|(_, r)| r).unwrap_or(value); + let mut parts = rest.split_whitespace(); + + let day: i64 = parts.next()?.parse().ok()?; + let month = month_number(parts.next()?)?; + let year: i64 = parts.next()?.parse().ok()?; + + let mut clock = parts.next()?.split(':'); + let hour: i64 = clock.next()?.parse().ok()?; + let minute: i64 = clock.next()?.parse().ok()?; + let second: i64 = clock.next()?.parse().ok()?; + + if !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 { + return None; + } + + Some(days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second) +} + +fn month_number(name: &str) -> Option<i64> { + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + MONTHS.iter().position(|m| *m == name).map(|i| i as i64 + 1) +} + +/// Days between 1970-01-01 and the given proleptic Gregorian date. +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let year = if month <= 2 { year - 1 } else { year }; + let era = if year >= 0 { year } else { year - 399 } / 400; + let year_of_era = year - era * 400; + let month_shifted = (month + 9) % 12; + let day_of_year = (153 * month_shifted + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +#[cfg(test)] +mod tests { + use super::{days_from_civil, http_date_to_unix, jitter, parse_retry_after}; + use std::time::Duration; + + #[test] + fn reads_plain_seconds() { + assert_eq!(parse_retry_after(" 7 "), Some(Duration::from_secs(7))); + } + + #[test] + fn negative_seconds_become_zero() { + assert_eq!(parse_retry_after("-5"), Some(Duration::ZERO)); + } + + #[test] + fn reads_an_http_date() { + let epoch = http_date_to_unix("Wed, 21 Oct 2026 07:28:00 GMT").unwrap(); + assert_eq!(epoch, 1_792_567_680); + } + + #[test] + fn a_date_in_the_past_is_zero() { + assert_eq!( + parse_retry_after("Thu, 01 Jan 1970 00:00:00 GMT"), + Some(Duration::ZERO) + ); + } + + #[test] + fn rejects_nonsense() { + assert_eq!(parse_retry_after("soon"), None); + assert_eq!(parse_retry_after(""), None); + } + + #[test] + fn civil_days_match_known_dates() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + assert_eq!(days_from_civil(2000, 3, 1), 11_017); + } + + #[test] + fn jitter_stays_within_the_ceiling() { + let ceiling = Duration::from_millis(50); + for _ in 0..100 { + assert!(jitter(ceiling) <= ceiling); + } + } +} |