From dd8b74b06aa9622379f73c1c368c9bf5a87d0e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:47:51 +0000 Subject: [PATCH 1/3] Decode explicit JSON nulls as defaults in every response type Only TimeZone tolerated a key sent as null. Every other non-Option field of IpInfo, Asn, Company, Carrier, Language, Currency, Threat, ThreatScores and Blocklist failed the whole response with a decode error, because #[serde(default)] only covers a missing key. Apply the existing null_as_default deserializer to all of them, so a null reads as "", false, 0, 0.0 or an empty list. Field types are unchanged and values of the wrong type are still rejected. Document the rule in the crate docs and the README, and add decoding tests for every model plus a client-level sub-resource test. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 + src/lib.rs | 7 ++ src/types.rs | 116 +++++++++++++++------------- tests/client.rs | 46 +++++++++++ tests/types.rs | 197 +++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 313 insertions(+), 55 deletions(-) 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/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..f0baa5e 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -282,6 +282,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()); +} From 2f08361eec77f1de0a5a9d868ce40d3e39a26d71 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:48:47 +0000 Subject: [PATCH 2/3] Route every GET through one helper in the client lookup_field carried its own copy of the request, status check and body decoding that get_typed already had, and get and bulk repeated the same status check and decoding a third and fourth time. Make get generic over the decoded type and use it for the full lookups, the raw single-field lookup and the typed sub-resources; move the status check and JSON decoding into decode_response, which bulk uses as well. Behaviour is unchanged: same query parameters, same Error::Api mapping. A test pins lookup_field's error mapping. Co-Authored-By: Claude Fable 5.1 --- src/client.rs | 73 ++++++++++++++++--------------------------------- tests/client.rs | 25 +++++++++++++++++ 2 files changed, 48 insertions(+), 50 deletions(-) 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/tests/client.rs b/tests/client.rs index f0baa5e..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; From cb7ef27edc24ef6fc725afb16c6cb7403fa8a9de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:48:48 +0000 Subject: [PATCH 3/3] Run rustfmt and clippy in the publish workflow publish.yml ran cargo test and cargo publish on a release without the formatting and lint gates that ci.yml applies to pull requests and pushes to main, so a release could ship code that CI would reject. Add the rustfmt and clippy components to the toolchain step and run cargo fmt --check and cargo clippy --all-targets -- -D warnings before cargo test, mirroring ci.yml. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/publish.yml | 6 ++++++ 1 file changed, 6 insertions(+) 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