summaryrefslogtreecommitdiffstats
path: root/src/model.rs
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:17:46 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:17:46 -0300
commit4a8c3b675df4b1a6b29904fc2af60605002a7f9e (patch)
tree5c3dcc6594603d242b6a284c1601fd1ffbfcc8f4 /src/model.rs
downloadrust-4a8c3b675df4b1a6b29904fc2af60605002a7f9e.tar.gz
Initial importv0.1.0
Diffstat (limited to 'src/model.rs')
-rw-r--r--src/model.rs227
1 files changed, 227 insertions, 0 deletions
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,
+ }
+}