diff options
Diffstat (limited to 'src/model.rs')
| -rw-r--r-- | src/model.rs | 227 |
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, + } +} |