From 223c8ba2b2159f0c97a56acb21f6068b3b0e7bef Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Tue, 15 Jul 2025 19:22:18 +0200 Subject: [PATCH 1/3] Fix failing tests --- src/ipinfo.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ipinfo.rs b/src/ipinfo.rs index 8bf2a21..0826345 100644 --- a/src/ipinfo.rs +++ b/src/ipinfo.rs @@ -584,12 +584,12 @@ mod tests { let ip4 = &details["4.2.2.4"]; assert_eq!(ip4.ip, "4.2.2.4"); assert_eq!(ip4.hostname, Some("d.resolvers.level3.net".to_owned())); - assert_eq!(ip4.city, "Broomfield"); - assert_eq!(ip4.region, "Colorado"); + assert_eq!(ip4.city, "Monroe"); + assert_eq!(ip4.region, "Louisiana"); assert_eq!(ip4.country, "US"); - assert_eq!(ip4.loc, "39.8854,-105.1139"); - assert_eq!(ip4.postal, Some("80021".to_owned())); - assert_eq!(ip4.timezone, Some("America/Denver".to_owned())); + assert_eq!(ip4.loc, "32.5530,-92.0422"); + assert_eq!(ip4.postal, Some("71203".to_owned())); + assert_eq!(ip4.timezone, Some("America/Chicago".to_owned())); } #[tokio::test] From ab264a0cfbdc9cc1e12686d3795562add7ba84e5 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Wed, 16 Jul 2025 14:01:27 +0200 Subject: [PATCH 2/3] Add support for Lite API --- README.md | 38 +++- examples/lookup_lite.rs | 22 +++ src/api.rs | 56 +++++- src/ipinfo_lite.rs | 427 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + 5 files changed, 541 insertions(+), 4 deletions(-) create mode 100644 examples/lookup_lite.rs create mode 100644 src/ipinfo_lite.rs diff --git a/README.md b/README.md index 7d8f527..15f3748 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The free plan is limited to 50,000 requests per month, and doesn't include some data fields such as the IP type and company information. To get the complete list of information on an IP address and make more requests per day see [https://ipinfo.io/pricing](https://ipinfo.io/pricing). -⚠️ Note: This library does not currently support our newest free API https://ipinfo.io/lite. If you’d like to use IPinfo Lite, you can call the [endpoint directly](https://ipinfo.io/developers/lite-api) using your preferred HTTP client. Developers are also welcome to contribute support for Lite by submitting a pull request. +The library also supports the Lite API, see the [Lite API section](#lite-api) for more info. ## Examples @@ -110,6 +110,42 @@ let config = IpInfoConfig { }; ``` +### Lite API + +The library gives the possibility to use the [Lite API](https://ipinfo.io/developers/lite-api) too, authentication with your token is still required. + +The returned details are slightly different from the Core API. + +There's a Lite API example too in the `/examples` directory. You can run it directly like the others, remember to replace `` with your access token + +```bash +cargo run --example lookup_lite -- +``` + +The `lookup_lite` example above looks more or less like + +```rust +use ipinfo::{IpInfoLite, IpInfoLiteConfig}; +#[tokio::main] +async fn main() { + let config = IpInfoLiteConfig { + token: Some("".to_string()), + ..Default::default() + }; + + let mut ipinfo = IpInfoLite::new(config) + .expect("should construct"); + + let res = ipinfo.lookup_self_v4().await; + match res { + Ok(r) => { + println!("Current IP lookup result: {:?}", r); + }, + Err(e) => println!("error occurred: {}", &e.to_string()), + } +} +``` + ## Other Libraries There are official IPinfo client libraries available for many languages including diff --git a/examples/lookup_lite.rs b/examples/lookup_lite.rs new file mode 100644 index 0000000..0aca352 --- /dev/null +++ b/examples/lookup_lite.rs @@ -0,0 +1,22 @@ +use ipinfo::{IpInfoLite, IpInfoLiteConfig}; +use std::env; + +#[tokio::main] +async fn main() { + let token = env::args().nth(1); + + let config = IpInfoLiteConfig { + token, + ..Default::default() + }; + + let mut ipinfo = IpInfoLite::new(config).expect("should construct"); + + let res = ipinfo.lookup_self_v4().await; + match res { + Ok(r) => { + println!("Current IP lookup result: {:?}", r); + } + Err(e) => println!("error occurred: {}", &e.to_string()), + } +} diff --git a/src/api.rs b/src/api.rs index d26e2bc..49ea25f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -197,22 +197,72 @@ pub struct DomainsDetails { } /// CountryFlag details. -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] pub struct CountryFlag { pub emoji: String, pub unicode: String, } /// CountryCurrency details. -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] pub struct CountryCurrency { pub code: String, pub symbol: String, } /// Continent details. -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] pub struct Continent { pub code: String, pub name: String, } + +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct IpDetailsLite { + pub ip: String, + + /// The country code for the IP address. + pub country_code: String, + + /// The country name for the IP address. + pub country: String, + + /// The country name for the IP address. + #[serde(skip_deserializing)] + pub country_name: String, + + /// EU status of the country. + #[serde(skip_deserializing)] + pub is_eu: bool, + + /// Flag and unicode of the country. + #[serde(skip_deserializing)] + pub country_flag: CountryFlag, + + /// Link of the Flag of country. + #[serde(skip_deserializing)] + pub country_flag_url: String, + + /// Code and symbol of the country's currency. + #[serde(skip_deserializing)] + pub country_currency: CountryCurrency, + + /// The AS number. + pub asn: String, + + /// The AS name. + pub as_name: String, + + /// The AS domain. + pub as_domain: String, + + /// Code and name of the continent. + #[serde(skip_deserializing)] + pub continent: Continent, + + /// If the IP Address is Bogon + pub bogon: Option, + + #[serde(flatten)] + pub extra: HashMap, +} diff --git a/src/ipinfo_lite.rs b/src/ipinfo_lite.rs new file mode 100644 index 0000000..4e54d86 --- /dev/null +++ b/src/ipinfo_lite.rs @@ -0,0 +1,427 @@ +// 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, + IpDetailsLite, 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/lite"; +const BASE_URL_V6: &str = "https://v6.api.ipinfo.io/lite"; + +/// IpInfoLite structure configuration. +pub struct IpInfoLiteConfig { + /// 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 IpInfoLiteConfig { + 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, + } + } +} + +/// IpInfoLite requests context structure. +pub struct IpInfoLite { + token: Option, + client: reqwest::Client, + cache: LruCache, + countries: HashMap, + eu: Vec, + country_flags: HashMap, + country_currencies: HashMap, + continents: HashMap, +} + +impl IpInfoLite { + /// Construct a new IpInfoLite structure. + /// + /// # Examples + /// + /// ``` + /// use ipinfo::IpInfoLite; + /// + /// let ipinfo = IpInfoLite::new(Default::default()).expect("should construct"); + /// ``` + pub fn new(config: IpInfoLiteConfig) -> 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 IpDetailsLite for a single IP Address + /// + /// # Example + /// + /// ```no_run + /// use ipinfo::IpInfoLite; + /// + /// #[tokio::main] + /// async fn main() { + /// let mut ipinfo = IpInfoLite::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 IPDetailsLite of your own v4 IP + /// + /// # Example + /// + /// ```no_run + /// use ipinfo::IpInfoLite; + /// + /// #[tokio::main] + /// async fn main() { + /// let mut ipinfo = IpInfoLite::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 IPDetailsLite of your own v6 IP + /// + /// # Example + /// + /// ```no_run + /// use ipinfo::IpInfoLite; + /// + /// #[tokio::main] + /// async fn main() { + /// let mut ipinfo = IpInfoLite::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(IpDetailsLite { + 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: IpDetailsLite = 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 IpDetailsLite) { + if !&details.country_code.is_empty() { + let country_name = + self.countries.get(&details.country_code).unwrap(); + details.country_name = country_name.to_owned(); + details.is_eu = self.eu.contains(&details.country_code); + let country_flag = + self.country_flags.get(&details.country_code).unwrap(); + details.country_flag = country_flag.to_owned(); + let file_ext = ".svg"; + details.country_flag_url = COUNTRY_FLAG_URL.to_string() + + &details.country_code + + file_ext; + let country_currency = + self.country_currencies.get(&details.country_code).unwrap(); + details.country_currency = country_currency.to_owned(); + let continent = + self.continents.get(&details.country_code).unwrap(); + details.continent = continent.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() -> IpInfoLite { + IpInfoLite::new(IpInfoLiteConfig { + 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 = IpInfoLiteConfig::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 = IpInfoLite::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 = + IpInfoLite::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.country_code, "US"); + assert_eq!(details.country, "United States"); + assert_eq!(details.country_name, "United States"); + assert_eq!(details.is_eu, false); + assert_eq!(details.country_flag.emoji, "🇺🇸"); + assert_eq!(details.country_flag.unicode, "U+1F1FA U+1F1F8"); + assert_eq!( + details.country_flag_url, + "https://cdn.ipinfo.io/static/images/countries-flags/US.svg" + ); + assert_eq!(details.country_currency.code, "USD"); + assert_eq!(details.country_currency.symbol, "$"); + + assert_eq!(details.asn, "AS15169"); + assert_eq!(details.as_name, "Google LLC"); + assert_eq!(details.as_domain, "google.com"); + + assert_eq!(details.continent.code, "NA"); + assert_eq!(details.continent.name, "North America"); + } + + #[tokio::test] + async fn lookup_single_ip_v6() { + let mut ipinfo = get_ipinfo_client(); + + let details = ipinfo + .lookup("2001:4860:4860::8888") + .await + .expect("should lookup"); + + assert_eq!(details.ip, "2001:4860:4860::8888"); + assert_eq!(details.country_code, "US"); + assert_eq!(details.country, "United States"); + assert_eq!(details.country_name, "United States"); + assert_eq!(details.is_eu, false); + assert_eq!(details.country_flag.emoji, "🇺🇸"); + assert_eq!(details.country_flag.unicode, "U+1F1FA U+1F1F8"); + assert_eq!( + details.country_flag_url, + "https://cdn.ipinfo.io/static/images/countries-flags/US.svg" + ); + assert_eq!(details.country_currency.code, "USD"); + assert_eq!(details.country_currency.symbol, "$"); + + assert_eq!(details.asn, "AS15169"); + assert_eq!(details.as_name, "Google LLC"); + assert_eq!(details.as_domain, "google.com"); + + assert_eq!(details.continent.code, "NA"); + assert_eq!(details.continent.name, "North America"); + } + + #[tokio::test] + async fn lookup_self_v4() { + let mut ipinfo = get_ipinfo_client(); + + let details = ipinfo.lookup_self_v4().await.expect("should lookup"); + + // We can't know the values since they depend on the host IP + assert_ne!(details.ip, ""); + assert_ne!(details.country_code, ""); + assert_ne!(details.country, ""); + assert_ne!(details.country_name, ""); + + assert_ne!(details.country_flag.emoji, ""); + assert_ne!(details.country_flag.unicode, ""); + assert_ne!(details.country_flag_url, ""); + assert_ne!(details.country_currency.code, ""); + assert_ne!(details.country_currency.symbol, ""); + + assert_ne!(details.asn, ""); + assert_ne!(details.as_name, ""); + assert_ne!(details.as_domain, ""); + + assert_ne!(details.continent.code, ""); + assert_ne!(details.continent.name, ""); + } +} diff --git a/src/lib.rs b/src/lib.rs index 33036ac..3dfdc03 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,9 +56,11 @@ mod api; mod bogon; mod data; mod ipinfo; +mod ipinfo_lite; mod util; pub use crate::ipinfo::*; +pub use crate::ipinfo_lite::*; pub use api::*; pub use bogon::*; pub use data::*; From b2ddd9eeac84c7cfb505e7576701bc4f1c20d300 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Wed, 16 Jul 2025 14:31:54 +0200 Subject: [PATCH 3/3] Fix clippy issues --- src/bogon.rs | 2 +- src/ipinfo.rs | 8 ++++---- src/ipinfo_lite.rs | 4 ++-- src/util.rs | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/bogon.rs b/src/bogon.rs index 071fc53..95af330 100644 --- a/src/bogon.rs +++ b/src/bogon.rs @@ -118,7 +118,7 @@ lazy_static! { /// assert_eq!(is_bogon("foo"), false); /// ``` pub fn is_bogon(ip_address: &str) -> bool { - ip_address.parse().map_or(false, is_bogon_addr) + ip_address.parse().is_ok_and(is_bogon_addr) } /// Returns a boolean indicating whether an IP address is bogus. diff --git a/src/ipinfo.rs b/src/ipinfo.rs index 0826345..18dc59e 100644 --- a/src/ipinfo.rs +++ b/src/ipinfo.rs @@ -260,7 +260,7 @@ impl IpInfo { ) -> Result, IpError> { // Lookup cache misses which are not bogon let response = client - .post(format!("{}/batch", BASE_URL)) + .post(format!("{BASE_URL}/batch")) .headers(Self::construct_headers()) .bearer_auth(self.token.as_deref().unwrap_or_default()) .json(&json!(ips)) @@ -363,7 +363,7 @@ impl IpInfo { // lookup in case of a cache miss let response = self .client - .get(format!("{}/{}", base_url, ip)) + .get(format!("{base_url}/{ip}")) .headers(Self::construct_headers()) .bearer_auth(self.token.as_deref().unwrap_or_default()) .send() @@ -412,7 +412,7 @@ impl IpInfo { return Err(err!(MapLimitError)); } - let map_url = &format!("{}/tools/map?cli=1", BASE_URL); + let map_url = &format!("{BASE_URL}/tools/map?cli=1"); let client = self.client.clone(); let json_ips = serde_json::json!(ips); @@ -454,7 +454,7 @@ impl IpInfo { let mut headers = HeaderMap::new(); headers.insert( USER_AGENT, - HeaderValue::from_str(&format!("IPinfoClient/Rust/{}", VERSION)) + HeaderValue::from_str(&format!("IPinfoClient/Rust/{VERSION}")) .unwrap(), ); headers.insert( diff --git a/src/ipinfo_lite.rs b/src/ipinfo_lite.rs index 4e54d86..b81935d 100644 --- a/src/ipinfo_lite.rs +++ b/src/ipinfo_lite.rs @@ -222,7 +222,7 @@ impl IpInfoLite { // lookup in case of a cache miss let response = self .client - .get(format!("{}/{}", base_url, ip)) + .get(format!("{base_url}/{ip}")) .headers(Self::construct_headers()) .bearer_auth(self.token.as_deref().unwrap_or_default()) .send() @@ -281,7 +281,7 @@ impl IpInfoLite { let mut headers = HeaderMap::new(); headers.insert( USER_AGENT, - HeaderValue::from_str(&format!("IPinfoClient/Rust/{}", VERSION)) + HeaderValue::from_str(&format!("IPinfoClient/Rust/{VERSION}")) .unwrap(), ); headers.insert( diff --git a/src/util.rs b/src/util.rs index b838346..8a02720 100644 --- a/src/util.rs +++ b/src/util.rs @@ -20,5 +20,5 @@ pub const BATCH_REQ_TIMEOUT_DEFAULT: Duration = Duration::from_secs(5); const CACHE_KEY_VERSION: &str = "1"; pub fn cache_key(k: &str) -> String { - format!("{}:{}", k, CACHE_KEY_VERSION) + format!("{k}:{CACHE_KEY_VERSION}") }