1use std::time::Duration;
7
8use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT};
9use reqwest::Client as HttpClient;
10use serde_json::Value;
11
12use crate::error::Error;
13use crate::options::Options;
14use crate::response::LookupResponse;
15
16pub const VERSION: &str = env!("CARGO_PKG_VERSION");
18
19pub const HOST_FREE: &str = "ipwho.is";
21
22pub const HOST_PAID: &str = "ipwhois.pro";
24
25pub const BULK_LIMIT: usize = 100;
27
28pub const SUPPORTED_LANGUAGES: &[&str] = &["en", "ru", "de", "es", "pt-BR", "fr", "zh-CN", "ja"];
30
31const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
32const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
33
34#[derive(Debug, Clone)]
63pub struct IpWhois {
64 api_key: Option<String>,
65 user_agent: String,
66 timeout: Duration,
67 connect_timeout: Duration,
68 ssl: bool,
69 defaults: Options,
70 http: HttpClient,
71}
72
73impl Default for IpWhois {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl IpWhois {
80 pub fn new() -> Self {
86 Self::build(None)
87 }
88
89 pub fn with_key(api_key: impl Into<String>) -> Self {
100 Self::build(Some(api_key.into()))
101 }
102
103 pub fn try_with_key(api_key: impl Into<String>) -> Result<Self, Error> {
120 let key = api_key.into();
121 if key.trim().is_empty() {
122 return Err(Error::invalid_argument("API key must not be empty."));
123 }
124 Ok(Self::build(Some(key)))
125 }
126
127 fn build(api_key: Option<String>) -> Self {
128 let http = HttpClient::builder()
129 .timeout(DEFAULT_TIMEOUT)
130 .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
131 .redirect(reqwest::redirect::Policy::limited(3))
132 .build()
133 .unwrap_or_else(|_| HttpClient::new());
137
138 Self {
139 api_key,
140 user_agent: format!("ipwhois-rust/{}", VERSION),
141 timeout: DEFAULT_TIMEOUT,
142 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
143 ssl: true,
144 defaults: Options::default(),
145 http,
146 }
147 }
148
149 pub fn with_language(mut self, lang: impl Into<String>) -> Self {
156 self.defaults.lang = Some(lang.into());
157 self
158 }
159
160 pub fn with_fields<I, S>(mut self, fields: I) -> Self
166 where
167 I: IntoIterator<Item = S>,
168 S: Into<String>,
169 {
170 self.defaults.fields = Some(fields.into_iter().map(Into::into).collect());
171 self
172 }
173
174 pub fn with_security(mut self, enabled: bool) -> Self {
176 self.defaults.security = Some(enabled);
177 self
178 }
179
180 pub fn with_rate(mut self, enabled: bool) -> Self {
182 self.defaults.rate = Some(enabled);
183 self
184 }
185
186 pub fn with_timeout(mut self, timeout: Duration) -> Self {
188 self.timeout = timeout;
189 self.rebuild_http();
190 self
191 }
192
193 pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
195 self.connect_timeout = timeout;
196 self.rebuild_http();
197 self
198 }
199
200 pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
202 self.user_agent = ua.into();
203 self
204 }
205
206 pub fn with_ssl(mut self, ssl: bool) -> Self {
213 self.ssl = ssl;
214 self
215 }
216
217 fn rebuild_http(&mut self) {
218 if let Ok(http) = HttpClient::builder()
219 .timeout(self.timeout)
220 .connect_timeout(self.connect_timeout)
221 .redirect(reqwest::redirect::Policy::limited(3))
222 .build()
223 {
224 self.http = http;
225 }
226 }
227
228 pub async fn lookup(&self, ip: impl AsRef<str>) -> Result<LookupResponse, Error> {
252 self.do_lookup(Some(ip.as_ref()), &Options::default()).await
253 }
254
255 pub async fn lookup_with(
259 &self,
260 ip: impl AsRef<str>,
261 options: &Options,
262 ) -> Result<LookupResponse, Error> {
263 self.do_lookup(Some(ip.as_ref()), options).await
264 }
265
266 pub async fn lookup_self(&self) -> Result<LookupResponse, Error> {
272 self.do_lookup(None, &Options::default()).await
273 }
274
275 pub async fn lookup_self_with(&self, options: &Options) -> Result<LookupResponse, Error> {
277 self.do_lookup(None, options).await
278 }
279
280 async fn do_lookup(
282 &self,
283 ip: Option<&str>,
284 options: &Options,
285 ) -> Result<LookupResponse, Error> {
286 let merged = self.defaults.merged_with(options);
287 validate_options(&merged)?;
288
289 let path = match ip {
290 Some(addr) => format!("/{}", urlencode(addr)),
291 None => "/".to_string(),
292 };
293
294 let url = self.build_url(&path, &merged);
295 let body: Value = self.request(&url).await?;
296 decode_lookup(body)
297 }
298
299 pub async fn bulk_lookup<I, S>(&self, ips: I) -> Result<Vec<LookupResponse>, Error>
314 where
315 I: IntoIterator<Item = S>,
316 S: AsRef<str>,
317 {
318 self.bulk_lookup_with(ips, &Options::default()).await
319 }
320
321 pub async fn bulk_lookup_with<I, S>(
324 &self,
325 ips: I,
326 options: &Options,
327 ) -> Result<Vec<LookupResponse>, Error>
328 where
329 I: IntoIterator<Item = S>,
330 S: AsRef<str>,
331 {
332 let collected: Vec<String> = ips.into_iter().map(|s| s.as_ref().to_string()).collect();
333
334 if collected.is_empty() {
335 return Err(Error::invalid_argument(
336 "Bulk lookup requires at least one IP address.",
337 ));
338 }
339 if collected.len() > BULK_LIMIT {
340 return Err(Error::invalid_argument(format!(
341 "Bulk lookup accepts at most {} IP addresses per call, got {}.",
342 BULK_LIMIT,
343 collected.len()
344 )));
345 }
346
347 let merged = self.defaults.merged_with(options);
348 validate_options(&merged)?;
349
350 let joined = collected
354 .iter()
355 .map(|ip| urlencode(ip))
356 .collect::<Vec<_>>()
357 .join(",");
358 let path = format!("/bulk/{}", joined);
359
360 let url = self.build_url(&path, &merged);
361 let body: Value = self.request(&url).await?;
362 decode_bulk(body)
363 }
364
365 pub(crate) fn build_url(&self, path: &str, options: &Options) -> String {
371 let host = if self.api_key.is_some() {
372 HOST_PAID
373 } else {
374 HOST_FREE
375 };
376 let scheme = if self.ssl { "https" } else { "http" };
377
378 let mut url = format!("{}://{}{}", scheme, host, path);
379
380 let mut pairs: Vec<(&str, String)> = Vec::new();
381 if let Some(key) = &self.api_key {
382 pairs.push(("key", key.clone()));
383 }
384 if let Some(lang) = &options.lang {
385 pairs.push(("lang", lang.clone()));
386 }
387 if let Some(fields) = &options.fields {
388 pairs.push(("fields", fields.join(",")));
389 }
390 if options.security == Some(true) {
391 pairs.push(("security", "1".to_string()));
392 }
393 if options.rate == Some(true) {
394 pairs.push(("rate", "1".to_string()));
395 }
396
397 if !pairs.is_empty() {
398 url.push('?');
399 for (i, (k, v)) in pairs.iter().enumerate() {
400 if i > 0 {
401 url.push('&');
402 }
403 url.push_str(&urlencode_form(k));
404 url.push('=');
405 url.push_str(&urlencode_form(v));
406 }
407 }
408
409 url
410 }
411
412 async fn request(&self, url: &str) -> Result<Value, Error> {
416 let mut headers = HeaderMap::new();
417 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
418 headers.insert(
419 USER_AGENT,
420 HeaderValue::from_str(&self.user_agent)
421 .unwrap_or_else(|_| HeaderValue::from_static("ipwhois-rust")),
422 );
423
424 let resp = self
425 .http
426 .get(url)
427 .headers(headers)
428 .send()
429 .await
430 .map_err(|e| Error::network(e.to_string()))?;
431
432 let status = resp.status();
433 let retry_after = resp
434 .headers()
435 .get(reqwest::header::RETRY_AFTER)
436 .and_then(|v| v.to_str().ok())
437 .and_then(|s| s.parse::<u64>().ok());
438
439 let body_bytes = resp
440 .bytes()
441 .await
442 .map_err(|e| Error::network(e.to_string()))?;
443
444 let value: Value = if body_bytes.is_empty() {
448 Value::Null
449 } else {
450 match serde_json::from_slice::<Value>(&body_bytes) {
451 Ok(v) => v,
452 Err(_) => {
453 let snippet = body_snippet(&body_bytes);
454 return Err(Error::api(
455 format!(
456 "Invalid JSON returned by ipwhois API (HTTP {}): {}",
457 status.as_u16(),
458 snippet
459 ),
460 Some(status.as_u16()),
461 None,
462 ));
463 }
464 }
465 };
466
467 if status.is_client_error() || status.is_server_error() {
468 let message = value
471 .get("message")
472 .and_then(|v| v.as_str())
473 .map(|s| s.to_string())
474 .unwrap_or_else(|| format!("HTTP {} returned by ipwhois API", status.as_u16()));
475
476 let retry_after = if status.as_u16() == 429 && self.api_key.is_none() {
480 retry_after
481 } else {
482 None
483 };
484
485 return Err(Error::api(message, Some(status.as_u16()), retry_after));
486 }
487
488 Ok(value)
489 }
490}
491
492fn validate_options(options: &Options) -> Result<(), Error> {
495 if let Some(lang) = &options.lang {
496 if !SUPPORTED_LANGUAGES.iter().any(|s| s == lang) {
497 return Err(Error::invalid_argument(format!(
498 "Unsupported language \"{}\". Supported: {}.",
499 lang,
500 SUPPORTED_LANGUAGES.join(", ")
501 )));
502 }
503 }
504 Ok(())
505}
506
507fn decode_lookup(value: Value) -> Result<LookupResponse, Error> {
511 if let Some(false) = value.get("success").and_then(|v| v.as_bool()) {
515 let message = value
516 .get("message")
517 .and_then(|v| v.as_str())
518 .map(|s| s.to_string())
519 .unwrap_or_else(|| "ipwhois API returned success=false".to_string());
520 return Err(Error::api(message, None, None));
521 }
522
523 serde_json::from_value::<LookupResponse>(value).map_err(|e| {
524 Error::api(
525 format!("Could not decode ipwhois response: {}", e),
526 None,
527 None,
528 )
529 })
530}
531
532fn decode_bulk(value: Value) -> Result<Vec<LookupResponse>, Error> {
536 match value {
537 Value::Array(_) => serde_json::from_value::<Vec<LookupResponse>>(value).map_err(|e| {
538 Error::api(
539 format!("Could not decode ipwhois bulk response: {}", e),
540 None,
541 None,
542 )
543 }),
544 Value::Object(_) => {
545 let message = value
547 .get("message")
548 .and_then(|v| v.as_str())
549 .map(|s| s.to_string())
550 .unwrap_or_else(|| "ipwhois bulk request failed".to_string());
551 Err(Error::api(message, None, None))
552 }
553 _ => Err(Error::api(
554 "Unexpected response shape from ipwhois bulk endpoint",
555 None,
556 None,
557 )),
558 }
559}
560
561fn urlencode(s: &str) -> String {
565 let mut out = String::with_capacity(s.len());
566 for b in s.bytes() {
567 match b {
568 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
569 out.push(b as char);
570 }
571 _ => {
572 out.push('%');
573 out.push_str(&format!("{:02X}", b));
574 }
575 }
576 }
577 out
578}
579
580fn urlencode_form(s: &str) -> String {
585 let mut out = String::with_capacity(s.len());
586 for b in s.bytes() {
587 match b {
588 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
589 out.push(b as char);
590 }
591 b' ' => out.push('+'),
592 _ => {
593 out.push('%');
594 out.push_str(&format!("{:02X}", b));
595 }
596 }
597 }
598 out
599}
600
601fn body_snippet(bytes: &[u8]) -> String {
606 let s = String::from_utf8_lossy(bytes);
607 let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
608 if collapsed.chars().count() > 200 {
609 let truncated: String = collapsed.chars().take(200).collect();
610 format!("{}…", truncated)
611 } else {
612 collapsed
613 }
614}
615
616#[cfg(test)]
621mod tests {
622 use super::*;
623
624 fn build(client: &IpWhois, path: &str, options: Options) -> String {
625 let merged = client.defaults.merged_with(&options);
626 client.build_url(path, &merged)
627 }
628
629 #[test]
630 fn free_endpoint_has_no_api_key() {
631 let url = build(&IpWhois::new(), "/8.8.8.8", Options::default());
632 assert_eq!(url, "https://ipwho.is/8.8.8.8");
633 }
634
635 #[test]
636 fn paid_endpoint_appends_api_key() {
637 let url = build(
638 &IpWhois::with_key("TESTKEY"),
639 "/8.8.8.8",
640 Options::default(),
641 );
642 assert!(
643 url.starts_with("https://ipwhois.pro/8.8.8.8?"),
644 "got: {}",
645 url
646 );
647 assert!(url.contains("key=TESTKEY"), "got: {}", url);
648 }
649
650 #[test]
651 fn https_is_used_by_default() {
652 assert!(build(&IpWhois::new(), "/", Options::default()).starts_with("https://"));
653 assert!(build(&IpWhois::with_key("K"), "/", Options::default()).starts_with("https://"));
654 }
655
656 #[test]
657 fn ssl_can_be_disabled() {
658 let free = IpWhois::new().with_ssl(false);
659 let paid = IpWhois::with_key("K").with_ssl(false);
660
661 assert!(build(&free, "/", Options::default()).starts_with("http://ipwho.is"));
662 assert!(build(&paid, "/", Options::default()).starts_with("http://ipwhois.pro"));
663 }
664
665 #[test]
666 fn fields_are_joined_with_commas() {
667 let opts = Options::new().with_fields(["country", "city", "flag.emoji"]);
668 let url = build(&IpWhois::with_key("K"), "/8.8.8.8", opts);
669 assert!(
671 url.contains("fields=country%2Ccity%2Cflag.emoji"),
672 "got: {}",
673 url
674 );
675 }
676
677 #[test]
678 fn security_and_rate_are_flags_not_values() {
679 let opts = Options::new().with_security(true).with_rate(true);
680 let url = build(&IpWhois::with_key("K"), "/", opts);
681 assert!(url.contains("security=1"));
682 assert!(url.contains("rate=1"));
683 }
684
685 #[test]
686 fn security_false_is_omitted() {
687 let opts = Options::new().with_security(false);
688 let url = build(&IpWhois::with_key("K"), "/", opts);
689 assert!(!url.contains("security="), "got: {}", url);
690 }
691
692 #[test]
693 fn per_call_options_override_defaults() {
694 let client = IpWhois::with_key("K").with_language("ru");
695 let url = build(&client, "/", Options::new().with_lang("en"));
696 assert!(url.contains("lang=en"));
697 assert!(!url.contains("lang=ru"));
698 }
699
700 #[tokio::test]
701 async fn invalid_language_returns_error() {
702 let result = IpWhois::new()
703 .lookup_with("8.8.8.8", &Options::new().with_lang("klingon"))
704 .await;
705
706 let err = result.expect_err("expected an error");
707 assert_eq!(err.error_type(), "invalid_argument");
708 assert!(err.message().contains("klingon"));
709 }
710
711 #[tokio::test]
712 async fn bulk_lookup_refuses_empty_list() {
713 let empty: &[&str] = &[];
714 let result = IpWhois::with_key("K").bulk_lookup(empty).await;
715 let err = result.expect_err("expected an error");
716 assert_eq!(err.error_type(), "invalid_argument");
717 }
718
719 #[tokio::test]
720 async fn bulk_lookup_refuses_more_than_limit() {
721 let too_many: Vec<&str> = std::iter::repeat("8.8.8.8").take(BULK_LIMIT + 1).collect();
722 let result = IpWhois::with_key("K").bulk_lookup(too_many).await;
723 let err = result.expect_err("expected an error");
724 assert_eq!(err.error_type(), "invalid_argument");
725 }
726
727 #[tokio::test]
728 async fn bulk_lookup_accepts_vec_of_strings() {
729 let owned: Vec<String> = Vec::new();
733 let result = IpWhois::with_key("K").bulk_lookup(owned).await;
734 assert_eq!(result.expect_err("empty").error_type(), "invalid_argument");
735 }
736
737 #[test]
738 fn try_with_key_rejects_empty_string() {
739 let err = IpWhois::try_with_key("").expect_err("expected an error");
740 assert_eq!(err.error_type(), "invalid_argument");
741 }
742
743 #[test]
744 fn try_with_key_rejects_whitespace_only() {
745 let err = IpWhois::try_with_key(" \t\n").expect_err("expected an error");
746 assert_eq!(err.error_type(), "invalid_argument");
747 }
748
749 #[test]
750 fn try_with_key_accepts_valid_key() {
751 let client = IpWhois::try_with_key("KEY").expect("should accept");
752 let url = build(&client, "/", Options::default());
753 assert!(url.contains("key=KEY"));
754 }
755
756 #[test]
757 fn bulk_url_is_comma_separated() {
758 let url = build(
759 &IpWhois::with_key("K"),
760 "/bulk/8.8.8.8,1.1.1.1",
761 Options::default(),
762 );
763 assert!(url.contains("/bulk/8.8.8.8,1.1.1.1"), "got: {}", url);
764 }
765
766 #[tokio::test]
767 async fn bulk_url_with_ipv6_percent_encodes_colons_but_not_commas() {
768 let client = IpWhois::with_key("K");
773 let merged = client.defaults.merged_with(&Options::default());
774 let joined = ["2c0f:fb50:4003::", "8.8.8.8"]
775 .iter()
776 .map(|ip| urlencode(ip))
777 .collect::<Vec<_>>()
778 .join(",");
779 let url = client.build_url(&format!("/bulk/{}", joined), &merged);
780
781 assert!(
782 url.contains("/bulk/2c0f%3Afb50%3A4003%3A%3A,8.8.8.8"),
783 "got: {}",
784 url
785 );
786 assert!(
789 !url[url.find("/bulk/").unwrap()..].contains("%2C"),
790 "got: {}",
791 url
792 );
793 }
794
795 #[test]
796 fn set_language_affects_subsequent_requests() {
797 let client = IpWhois::with_key("K").with_language("de");
798 let url = build(&client, "/", Options::default());
799 assert!(url.contains("lang=de"));
800 }
801
802 #[test]
803 fn user_agent_carries_version() {
804 let ua = IpWhois::new().user_agent;
805 assert!(ua.starts_with("ipwhois-rust/"));
806 }
807
808 #[test]
809 fn supported_languages_present() {
810 for lang in ["en", "ru", "de", "es", "pt-BR", "fr", "zh-CN", "ja"] {
811 assert!(SUPPORTED_LANGUAGES.contains(&lang), "missing: {}", lang);
812 }
813 }
814
815 #[test]
816 fn bulk_limit_is_one_hundred() {
817 assert_eq!(BULK_LIMIT, 100);
818 }
819
820 #[test]
821 fn decode_lookup_success_returns_typed_response() {
822 let body = serde_json::json!({
823 "ip": "8.8.8.8",
824 "success": true,
825 "country": "United States",
826 "country_code": "US",
827 });
828 let info = decode_lookup(body).expect("decode");
829 assert!(info.success);
830 assert_eq!(info.ip.as_deref(), Some("8.8.8.8"));
831 assert_eq!(info.country_code.as_deref(), Some("US"));
832 }
833
834 #[test]
835 fn decode_lookup_success_false_becomes_api_error() {
836 let body = serde_json::json!({
839 "success": false,
840 "message": "Invalid IP address",
841 });
842 let err = decode_lookup(body).expect_err("expected an error");
843 assert_eq!(err.error_type(), "api");
844 assert_eq!(err.message(), "Invalid IP address");
845 }
846
847 #[test]
848 fn decode_bulk_array_returns_per_ip_entries() {
849 let body = serde_json::json!([
850 { "ip": "8.8.8.8", "success": true, "country_code": "US" },
851 { "ip": "999.999.999.999", "success": false, "message": "Invalid IP address" },
852 ]);
853 let rows = decode_bulk(body).expect("decode");
854 assert_eq!(rows.len(), 2);
855 assert!(rows[0].success);
856 assert!(!rows[1].success);
857 assert_eq!(rows[1].message.as_deref(), Some("Invalid IP address"));
858 }
859
860 #[test]
861 fn decode_bulk_object_means_whole_batch_failed() {
862 let body = serde_json::json!({
866 "success": false,
867 "message": "Invalid API key",
868 });
869 let err = decode_bulk(body).expect_err("expected an error");
870 assert_eq!(err.error_type(), "api");
871 assert_eq!(err.message(), "Invalid API key");
872 }
873
874 #[test]
875 fn body_snippet_collapses_whitespace_and_truncates() {
876 let snippet = body_snippet(b"<html>\n <body>oops</body>\n</html>");
878 assert_eq!(snippet, "<html> <body>oops</body> </html>");
879
880 let long: Vec<u8> = b"a".iter().cycle().take(500).copied().collect();
882 let snippet = body_snippet(&long);
883 assert!(snippet.ends_with('…'), "got: {}", snippet);
884 assert_eq!(snippet.chars().count(), 201); }
886
887 #[tokio::test]
888 async fn lookup_accepts_string_owned_and_borrowed() {
889 let client = IpWhois::new();
895
896 let owned: String = "8.8.8.8".to_string();
897 let _ = client
898 .lookup_with(&owned, &Options::new().with_lang("klingon"))
899 .await
900 .expect_err("invalid lang short-circuits before any request");
901
902 let _ = client
903 .lookup_with(owned.clone(), &Options::new().with_lang("klingon"))
904 .await
905 .expect_err("owned String works too");
906
907 let _ = client
908 .lookup_with("8.8.8.8", &Options::new().with_lang("klingon"))
909 .await
910 .expect_err("&str still works");
911 }
912}