use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT};
use reqwest::Client as HttpClient;
use serde_json::Value;
use crate::error::Error;
use crate::options::Options;
use crate::response::LookupResponse;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const HOST_FREE: &str = "ipwho.is";
pub const HOST_PAID: &str = "ipwhois.pro";
pub const BULK_LIMIT: usize = 100;
pub const SUPPORTED_LANGUAGES: &[&str] = &["en", "ru", "de", "es", "pt-BR", "fr", "zh-CN", "ja"];
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct IpWhois {
api_key: Option<String>,
user_agent: String,
timeout: Duration,
connect_timeout: Duration,
ssl: bool,
defaults: Options,
http: HttpClient,
}
impl Default for IpWhois {
fn default() -> Self {
Self::new()
}
}
impl IpWhois {
pub fn new() -> Self {
Self::build(None)
}
pub fn with_key(api_key: impl Into<String>) -> Self {
Self::build(Some(api_key.into()))
}
pub fn try_with_key(api_key: impl Into<String>) -> Result<Self, Error> {
let key = api_key.into();
if key.trim().is_empty() {
return Err(Error::invalid_argument("API key must not be empty."));
}
Ok(Self::build(Some(key)))
}
fn build(api_key: Option<String>) -> Self {
let http = HttpClient::builder()
.timeout(DEFAULT_TIMEOUT)
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.redirect(reqwest::redirect::Policy::limited(3))
.build()
.unwrap_or_else(|_| HttpClient::new());
Self {
api_key,
user_agent: format!("ipwhois-rust/{}", VERSION),
timeout: DEFAULT_TIMEOUT,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
ssl: true,
defaults: Options::default(),
http,
}
}
pub fn with_language(mut self, lang: impl Into<String>) -> Self {
self.defaults.lang = Some(lang.into());
self
}
pub fn with_fields<I, S>(mut self, fields: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.defaults.fields = Some(fields.into_iter().map(Into::into).collect());
self
}
pub fn with_security(mut self, enabled: bool) -> Self {
self.defaults.security = Some(enabled);
self
}
pub fn with_rate(mut self, enabled: bool) -> Self {
self.defaults.rate = Some(enabled);
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self.rebuild_http();
self
}
pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self.rebuild_http();
self
}
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = ua.into();
self
}
pub fn with_ssl(mut self, ssl: bool) -> Self {
self.ssl = ssl;
self
}
fn rebuild_http(&mut self) {
if let Ok(http) = HttpClient::builder()
.timeout(self.timeout)
.connect_timeout(self.connect_timeout)
.redirect(reqwest::redirect::Policy::limited(3))
.build()
{
self.http = http;
}
}
pub async fn lookup(&self, ip: impl AsRef<str>) -> Result<LookupResponse, Error> {
self.do_lookup(Some(ip.as_ref()), &Options::default()).await
}
pub async fn lookup_with(
&self,
ip: impl AsRef<str>,
options: &Options,
) -> Result<LookupResponse, Error> {
self.do_lookup(Some(ip.as_ref()), options).await
}
pub async fn lookup_self(&self) -> Result<LookupResponse, Error> {
self.do_lookup(None, &Options::default()).await
}
pub async fn lookup_self_with(&self, options: &Options) -> Result<LookupResponse, Error> {
self.do_lookup(None, options).await
}
async fn do_lookup(
&self,
ip: Option<&str>,
options: &Options,
) -> Result<LookupResponse, Error> {
let merged = self.defaults.merged_with(options);
validate_options(&merged)?;
let path = match ip {
Some(addr) => format!("/{}", urlencode(addr)),
None => "/".to_string(),
};
let url = self.build_url(&path, &merged);
let body: Value = self.request(&url).await?;
decode_lookup(body)
}
pub async fn bulk_lookup<I, S>(&self, ips: I) -> Result<Vec<LookupResponse>, Error>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.bulk_lookup_with(ips, &Options::default()).await
}
pub async fn bulk_lookup_with<I, S>(
&self,
ips: I,
options: &Options,
) -> Result<Vec<LookupResponse>, Error>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let collected: Vec<String> = ips.into_iter().map(|s| s.as_ref().to_string()).collect();
if collected.is_empty() {
return Err(Error::invalid_argument(
"Bulk lookup requires at least one IP address.",
));
}
if collected.len() > BULK_LIMIT {
return Err(Error::invalid_argument(format!(
"Bulk lookup accepts at most {} IP addresses per call, got {}.",
BULK_LIMIT,
collected.len()
)));
}
let merged = self.defaults.merged_with(options);
validate_options(&merged)?;
let joined = collected
.iter()
.map(|ip| urlencode(ip))
.collect::<Vec<_>>()
.join(",");
let path = format!("/bulk/{}", joined);
let url = self.build_url(&path, &merged);
let body: Value = self.request(&url).await?;
decode_bulk(body)
}
pub(crate) fn build_url(&self, path: &str, options: &Options) -> String {
let host = if self.api_key.is_some() {
HOST_PAID
} else {
HOST_FREE
};
let scheme = if self.ssl { "https" } else { "http" };
let mut url = format!("{}://{}{}", scheme, host, path);
let mut pairs: Vec<(&str, String)> = Vec::new();
if let Some(key) = &self.api_key {
pairs.push(("key", key.clone()));
}
if let Some(lang) = &options.lang {
pairs.push(("lang", lang.clone()));
}
if let Some(fields) = &options.fields {
pairs.push(("fields", fields.join(",")));
}
if options.security == Some(true) {
pairs.push(("security", "1".to_string()));
}
if options.rate == Some(true) {
pairs.push(("rate", "1".to_string()));
}
if !pairs.is_empty() {
url.push('?');
for (i, (k, v)) in pairs.iter().enumerate() {
if i > 0 {
url.push('&');
}
url.push_str(&urlencode_form(k));
url.push('=');
url.push_str(&urlencode_form(v));
}
}
url
}
async fn request(&self, url: &str) -> Result<Value, Error> {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(
USER_AGENT,
HeaderValue::from_str(&self.user_agent)
.unwrap_or_else(|_| HeaderValue::from_static("ipwhois-rust")),
);
let resp = self
.http
.get(url)
.headers(headers)
.send()
.await
.map_err(|e| Error::network(e.to_string()))?;
let status = resp.status();
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
let body_bytes = resp
.bytes()
.await
.map_err(|e| Error::network(e.to_string()))?;
let value: Value = if body_bytes.is_empty() {
Value::Null
} else {
match serde_json::from_slice::<Value>(&body_bytes) {
Ok(v) => v,
Err(_) => {
let snippet = body_snippet(&body_bytes);
return Err(Error::api(
format!(
"Invalid JSON returned by ipwhois API (HTTP {}): {}",
status.as_u16(),
snippet
),
Some(status.as_u16()),
None,
));
}
}
};
if status.is_client_error() || status.is_server_error() {
let message = value
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("HTTP {} returned by ipwhois API", status.as_u16()));
let retry_after = if status.as_u16() == 429 && self.api_key.is_none() {
retry_after
} else {
None
};
return Err(Error::api(message, Some(status.as_u16()), retry_after));
}
Ok(value)
}
}
fn validate_options(options: &Options) -> Result<(), Error> {
if let Some(lang) = &options.lang {
if !SUPPORTED_LANGUAGES.iter().any(|s| s == lang) {
return Err(Error::invalid_argument(format!(
"Unsupported language \"{}\". Supported: {}.",
lang,
SUPPORTED_LANGUAGES.join(", ")
)));
}
}
Ok(())
}
fn decode_lookup(value: Value) -> Result<LookupResponse, Error> {
if let Some(false) = value.get("success").and_then(|v| v.as_bool()) {
let message = value
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "ipwhois API returned success=false".to_string());
return Err(Error::api(message, None, None));
}
serde_json::from_value::<LookupResponse>(value).map_err(|e| {
Error::api(
format!("Could not decode ipwhois response: {}", e),
None,
None,
)
})
}
fn decode_bulk(value: Value) -> Result<Vec<LookupResponse>, Error> {
match value {
Value::Array(_) => serde_json::from_value::<Vec<LookupResponse>>(value).map_err(|e| {
Error::api(
format!("Could not decode ipwhois bulk response: {}", e),
None,
None,
)
}),
Value::Object(_) => {
let message = value
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "ipwhois bulk request failed".to_string());
Err(Error::api(message, None, None))
}
_ => Err(Error::api(
"Unexpected response shape from ipwhois bulk endpoint",
None,
None,
)),
}
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push_str(&format!("{:02X}", b));
}
}
}
out
}
fn urlencode_form(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
b' ' => out.push('+'),
_ => {
out.push('%');
out.push_str(&format!("{:02X}", b));
}
}
}
out
}
fn body_snippet(bytes: &[u8]) -> String {
let s = String::from_utf8_lossy(bytes);
let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() > 200 {
let truncated: String = collapsed.chars().take(200).collect();
format!("{}…", truncated)
} else {
collapsed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build(client: &IpWhois, path: &str, options: Options) -> String {
let merged = client.defaults.merged_with(&options);
client.build_url(path, &merged)
}
#[test]
fn free_endpoint_has_no_api_key() {
let url = build(&IpWhois::new(), "/8.8.8.8", Options::default());
assert_eq!(url, "https://ipwho.is/8.8.8.8");
}
#[test]
fn paid_endpoint_appends_api_key() {
let url = build(
&IpWhois::with_key("TESTKEY"),
"/8.8.8.8",
Options::default(),
);
assert!(
url.starts_with("https://ipwhois.pro/8.8.8.8?"),
"got: {}",
url
);
assert!(url.contains("key=TESTKEY"), "got: {}", url);
}
#[test]
fn https_is_used_by_default() {
assert!(build(&IpWhois::new(), "/", Options::default()).starts_with("https://"));
assert!(build(&IpWhois::with_key("K"), "/", Options::default()).starts_with("https://"));
}
#[test]
fn ssl_can_be_disabled() {
let free = IpWhois::new().with_ssl(false);
let paid = IpWhois::with_key("K").with_ssl(false);
assert!(build(&free, "/", Options::default()).starts_with("http://ipwho.is"));
assert!(build(&paid, "/", Options::default()).starts_with("http://ipwhois.pro"));
}
#[test]
fn fields_are_joined_with_commas() {
let opts = Options::new().with_fields(["country", "city", "flag.emoji"]);
let url = build(&IpWhois::with_key("K"), "/8.8.8.8", opts);
assert!(
url.contains("fields=country%2Ccity%2Cflag.emoji"),
"got: {}",
url
);
}
#[test]
fn security_and_rate_are_flags_not_values() {
let opts = Options::new().with_security(true).with_rate(true);
let url = build(&IpWhois::with_key("K"), "/", opts);
assert!(url.contains("security=1"));
assert!(url.contains("rate=1"));
}
#[test]
fn security_false_is_omitted() {
let opts = Options::new().with_security(false);
let url = build(&IpWhois::with_key("K"), "/", opts);
assert!(!url.contains("security="), "got: {}", url);
}
#[test]
fn per_call_options_override_defaults() {
let client = IpWhois::with_key("K").with_language("ru");
let url = build(&client, "/", Options::new().with_lang("en"));
assert!(url.contains("lang=en"));
assert!(!url.contains("lang=ru"));
}
#[tokio::test]
async fn invalid_language_returns_error() {
let result = IpWhois::new()
.lookup_with("8.8.8.8", &Options::new().with_lang("klingon"))
.await;
let err = result.expect_err("expected an error");
assert_eq!(err.error_type(), "invalid_argument");
assert!(err.message().contains("klingon"));
}
#[tokio::test]
async fn bulk_lookup_refuses_empty_list() {
let empty: &[&str] = &[];
let result = IpWhois::with_key("K").bulk_lookup(empty).await;
let err = result.expect_err("expected an error");
assert_eq!(err.error_type(), "invalid_argument");
}
#[tokio::test]
async fn bulk_lookup_refuses_more_than_limit() {
let too_many: Vec<&str> = std::iter::repeat("8.8.8.8").take(BULK_LIMIT + 1).collect();
let result = IpWhois::with_key("K").bulk_lookup(too_many).await;
let err = result.expect_err("expected an error");
assert_eq!(err.error_type(), "invalid_argument");
}
#[tokio::test]
async fn bulk_lookup_accepts_vec_of_strings() {
let owned: Vec<String> = Vec::new();
let result = IpWhois::with_key("K").bulk_lookup(owned).await;
assert_eq!(result.expect_err("empty").error_type(), "invalid_argument");
}
#[test]
fn try_with_key_rejects_empty_string() {
let err = IpWhois::try_with_key("").expect_err("expected an error");
assert_eq!(err.error_type(), "invalid_argument");
}
#[test]
fn try_with_key_rejects_whitespace_only() {
let err = IpWhois::try_with_key(" \t\n").expect_err("expected an error");
assert_eq!(err.error_type(), "invalid_argument");
}
#[test]
fn try_with_key_accepts_valid_key() {
let client = IpWhois::try_with_key("KEY").expect("should accept");
let url = build(&client, "/", Options::default());
assert!(url.contains("key=KEY"));
}
#[test]
fn bulk_url_is_comma_separated() {
let url = build(
&IpWhois::with_key("K"),
"/bulk/8.8.8.8,1.1.1.1",
Options::default(),
);
assert!(url.contains("/bulk/8.8.8.8,1.1.1.1"), "got: {}", url);
}
#[tokio::test]
async fn bulk_url_with_ipv6_percent_encodes_colons_but_not_commas() {
let client = IpWhois::with_key("K");
let merged = client.defaults.merged_with(&Options::default());
let joined = ["2c0f:fb50:4003::", "8.8.8.8"]
.iter()
.map(|ip| urlencode(ip))
.collect::<Vec<_>>()
.join(",");
let url = client.build_url(&format!("/bulk/{}", joined), &merged);
assert!(
url.contains("/bulk/2c0f%3Afb50%3A4003%3A%3A,8.8.8.8"),
"got: {}",
url
);
assert!(
!url[url.find("/bulk/").unwrap()..].contains("%2C"),
"got: {}",
url
);
}
#[test]
fn set_language_affects_subsequent_requests() {
let client = IpWhois::with_key("K").with_language("de");
let url = build(&client, "/", Options::default());
assert!(url.contains("lang=de"));
}
#[test]
fn user_agent_carries_version() {
let ua = IpWhois::new().user_agent;
assert!(ua.starts_with("ipwhois-rust/"));
}
#[test]
fn supported_languages_present() {
for lang in ["en", "ru", "de", "es", "pt-BR", "fr", "zh-CN", "ja"] {
assert!(SUPPORTED_LANGUAGES.contains(&lang), "missing: {}", lang);
}
}
#[test]
fn bulk_limit_is_one_hundred() {
assert_eq!(BULK_LIMIT, 100);
}
#[test]
fn decode_lookup_success_returns_typed_response() {
let body = serde_json::json!({
"ip": "8.8.8.8",
"success": true,
"country": "United States",
"country_code": "US",
});
let info = decode_lookup(body).expect("decode");
assert!(info.success);
assert_eq!(info.ip.as_deref(), Some("8.8.8.8"));
assert_eq!(info.country_code.as_deref(), Some("US"));
}
#[test]
fn decode_lookup_success_false_becomes_api_error() {
let body = serde_json::json!({
"success": false,
"message": "Invalid IP address",
});
let err = decode_lookup(body).expect_err("expected an error");
assert_eq!(err.error_type(), "api");
assert_eq!(err.message(), "Invalid IP address");
}
#[test]
fn decode_bulk_array_returns_per_ip_entries() {
let body = serde_json::json!([
{ "ip": "8.8.8.8", "success": true, "country_code": "US" },
{ "ip": "999.999.999.999", "success": false, "message": "Invalid IP address" },
]);
let rows = decode_bulk(body).expect("decode");
assert_eq!(rows.len(), 2);
assert!(rows[0].success);
assert!(!rows[1].success);
assert_eq!(rows[1].message.as_deref(), Some("Invalid IP address"));
}
#[test]
fn decode_bulk_object_means_whole_batch_failed() {
let body = serde_json::json!({
"success": false,
"message": "Invalid API key",
});
let err = decode_bulk(body).expect_err("expected an error");
assert_eq!(err.error_type(), "api");
assert_eq!(err.message(), "Invalid API key");
}
#[test]
fn body_snippet_collapses_whitespace_and_truncates() {
let snippet = body_snippet(b"<html>\n <body>oops</body>\n</html>");
assert_eq!(snippet, "<html> <body>oops</body> </html>");
let long: Vec<u8> = b"a".iter().cycle().take(500).copied().collect();
let snippet = body_snippet(&long);
assert!(snippet.ends_with('…'), "got: {}", snippet);
assert_eq!(snippet.chars().count(), 201); }
#[tokio::test]
async fn lookup_accepts_string_owned_and_borrowed() {
let client = IpWhois::new();
let owned: String = "8.8.8.8".to_string();
let _ = client
.lookup_with(&owned, &Options::new().with_lang("klingon"))
.await
.expect_err("invalid lang short-circuits before any request");
let _ = client
.lookup_with(owned.clone(), &Options::new().with_lang("klingon"))
.await
.expect_err("owned String works too");
let _ = client
.lookup_with("8.8.8.8", &Options::new().with_lang("klingon"))
.await
.expect_err("&str still works");
}
}