diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3469e05..abb8481 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,12 @@ jobs: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - run: cargo fmt --check + + - run: cargo clippy --all-targets -- -D warnings - run: cargo test diff --git a/README.md b/README.md index b4eeedb..97fa449 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ The main response type is `IpInfo` which includes: - `days_seen`: number of days the IP has been seen on the list - **Languages**: `languages` (vec of `Language` with `name`, `native`, `code`) +All response structs ignore unknown keys and decode a missing key and an explicit `null` alike: `Option` fields read `None`, every other field reads its default (`""`, `false`, `0`, `0.0` or an empty list), so a `null` anywhere in a response does not fail the lookup. + ## License MIT diff --git a/src/client.rs b/src/client.rs index 3a8e87f..7f23a85 100644 --- a/src/client.rs +++ b/src/client.rs @@ -157,54 +157,42 @@ impl IpData { pub async fn lookup_field(&self, ip: &str, field: &str) -> Result { validate_ip(ip)?; let url = format!("{}/{}/{}", self.base_url, ip, field); - let resp = self - .client - .get(&url) - .query(&[("api-key", &self.api_key)]) - .send() - .await?; - - let status = resp.status(); - if !status.is_success() { - return Err(parse_error(status.as_u16(), resp).await); - } - - Ok(resp.json().await?) + self.get(&url, &[]).await } /// Returns ASN (Autonomous System Number) data for an IP address. pub async fn asn(&self, ip: &str) -> Result { validate_ip(ip)?; let url = format!("{}/{}/asn", self.base_url, ip); - self.get_typed(&url).await + self.get(&url, &[]).await } /// Returns mobile carrier data for an IP address. pub async fn carrier(&self, ip: &str) -> Result { validate_ip(ip)?; let url = format!("{}/{}/carrier", self.base_url, ip); - self.get_typed(&url).await + self.get(&url, &[]).await } /// Returns currency data for an IP address. pub async fn currency(&self, ip: &str) -> Result { validate_ip(ip)?; let url = format!("{}/{}/currency", self.base_url, ip); - self.get_typed(&url).await + self.get(&url, &[]).await } /// Returns time zone data for an IP address. pub async fn time_zone(&self, ip: &str) -> Result { validate_ip(ip)?; let url = format!("{}/{}/time_zone", self.base_url, ip); - self.get_typed(&url).await + self.get(&url, &[]).await } /// Returns threat intelligence data for an IP address. pub async fn threat(&self, ip: &str) -> Result { validate_ip(ip)?; let url = format!("{}/{}/threat", self.base_url, ip); - self.get_typed(&url).await + self.get(&url, &[]).await } /// Performs a bulk lookup for up to 100 IP addresses. @@ -240,12 +228,7 @@ impl IpData { Err(e) => return Err(e.into()), }; - let status = resp.status(); - if !status.is_success() { - return Err(parse_error(status.as_u16(), resp).await); - } - - let bulk: BulkResponse = resp.json().await?; + let bulk: BulkResponse = decode_response(resp).await?; Ok(bulk.responses) } @@ -259,25 +242,10 @@ impl IpData { .await } - /// Internal typed GET helper for sub-resource endpoints. - async fn get_typed(&self, url: &str) -> Result { - let resp = self - .client - .get(url) - .query(&[("api-key", &self.api_key)]) - .send() - .await?; - - let status = resp.status(); - if !status.is_success() { - return Err(parse_error(status.as_u16(), resp).await); - } - - Ok(resp.json().await?) - } - - /// Internal GET helper for IP lookups with optional field filtering. - async fn get(&self, url: &str, fields: &[&str]) -> Result { + /// Internal GET helper shared by the full lookups, the raw single-field lookup and + /// the typed sub-resources: sends the API key and, when `fields` is not empty, a + /// comma-separated `fields` filter, then decodes the JSON body into `T`. + async fn get(&self, url: &str, fields: &[&str]) -> Result { let mut req = self.client.get(url).query(&[("api-key", &self.api_key)]); if !fields.is_empty() { @@ -285,13 +253,7 @@ impl IpData { req = req.query(&[("fields", &fields_str)]); } - let resp = req.send().await?; - let status = resp.status(); - if !status.is_success() { - return Err(parse_error(status.as_u16(), resp).await); - } - - Ok(resp.json().await?) + decode_response(req.send().await?).await } } @@ -335,6 +297,17 @@ fn validate_ip(ip: &str) -> Result<()> { .map_err(|_| Error::InvalidIp(ip.to_string())) } +/// Decodes the JSON body of a successful response into `T`. A non-success status +/// becomes [`Error::Api`] with the message from the response body. +async fn decode_response(resp: reqwest::Response) -> Result { + let status = resp.status(); + if !status.is_success() { + return Err(parse_error(status.as_u16(), resp).await); + } + + Ok(resp.json().await?) +} + /// Parses an error response from the API. async fn parse_error(status: u16, resp: reqwest::Response) -> Error { let message = match resp.json::().await { diff --git a/src/lib.rs b/src/lib.rs index 3d9602b..d0db0b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,13 @@ //! # Ok(()) //! # } //! ``` +//! +//! ## Response types +//! +//! [`IpInfo`] and the nested types ([`Asn`], [`Threat`], [`TimeZone`] and the others) +//! ignore unknown keys and decode a missing key and an explicit JSON `null` the same +//! way: `Option` fields read `None`, every other field reads its default (`""`, `false`, +//! `0`, `0.0` or an empty list). A `null` anywhere in a response never fails a lookup. mod client; mod error; diff --git a/src/types.rs b/src/types.rs index c0b1e4b..1c63fce 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,28 +1,33 @@ use serde::{Deserialize, Serialize}; /// Full IP geolocation and threat intelligence response. +/// +/// Unknown keys are ignored. A missing key and an explicit JSON `null` decode the same +/// way: `Option` fields read `None`, every other field reads its default (`""`, +/// `false`, `0.0` or an empty list). The nested types follow the same rule, so a +/// `null` anywhere in a response never fails a lookup. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct IpInfo { /// The queried IP address. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub ip: String, /// Whether the IP is in an EU member country. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_eu: bool, /// Whether the IP belongs to a satellite internet provider. /// /// The API sends this key only when it is true; the field reads `false` when the /// key is absent. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_satellite: bool, /// Whether the IP is an anycast address, announced from several locations at once. /// /// The API sends this key only when it is true; the field reads `false` when the /// key is absent. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_anycast: bool, /// City name. @@ -58,11 +63,11 @@ pub struct IpInfo { pub continent_code: Option, /// Latitude. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub latitude: f64, /// Longitude. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub longitude: f64, /// Postal / ZIP code. @@ -102,7 +107,7 @@ pub struct IpInfo { pub carrier: Option, /// Languages spoken in the country. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub languages: Vec, /// Local currency information. @@ -134,23 +139,23 @@ pub struct IpInfo { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Asn { /// ASN string (e.g. "AS15169"). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub asn: String, /// Organization name. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub name: String, /// Organization domain. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub domain: String, /// Route prefix (e.g. "8.8.8.0/24"). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub route: String, /// ASN type: "isp", "business", "education", "hosting", or "inactive". - #[serde(default, rename = "type")] + #[serde(default, rename = "type", deserialize_with = "null_as_default")] pub asn_type: String, } @@ -158,19 +163,19 @@ pub struct Asn { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Company { /// Company name. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub name: String, /// Company domain. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub domain: String, /// Network CIDR. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub network: String, /// Type: "isp", "business", "education", or "hosting". - #[serde(default, rename = "type")] + #[serde(default, rename = "type", deserialize_with = "null_as_default")] pub company_type: String, } @@ -178,15 +183,15 @@ pub struct Company { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Carrier { /// Carrier name. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub name: String, /// Mobile Country Code. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub mcc: String, /// Mobile Network Code. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub mnc: String, } @@ -194,11 +199,11 @@ pub struct Carrier { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Language { /// Language name in English. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub name: String, /// Language name in its native form. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub native: String, /// ISO 639-1 language code. @@ -210,23 +215,23 @@ pub struct Language { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Currency { /// Currency name (e.g. "US Dollar"). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub name: String, /// ISO 4217 currency code (e.g. "USD"). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub code: String, /// Currency symbol (e.g. "$"). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub symbol: String, /// Native currency symbol. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub native: String, /// Plural form of the currency name. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub plural: String, } @@ -258,60 +263,51 @@ pub struct TimeZone { pub current_time: String, } -/// Deserializes an explicit JSON `null` as the field's default value. -fn null_as_default<'de, D, T>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, - T: Default + Deserialize<'de>, -{ - Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) -} - /// Threat intelligence data for an IP address. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Threat { /// Whether the IP is a known Tor exit node. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_tor: bool, /// Whether the IP belongs to a known VPN provider. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_vpn: bool, /// Whether the IP is an iCloud Private Relay address. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_icloud_relay: bool, /// Whether the IP is a known proxy. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_proxy: bool, /// Whether the IP belongs to a data center. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_datacenter: bool, /// Whether the IP is anonymous (Tor, proxy, or VPN). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_anonymous: bool, /// Whether the IP is a known attacker. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_known_attacker: bool, /// Whether the IP is a known abuser. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_known_abuser: bool, /// Whether the IP is listed in any threat feed. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_threat: bool, /// Whether the IP is a bogon (unallocated/reserved). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_bogon: bool, /// Threat feeds that list this IP. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub blocklists: Vec, /// Machine-learning-based threat scores. @@ -325,19 +321,19 @@ pub struct Threat { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ThreatScores { /// VPN likelihood score. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub vpn_score: i32, /// Proxy likelihood score. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub proxy_score: i32, /// Overall threat score. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub threat_score: i32, /// Trust / reputation score. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub trust_score: i32, } @@ -345,15 +341,15 @@ pub struct ThreatScores { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Blocklist { /// Blocklist name. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub name: String, /// Blocklist website URL. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub site: String, /// Blocklist category / type. - #[serde(default, rename = "type")] + #[serde(default, rename = "type", deserialize_with = "null_as_default")] pub blocklist_type: String, /// First day the IP was seen on this blocklist, as a `YYYY-MM-DD` date (UTC). @@ -381,3 +377,17 @@ pub struct Blocklist { pub(crate) struct BulkResponse { pub responses: Vec, } + +/// Deserializes an explicit JSON `null` as the field's default value. +/// +/// Used together with `#[serde(default)]` on the public fields that are not `Option`s: +/// `default` covers a missing key and this function covers a key sent as `null`, so +/// neither fails the whole response. Any other value is decoded as usual, and a value +/// of the wrong type is still an error. +fn null_as_default<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} diff --git a/tests/client.rs b/tests/client.rs index 9cb26c7..d1f32f3 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -164,6 +164,31 @@ async fn lookup_field_returns_the_raw_json_value() { assert_eq!(is_eu, Value::Bool(false)); } +#[tokio::test] +async fn lookup_field_maps_api_errors_like_the_typed_lookups() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/8.8.8.8/asn")) + .and(query_param("api-key", API_KEY)) + .respond_with( + ResponseTemplate::new(403).set_body_json(json!({ "message": "quota exceeded" })), + ) + .expect(1) + .mount(&server) + .await; + + let err = client_for(&server) + .lookup_field("8.8.8.8", "asn") + .await + .unwrap_err(); + + assert!(matches!( + &err, + Error::Api { status: 403, message } if message == "quota exceeded" + )); + assert_eq!(err.status(), Some(403)); +} + #[tokio::test] async fn asn_decodes_the_typed_sub_resource() { let server = MockServer::start().await; @@ -282,6 +307,52 @@ async fn threat_decodes_the_typed_sub_resource() { assert_eq!(scores.trust_score, 0); } +#[tokio::test] +async fn sub_resources_answered_with_nulls_decode_to_defaults() { + let server = MockServer::start().await; + mock_get( + &server, + "/8.8.8.8/carrier", + json!({ "name": null, "mcc": null, "mnc": null }), + ) + .await; + mock_get( + &server, + "/8.8.8.8/asn", + json!({ + "asn": "AS15169", + "name": "Google LLC", + "domain": null, + "route": null, + "type": null + }), + ) + .await; + mock_get( + &server, + "/8.8.8.8/time_zone", + json!({ "name": null, "abbr": null, "offset": null, "is_dst": null, "current_time": null }), + ) + .await; + let client = client_for(&server); + + let carrier = client.carrier("8.8.8.8").await.unwrap(); + assert_eq!(carrier.name, ""); + assert_eq!(carrier.mcc, ""); + assert_eq!(carrier.mnc, ""); + + let asn = client.asn("8.8.8.8").await.unwrap(); + assert_eq!(asn.asn, "AS15169"); + assert_eq!(asn.name, "Google LLC"); + assert_eq!(asn.domain, ""); + assert_eq!(asn.route, ""); + assert_eq!(asn.asn_type, ""); + + let tz = client.time_zone("8.8.8.8").await.unwrap(); + assert_eq!(tz.name, ""); + assert!(!tz.is_dst); +} + #[tokio::test] async fn bulk_posts_the_ip_list_and_returns_results_in_order() { let server = MockServer::start().await; diff --git a/tests/types.rs b/tests/types.rs index 5f3457c..c5298af 100644 --- a/tests/types.rs +++ b/tests/types.rs @@ -1,8 +1,11 @@ //! Decoding tests for the response types, built from API samples in `tests/fixtures`: //! a satellite ISP (`is_satellite`), an anycast address with dated blocklist entries -//! (`is_anycast`) and a VPN exit whose blocklist includes an entry without dates. +//! (`is_anycast`) and a VPN exit whose blocklist includes an entry without dates; plus +//! hand-written documents that put an explicit `null` in every kind of field. -use ipdata::{Blocklist, IpInfo, Threat, TimeZone}; +use ipdata::{ + Asn, Blocklist, Carrier, Company, Currency, IpInfo, Language, Threat, ThreatScores, TimeZone, +}; use serde_json::{json, Value}; const SATELLITE: &str = include_str!("fixtures/satellite.json"); @@ -164,3 +167,193 @@ fn new_fields_round_trip_through_serialization() { let again: IpInfo = serde_json::from_value(value).unwrap(); assert!(again.is_anycast); } + +// --- Explicit nulls --------------------------------------------------------------- + +#[test] +fn every_typed_model_decodes_explicit_nulls_to_defaults() { + let asn: Asn = serde_json::from_value(json!({ + "asn": null, "name": null, "domain": null, "route": null, "type": null + })) + .unwrap(); + assert_eq!(asn.asn, ""); + assert_eq!(asn.name, ""); + assert_eq!(asn.domain, ""); + assert_eq!(asn.route, ""); + assert_eq!(asn.asn_type, ""); + + let company: Company = serde_json::from_value(json!({ + "name": null, "domain": null, "network": null, "type": null + })) + .unwrap(); + assert_eq!(company.name, ""); + assert_eq!(company.domain, ""); + assert_eq!(company.network, ""); + assert_eq!(company.company_type, ""); + + let carrier: Carrier = + serde_json::from_value(json!({ "name": null, "mcc": null, "mnc": null })).unwrap(); + assert_eq!(carrier.name, ""); + assert_eq!(carrier.mcc, ""); + assert_eq!(carrier.mnc, ""); + + let language: Language = + serde_json::from_value(json!({ "name": null, "native": null, "code": null })).unwrap(); + assert_eq!(language.name, ""); + assert_eq!(language.native, ""); + assert_eq!(language.code, None); + + let currency: Currency = serde_json::from_value(json!({ + "name": null, "code": null, "symbol": null, "native": null, "plural": null + })) + .unwrap(); + assert_eq!(currency.name, ""); + assert_eq!(currency.code, ""); + assert_eq!(currency.symbol, ""); + assert_eq!(currency.native, ""); + assert_eq!(currency.plural, ""); + + let scores: ThreatScores = serde_json::from_value(json!({ + "vpn_score": null, "proxy_score": null, "threat_score": null, "trust_score": null + })) + .unwrap(); + assert_eq!(scores.vpn_score, 0); + assert_eq!(scores.proxy_score, 0); + assert_eq!(scores.threat_score, 0); + assert_eq!(scores.trust_score, 0); + + let entry: Blocklist = serde_json::from_value(json!({ + "name": null, "site": null, "type": null, + "first_seen": null, "last_seen": null, "days_seen": null + })) + .unwrap(); + assert_eq!(entry.name, ""); + assert_eq!(entry.site, ""); + assert_eq!(entry.blocklist_type, ""); + assert_eq!(entry.first_seen, None); + assert_eq!(entry.last_seen, None); + assert_eq!(entry.days_seen, None); +} + +#[test] +fn threat_decodes_null_flags_lists_and_scores_to_defaults() { + let threat: Threat = serde_json::from_value(json!({ + "is_tor": null, "is_vpn": null, "is_icloud_relay": null, "is_proxy": null, + "is_datacenter": null, "is_anonymous": null, "is_known_attacker": null, + "is_known_abuser": null, "is_threat": null, "is_bogon": null, + "blocklists": null, "scores": null + })) + .unwrap(); + assert!(!threat.is_tor); + assert!(!threat.is_vpn); + assert!(!threat.is_icloud_relay); + assert!(!threat.is_proxy); + assert!(!threat.is_datacenter); + assert!(!threat.is_anonymous); + assert!(!threat.is_known_attacker); + assert!(!threat.is_known_abuser); + assert!(!threat.is_threat); + assert!(!threat.is_bogon); + assert!(threat.blocklists.is_empty()); + assert!(threat.scores.is_none()); + + // A null inside an otherwise populated object only affects that one field. + let threat: Threat = serde_json::from_value(json!({ + "is_vpn": true, + "blocklists": [{ "name": "Gluetun", "site": null, "type": "vpn" }], + "scores": { "vpn_score": 100, "trust_score": null } + })) + .unwrap(); + assert!(threat.is_vpn); + assert_eq!(threat.blocklists[0].name, "Gluetun"); + assert_eq!(threat.blocklists[0].site, ""); + let scores = threat.scores.unwrap(); + assert_eq!(scores.vpn_score, 100); + assert_eq!(scores.trust_score, 0); +} + +#[test] +fn ip_info_decodes_null_scalars_lists_and_objects_to_defaults() { + let info: IpInfo = serde_json::from_value(json!({ + "ip": null, "is_eu": null, "is_satellite": null, "is_anycast": null, + "latitude": null, "longitude": null, "languages": null, + "city": null, "region": null, "country_name": null, "postal": null, + "asn": null, "company": null, "carrier": null, "currency": null, + "time_zone": null, "threat": null, "count": null, "status": null, "message": null + })) + .unwrap(); + + assert_eq!(info.ip, ""); + assert!(!info.is_eu); + assert!(!info.is_satellite); + assert!(!info.is_anycast); + assert_eq!(info.latitude, 0.0); + assert_eq!(info.longitude, 0.0); + assert!(info.languages.is_empty()); + assert_eq!(info.city, None); + assert_eq!(info.region, None); + assert_eq!(info.country_name, None); + assert_eq!(info.postal, None); + assert!(info.asn.is_none()); + assert!(info.company.is_none()); + assert!(info.carrier.is_none()); + assert!(info.currency.is_none()); + assert!(info.time_zone.is_none()); + assert!(info.threat.is_none()); + assert_eq!(info.count, None); + assert_eq!(info.status, None); + assert_eq!(info.message, None); +} + +#[test] +fn nulls_scattered_through_a_full_response_leave_the_other_values_intact() { + let mut sample: Value = serde_json::from_str(ANYCAST).unwrap(); + sample["latitude"] = Value::Null; + sample["asn"]["domain"] = Value::Null; + sample["company"]["network"] = Value::Null; + sample["languages"][0]["native"] = Value::Null; + sample["currency"]["symbol"] = Value::Null; + sample["threat"]["is_datacenter"] = Value::Null; + sample["threat"]["blocklists"][0]["site"] = Value::Null; + sample["threat"]["scores"]["trust_score"] = Value::Null; + + let info: IpInfo = serde_json::from_value(sample).unwrap(); + + assert_eq!(info.ip, "8.8.8.8"); + assert!(info.is_anycast); + assert_eq!(info.latitude, 0.0); + assert!((info.longitude + 98.5).abs() < 0.01); + let asn = info.asn.unwrap(); + assert_eq!(asn.name, "Google LLC"); + assert_eq!(asn.domain, ""); + assert_eq!(asn.route, "8.8.8.0/24"); + let company = info.company.unwrap(); + assert_eq!(company.name, "Google LLC"); + assert_eq!(company.network, ""); + assert_eq!(info.languages[0].name, "English"); + assert_eq!(info.languages[0].native, ""); + assert_eq!(info.languages[0].code.as_deref(), Some("en")); + let currency = info.currency.unwrap(); + assert_eq!(currency.code, "USD"); + assert_eq!(currency.symbol, ""); + let threat = info.threat.unwrap(); + assert!(!threat.is_datacenter); + assert_eq!(threat.blocklists.len(), 2); + assert_eq!(threat.blocklists[0].name, "ipdata"); + assert_eq!(threat.blocklists[0].site, ""); + assert_eq!(threat.blocklists[0].days_seen, Some(1)); + let scores = threat.scores.unwrap(); + assert_eq!(scores.vpn_score, 100); + assert_eq!(scores.trust_score, 0); +} + +#[test] +fn null_tolerance_still_rejects_values_of_the_wrong_type() { + assert!(serde_json::from_value::(json!({ "latitude": "north" })).is_err()); + assert!(serde_json::from_value::(json!({ "is_eu": "yes" })).is_err()); + assert!(serde_json::from_value::(json!({ "languages": {} })).is_err()); + assert!(serde_json::from_value::(json!({ "asn": 15169 })).is_err()); + assert!(serde_json::from_value::(json!({ "is_tor": 1 })).is_err()); + assert!(serde_json::from_value::(json!({ "vpn_score": "100" })).is_err()); + assert!(serde_json::from_value::(json!({ "is_dst": "true" })).is_err()); +}