diff --git a/src/api.rs b/src/api.rs index 32e644c..a01caf2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -326,3 +326,134 @@ pub struct IpDetailsCore { #[serde(flatten)] pub extra: HashMap, } + +/// Plus API Geo details (extends Core). +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct PlusGeo { + pub city: Option, + pub region: Option, + pub region_code: Option, + pub country: Option, + pub country_code: Option, + pub continent: Option, + pub continent_code: Option, + pub latitude: f64, + pub longitude: f64, + pub timezone: Option, + pub postal_code: Option, + pub dma_code: Option, + pub geoname_id: Option, + pub radius: Option, + pub last_changed: Option, + + /// Enriched fields + #[serde(skip_deserializing)] + pub country_name: Option, + #[serde(skip_deserializing)] + pub is_eu: Option, + #[serde(skip_deserializing)] + pub country_flag: Option, + #[serde(skip_deserializing)] + pub country_flag_url: Option, + #[serde(skip_deserializing)] + pub country_currency: Option, + #[serde(skip_deserializing)] + pub continent_info: Option, +} + +/// Plus API AS details (extends Core). +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct PlusAS { + pub asn: String, + pub name: String, + pub domain: String, + #[serde(rename = "type")] + pub as_type: String, + pub last_changed: Option, +} + +/// Plus API Mobile details. +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct PlusMobile { + pub name: Option, + pub mcc: Option, + pub mnc: Option, +} + +/// Plus API Anonymous details. +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct PlusAnonymous { + pub is_proxy: bool, + pub is_relay: bool, + pub is_tor: bool, + pub is_vpn: bool, + pub name: Option, +} + +/// Plus API Abuse details (reuse existing AbuseDetails but with country_name). +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct PlusAbuse { + pub address: Option, + pub country: Option, + #[serde(skip_deserializing)] + pub country_name: Option, + pub email: Option, + pub name: Option, + pub network: Option, + pub phone: Option, +} + +/// Plus API Company details (reuse existing CompanyDetails). +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct PlusCompany { + pub name: Option, + pub domain: Option, + #[serde(rename = "type")] + pub company_type: Option, +} + +/// Plus API Privacy details (reuse existing PrivacyDetails). +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct PlusPrivacy { + pub vpn: bool, + pub proxy: bool, + pub tor: bool, + pub relay: bool, + pub hosting: bool, + pub service: Option, +} + +/// Plus API Domains details (reuse existing DomainsDetails). +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct PlusDomains { + pub ip: Option, + pub total: u64, + pub domains: Vec, +} + +/// Plus API IP address lookup details. +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct IpDetailsPlus { + pub ip: String, + pub hostname: Option, + pub geo: Option, + #[serde(rename = "as")] + pub asn: Option, + pub mobile: Option, + pub anonymous: Option, + pub is_anonymous: bool, + pub is_anycast: bool, + pub is_hosting: bool, + pub is_mobile: bool, + pub is_satellite: bool, + pub abuse: Option, + pub company: Option, + pub privacy: Option, + pub domains: Option, + + /// If the IP Address is Bogon + pub bogon: Option, + + #[serde(flatten)] + pub extra: HashMap, +} diff --git a/src/ipinfo_plus.rs b/src/ipinfo_plus.rs new file mode 100644 index 0000000..0eb1986 --- /dev/null +++ b/src/ipinfo_plus.rs @@ -0,0 +1,418 @@ +// Copyright 2019-2025 IPinfo library developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{collections::HashMap, num::NonZeroUsize, time::Duration}; + +use crate::{ + cache_key, is_bogon, Continent, CountryCurrency, CountryFlag, + IpDetailsPlus, IpError, CONTINENTS, COUNTRIES, CURRENCIES, EU, FLAGS, + VERSION, +}; + +use lru::LruCache; + +use reqwest::header::{ + HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE, USER_AGENT, +}; + +const COUNTRY_FLAG_URL: &str = + "https://cdn.ipinfo.io/static/images/countries-flags/"; +const BASE_URL: &str = "https://api.ipinfo.io/lookup"; +const BASE_URL_V6: &str = "https://v6.api.ipinfo.io/lookup"; + +/// IpInfoPlus structure configuration. +pub struct IpInfoPlusConfig { + /// IPinfo access token. + pub token: Option, + + /// The timeout of HTTP requests. (default: 3 seconds) + pub timeout: Duration, + + /// The size of the LRU cache. (default: 100 IPs) + pub cache_size: usize, + + // Default mapping of country codes to country names + pub defaut_countries: Option>, + + // Default list of EU countries + pub default_eu: Option>, + + // Default mapping of country codes to their respective flag emoji and unicode + pub default_flags: Option>, + + // Default mapping of currencies to their respective currency code and symbol + pub default_currencies: Option>, + + // Default mapping of country codes to their respective continent code and name + pub default_continents: Option>, +} + +impl Default for IpInfoPlusConfig { + fn default() -> Self { + Self { + token: None, + timeout: Duration::from_secs(3), + cache_size: 100, + defaut_countries: None, + default_eu: None, + default_flags: None, + default_currencies: None, + default_continents: None, + } + } +} + +/// IpInfoPlus requests context structure. +pub struct IpInfoPlus { + token: Option, + client: reqwest::Client, + cache: LruCache, + countries: HashMap, + eu: Vec, + country_flags: HashMap, + country_currencies: HashMap, + continents: HashMap, +} + +impl IpInfoPlus { + /// Construct a new IpInfoPlus structure. + /// + /// # Examples + /// + /// ``` + /// use ipinfo::IpInfoPlus; + /// + /// let ipinfo = IpInfoPlus::new(Default::default()).expect("should construct"); + /// ``` + pub fn new(config: IpInfoPlusConfig) -> Result { + let client = + reqwest::Client::builder().timeout(config.timeout).build()?; + + let mut ipinfo_obj = Self { + client, + token: config.token, + cache: LruCache::new( + NonZeroUsize::new(config.cache_size).unwrap(), + ), + countries: HashMap::new(), + eu: Vec::new(), + country_flags: HashMap::new(), + country_currencies: HashMap::new(), + continents: HashMap::new(), + }; + + if config.defaut_countries.is_none() { + ipinfo_obj.countries = COUNTRIES.clone(); + } else { + ipinfo_obj.countries = config.defaut_countries.unwrap(); + } + + if config.default_eu.is_none() { + ipinfo_obj.eu = EU.clone(); + } else { + ipinfo_obj.eu = config.default_eu.unwrap(); + } + + if config.default_flags.is_none() { + ipinfo_obj.country_flags = FLAGS.clone(); + } else { + ipinfo_obj.country_flags = config.default_flags.unwrap(); + } + + if config.default_currencies.is_none() { + ipinfo_obj.country_currencies = CURRENCIES.clone(); + } else { + ipinfo_obj.country_currencies = config.default_currencies.unwrap(); + } + + if config.default_continents.is_none() { + ipinfo_obj.continents = CONTINENTS.clone(); + } else { + ipinfo_obj.continents = config.default_continents.unwrap(); + } + + Ok(ipinfo_obj) + } + + /// looks up IpDetailsPlus for a single IP Address + /// + /// # Example + /// + /// ```no_run + /// use ipinfo::IpInfoPlus; + /// + /// #[tokio::main] + /// async fn main() { + /// let mut ipinfo = IpInfoPlus::new(Default::default()).expect("should construct"); + /// let res = ipinfo.lookup("8.8.8.8").await.expect("should run"); + /// } + /// ``` + pub async fn lookup( + &mut self, + ip: &str, + ) -> Result { + self._lookup(ip, BASE_URL).await + } + + /// looks up IPDetailsPlus of your own v4 IP + /// + /// # Example + /// + /// ```no_run + /// use ipinfo::IpInfoPlus; + /// + /// #[tokio::main] + /// async fn main() { + /// let mut ipinfo = IpInfoPlus::new(Default::default()).expect("should construct"); + /// let res = ipinfo.lookup_self_v4().await.expect("should run"); + /// } + /// ``` + pub async fn lookup_self_v4(&mut self) -> Result { + self._lookup("me", BASE_URL).await + } + + /// looks up IPDetailsPlus of your own v6 IP + /// + /// # Example + /// + /// ```no_run + /// use ipinfo::IpInfoPlus; + /// + /// #[tokio::main] + /// async fn main() { + /// let mut ipinfo = IpInfoPlus::new(Default::default()).expect("should construct"); + /// let res = ipinfo.lookup_self_v6().await.expect("should run"); + /// } + /// ``` + pub async fn lookup_self_v6(&mut self) -> Result { + self._lookup("me", BASE_URL_V6).await + } + + async fn _lookup( + &mut self, + ip: &str, + base_url: &str, + ) -> Result { + if is_bogon(ip) { + return Ok(IpDetailsPlus { + ip: ip.to_string(), + bogon: Some(true), + ..Default::default() // fill remaining with default values + }); + } + + // Check for cache hit + let cached_detail = self.cache.get(&cache_key(ip)); + + if let Some(cached_detail) = cached_detail { + return Ok(cached_detail.clone()); + } + + // lookup in case of a cache miss + let response = self + .client + .get(format!("{base_url}/{ip}")) + .headers(Self::construct_headers()) + .bearer_auth(self.token.as_deref().unwrap_or_default()) + .send() + .await?; + + // Check if we exhausted our request quota + if let reqwest::StatusCode::TOO_MANY_REQUESTS = response.status() { + return Err(err!(RateLimitExceededError)); + } + + // Acquire response + let raw_resp = response.error_for_status()?.text().await?; + + // Parse the response + let resp: serde_json::Value = serde_json::from_str(&raw_resp)?; + + // Return if an error occurred + if let Some(e) = resp["error"].as_str() { + return Err(err!(IpRequestError, e)); + } + + // Parse the results and add additional country details + let mut details: IpDetailsPlus = serde_json::from_str(&raw_resp)?; + self.populate_static_details(&mut details); + + // update cache + self.cache.put(cache_key(ip), details.clone()); + Ok(details) + } + + // Add country details and EU status to response + fn populate_static_details(&self, details: &mut IpDetailsPlus) { + // Enrich geo data + if let Some(ref mut geo) = details.geo { + if let Some(ref country_code) = geo.country_code { + if !country_code.is_empty() { + if let Some(country_name) = + self.countries.get(country_code) + { + geo.country_name = Some(country_name.to_owned()); + } + geo.is_eu = Some(self.eu.contains(country_code)); + if let Some(country_flag) = + self.country_flags.get(country_code) + { + geo.country_flag = Some(country_flag.to_owned()); + } + let file_ext = ".svg"; + geo.country_flag_url = Some( + COUNTRY_FLAG_URL.to_string() + country_code + file_ext, + ); + if let Some(country_currency) = + self.country_currencies.get(country_code) + { + geo.country_currency = + Some(country_currency.to_owned()); + } + if let Some(continent) = self.continents.get(country_code) + { + geo.continent_info = Some(continent.to_owned()); + } + } + } + } + + // Enrich abuse data with country_name + if let Some(ref mut abuse) = details.abuse { + if let Some(ref country) = abuse.country { + if !country.is_empty() { + if let Some(country_name) = self.countries.get(country) { + abuse.country_name = Some(country_name.to_owned()); + } + } + } + } + } + + /// Construct API request headers. + fn construct_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + USER_AGENT, + HeaderValue::from_str(&format!("IPinfoClient/Rust/{VERSION}")) + .unwrap(), + ); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + headers + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::IpErrorKind::HTTPClientError; + use std::env; + + fn get_ipinfo_client() -> IpInfoPlus { + IpInfoPlus::new(IpInfoPlusConfig { + token: Some(env::var("IPINFO_TOKEN").unwrap().to_string()), + timeout: Duration::from_secs(3), + cache_size: 100, + ..Default::default() + }) + .expect("should construct") + } + + #[test] + fn ipinfo_config_defaults_reasonable() { + let ipinfo_config = IpInfoPlusConfig::default(); + + assert_eq!(ipinfo_config.timeout, Duration::from_secs(3)); + assert_eq!(ipinfo_config.cache_size, 100); + } + + #[test] + fn request_headers_are_canonical() { + let headers = IpInfoPlus::construct_headers(); + + assert_eq!( + headers[USER_AGENT], + format!("IPinfoClient/Rust/{}", VERSION) + ); + assert_eq!(headers[CONTENT_TYPE], "application/json"); + assert_eq!(headers[ACCEPT], "application/json"); + } + + #[tokio::test] + async fn lookup_no_token() { + let mut ipinfo = + IpInfoPlus::new(Default::default()).expect("should construct"); + + assert_eq!( + ipinfo.lookup("8.8.8.8").await.err().unwrap().kind(), + HTTPClientError + ); + } + + #[tokio::test] + async fn lookup_single_ip() { + let mut ipinfo = get_ipinfo_client(); + + let details = ipinfo.lookup("8.8.8.8").await.expect("should lookup"); + + assert_eq!(details.ip, "8.8.8.8"); + assert_eq!(details.hostname, Some("dns.google".to_string())); + assert_eq!(details.is_anycast, true); + assert_eq!(details.is_hosting, true); + + // Check geo details + assert!(details.geo.is_some()); + let geo = details.geo.as_ref().unwrap(); + assert_eq!(geo.city, Some("Mountain View".to_string())); + assert_eq!(geo.region, Some("California".to_string())); + assert_eq!(geo.country_code, Some("US".to_string())); + assert_eq!(geo.country, Some("United States".to_string())); + assert_eq!(geo.country_name, Some("United States".to_string())); + assert_eq!(geo.is_eu, Some(false)); + assert_eq!(geo.country_flag.as_ref().unwrap().emoji, "🇺🇸"); + assert_eq!( + geo.country_flag.as_ref().unwrap().unicode, + "U+1F1FA U+1F1F8" + ); + assert_eq!( + geo.country_flag_url, + Some( + "https://cdn.ipinfo.io/static/images/countries-flags/US.svg" + .to_string() + ) + ); + assert_eq!(geo.country_currency.as_ref().unwrap().code, "USD"); + assert_eq!(geo.country_currency.as_ref().unwrap().symbol, "$"); + assert_eq!(geo.continent_info.as_ref().unwrap().code, "NA"); + assert_eq!(geo.continent_info.as_ref().unwrap().name, "North America"); + + // Check AS details + assert!(details.asn.is_some()); + let asn = details.asn.as_ref().unwrap(); + assert_eq!(asn.asn, "AS15169"); + assert_eq!(asn.name, "Google LLC"); + assert_eq!(asn.domain, "google.com"); + assert_eq!(asn.as_type, "hosting"); + + // Privacy details may or may not be present depending on IP + if let Some(privacy) = details.privacy.as_ref() { + // Just verify the structure is correct if present + assert!(!privacy.vpn || privacy.vpn); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 201422c..d8eddf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,11 +58,13 @@ mod data; mod ipinfo; mod ipinfo_core; mod ipinfo_lite; +mod ipinfo_plus; mod util; pub use crate::ipinfo::*; pub use crate::ipinfo_core::*; pub use crate::ipinfo_lite::*; +pub use crate::ipinfo_plus::*; pub use api::*; pub use bogon::*; pub use data::*;