Skip to main content

ipwhois/
client.rs

1//! The [`IpWhois`] client — the single entry point of this crate.
2//!
3//! One type, two methods (`lookup`, `bulk_lookup`), fluent setters for
4//! client-wide defaults, and a uniform `Result`-based error contract.
5
6use 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
16/// Library version, used in the default `User-Agent` header.
17pub const VERSION: &str = env!("CARGO_PKG_VERSION");
18
19/// Free-plan endpoint host (used when no API key is provided).
20pub const HOST_FREE: &str = "ipwho.is";
21
22/// Paid-plan endpoint host (used when an API key is provided).
23pub const HOST_PAID: &str = "ipwhois.pro";
24
25/// Maximum number of IP addresses allowed in a single bulk request.
26pub const BULK_LIMIT: usize = 100;
27
28/// Languages supported by the `lang` option.
29pub 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/// Async client for the ipwhois.io IP Geolocation API.
35///
36/// # Quick start
37///
38/// ```no_run
39/// use ipwhois::{IpWhois, Options};
40///
41/// # async fn run() -> Result<(), ipwhois::Error> {
42/// // Free plan (no API key, ~1 request/second per client IP)
43/// let ipwhois = IpWhois::new();
44/// let info = ipwhois.lookup("8.8.8.8").await?;
45///
46/// // Paid plan (with API key, higher limits, bulk, security data, …)
47/// let ipwhois = IpWhois::with_key("YOUR_API_KEY");
48/// let info = ipwhois
49///     .lookup_with("8.8.8.8", &Options::new().with_lang("en").with_security(true))
50///     .await?;
51///
52/// // Bulk lookup — up to 100 IPs in one call (paid only)
53/// let list = ipwhois.bulk_lookup(["8.8.8.8", "1.1.1.1", "208.67.222.222"]).await?;
54/// # Ok(()) }
55/// ```
56///
57/// # Error handling
58///
59/// Every fallible method returns [`Result`]`<_, `[`Error`]`>`. The error
60/// enum carries categories (`api`, `network`, `invalid_argument`) and
61/// metadata (`http_status`, `retry_after`).
62#[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    /// Create a free-plan client (no API key).
81    ///
82    /// The free endpoint (`ipwho.is`) rate-limits roughly to 1 request per
83    /// second per client IP. Suitable for low-traffic and non-commercial
84    /// use.
85    pub fn new() -> Self {
86        Self::build(None)
87    }
88
89    /// Create a paid-plan client using the supplied API key.
90    ///
91    /// Higher limits, plus access to bulk lookups and threat-detection
92    /// data. Get your key at <https://ipwhois.io>.
93    ///
94    /// This constructor does not validate the key — an empty or
95    /// whitespace-only key will produce a client that talks to the paid
96    /// host with `?key=` and let the API itself reject the request. Use
97    /// [`try_with_key`](Self::try_with_key) if you'd rather validate
98    /// up-front.
99    pub fn with_key(api_key: impl Into<String>) -> Self {
100        Self::build(Some(api_key.into()))
101    }
102
103    /// Like [`with_key`](Self::with_key), but rejects empty or
104    /// whitespace-only keys with [`Error::InvalidArgument`] before any
105    /// request is made.
106    ///
107    /// # Example
108    ///
109    /// ```
110    /// use ipwhois::IpWhois;
111    ///
112    /// // A valid-looking key is accepted (no network call is made here).
113    /// IpWhois::try_with_key("MY_KEY").expect("constructed");
114    ///
115    /// // Empty / whitespace-only keys are rejected up-front.
116    /// assert!(IpWhois::try_with_key("").is_err());
117    /// assert!(IpWhois::try_with_key("   ").is_err());
118    /// ```
119    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            // The default reqwest builder cannot fail under normal conditions
134            // (no proxies, no custom TLS); fall back to a vanilla client so
135            // construction does not panic in user code.
136            .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    /* ------------------------------------------------------------------ */
150    /* Fluent setters — set client-wide defaults                          */
151    /* ------------------------------------------------------------------ */
152
153    /// Set the default language used when none is supplied per call.
154    /// Must be one of [`SUPPORTED_LANGUAGES`].
155    pub fn with_language(mut self, lang: impl Into<String>) -> Self {
156        self.defaults.lang = Some(lang.into());
157        self
158    }
159
160    /// Restrict every response to a fixed set of fields by default.
161    ///
162    /// Include `"success"` in the list if you rely on the success flag for
163    /// error checking — when `fields` is set, the API only returns the
164    /// fields you ask for.
165    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    /// Enable or disable the `security` block on every call by default.
175    pub fn with_security(mut self, enabled: bool) -> Self {
176        self.defaults.security = Some(enabled);
177        self
178    }
179
180    /// Enable or disable the `rate` block on every call by default.
181    pub fn with_rate(mut self, enabled: bool) -> Self {
182        self.defaults.rate = Some(enabled);
183        self
184    }
185
186    /// Set the per-request total timeout (default: 10 seconds).
187    pub fn with_timeout(mut self, timeout: Duration) -> Self {
188        self.timeout = timeout;
189        self.rebuild_http();
190        self
191    }
192
193    /// Set the connection timeout (default: 5 seconds).
194    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
195        self.connect_timeout = timeout;
196        self.rebuild_http();
197        self
198    }
199
200    /// Override the `User-Agent` header sent with every request.
201    pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
202        self.user_agent = ua.into();
203        self
204    }
205
206    /// Toggle HTTPS. On by default; pass `false` to fall back to HTTP
207    /// (e.g. in environments without an up-to-date CA bundle).
208    ///
209    /// > HTTPS is strongly recommended for production traffic — your API
210    /// > key is sent in the query string and would otherwise travel in
211    /// > clear text.
212    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    /* ------------------------------------------------------------------ */
229    /* Public API                                                          */
230    /* ------------------------------------------------------------------ */
231
232    /// Look up a single IP address using the client's default options.
233    ///
234    /// Accepts any string-like value (`&str`, `String`, `&String`, `Cow<str>`, …).
235    /// To look up the caller's own public IP, use [`lookup_self`](Self::lookup_self).
236    ///
237    /// # Example
238    ///
239    /// ```no_run
240    /// # use ipwhois::IpWhois;
241    /// # async fn run() -> Result<(), ipwhois::Error> {
242    /// let ipwhois = IpWhois::new();
243    /// let info = ipwhois.lookup("8.8.8.8").await?;
244    /// println!("{} — {}", info.ip.unwrap_or_default(), info.country.unwrap_or_default());
245    ///
246    /// // String / &String also work, no `.as_str()` needed:
247    /// let owned = String::from("1.1.1.1");
248    /// let info = ipwhois.lookup(&owned).await?;
249    /// # Ok(()) }
250    /// ```
251    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    /// Look up a single IP address with per-call option overrides.
256    ///
257    /// Per-call options always win over the client's defaults.
258    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    /// Look up the caller's own public IP, using the client's default
267    /// options.
268    ///
269    /// Equivalent to `GET /` against the API endpoint, as documented at
270    /// <https://ipwhois.io/documentation>.
271    pub async fn lookup_self(&self) -> Result<LookupResponse, Error> {
272        self.do_lookup(None, &Options::default()).await
273    }
274
275    /// Look up the caller's own public IP with per-call option overrides.
276    pub async fn lookup_self_with(&self, options: &Options) -> Result<LookupResponse, Error> {
277        self.do_lookup(None, options).await
278    }
279
280    /// Internal worker shared by the four `lookup*` entry points.
281    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    /// Look up multiple IP addresses in a single request.
300    ///
301    /// Uses the GET / comma-separated form of the bulk endpoint
302    /// (<https://ipwhois.io/documentation/bulk>). Up to
303    /// [`BULK_LIMIT`] addresses per call. Each address counts as one
304    /// credit. Available on the **Business** and **Unlimited** plans only.
305    ///
306    /// Per-IP errors come back inline with `success = false` for the
307    /// affected entry; the rest of the batch is still usable. Whole-batch
308    /// failures (network outage, bad API key, rate limit, …) surface as
309    /// [`Err`].
310    ///
311    /// Accepts any iterable of string-like items, so `&[&str]`,
312    /// `Vec<String>`, `&[String]`, an iterator chain, etc. all work.
313    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    /// Same as [`bulk_lookup`](Self::bulk_lookup) with per-call option
322    /// overrides.
323    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        // The API expects the addresses joined by literal commas — the commas
351        // themselves must NOT be percent-encoded, otherwise the path is
352        // misinterpreted. Each address still gets URL-encoded individually.
353        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    /* ------------------------------------------------------------------ */
366    /* Internals                                                          */
367    /* ------------------------------------------------------------------ */
368
369    /// Build the full URL for a given path and effective options.
370    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    /// Perform a GET request and return the parsed JSON body. HTTP-level
413    /// errors are normalised into [`Error::Api`]. Network errors map to
414    /// [`Error::Network`].
415    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        // Decode body. The ipwhois API always returns JSON; a non-JSON body
445        // means something went wrong upstream (gateway error page, captive
446        // portal, …) — surface it as an Api error.
447        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            // For HTTP errors, normalise into Error::Api so callers don't
469            // have to inspect HTTP status separately.
470            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            // Retry-After is only sent by the free-plan endpoint
477            // (ipwho.is); the paid endpoint does not emit the header, so
478            // only attach it when there's no API key configured.
479            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
492/// Validate per-call options. Returns the first invalid option as
493/// [`Error::InvalidArgument`], so no request is sent for malformed input.
494fn 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
507/// Decode a single-IP response. HTTP 2xx + `success = false` (e.g. "Invalid
508/// IP address", "Reserved range") becomes [`Error::Api`] so the
509/// `Result` always matches the caller's intent.
510fn decode_lookup(value: Value) -> Result<LookupResponse, Error> {
511    // If the body is `success: false`, raise it as an Api error so the
512    // caller doesn't have to inspect the success flag manually after every
513    // call.
514    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
532/// Decode a bulk-endpoint response. Successful bulk responses are JSON
533/// arrays; whole-batch failures come back as a single object with
534/// `success = false`.
535fn 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            // Whole-batch failure shaped as `{success: false, message: ...}`.
546            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
561/// Percent-encode a single path segment. We avoid pulling in a
562/// percent-encoding crate and do the minimal RFC3986 unreserved-set escape
563/// inline — enough for IP addresses, which is all this path ever contains.
564fn 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
580/// `application/x-www-form-urlencoded` encoding for query-string keys and
581/// values. Spaces become `+`, everything outside the unreserved set is
582/// percent-encoded — including commas (`%2C`), which the unit tests pin
583/// down explicitly so future refactors don't drift.
584fn 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
601/// Produce a short, single-line snippet of a body for inclusion in error
602/// messages. Used when the API returns something that isn't valid JSON
603/// (gateway error page, captive portal, …) and we want to surface a hint
604/// of what came back without blowing up the log line.
605fn 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/* ---------------------------------------------------------------------- */
617/* Unit tests — URL construction and option validation, no real HTTP.      */
618/* ---------------------------------------------------------------------- */
619
620#[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        // url::form_urlencoded percent-encodes commas, so check the encoded form.
670        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        // Exercises the AsRef<str> generic — Vec<String> should work
730        // without manual conversion. Empty list still rejected the same
731        // way.
732        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        // Verifies that the per-address percent-encoding hits the colons
769        // in IPv6 addresses (%3A) without escaping the commas separating
770        // addresses (those must stay literal, otherwise the API
771        // misinterprets the path).
772        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        // Sanity: commas separating addresses are NOT percent-encoded
787        // inside the path itself.
788        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        // HTTP 2xx + `success: false` (e.g. "Reserved range") is treated
837        // as Error::Api so the single-IP path is uniformly Result-shaped.
838        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        // A bare `{success: false, ...}` object instead of an array means
863        // the whole batch failed (bad API key, rate limit, …) — surface
864        // as a single Error::Api.
865        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        // Short, multi-line bodies get folded onto a single line.
877        let snippet = body_snippet(b"<html>\n  <body>oops</body>\n</html>");
878        assert_eq!(snippet, "<html> <body>oops</body> </html>");
879
880        // Long bodies are truncated with an ellipsis.
881        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); // 200 chars + '…'
885    }
886
887    #[tokio::test]
888    async fn lookup_accepts_string_owned_and_borrowed() {
889        // The point of the `impl AsRef<str>` signature: String / &String
890        // / &str all work without an explicit conversion. We can't make a
891        // real HTTP call here, but we can confirm the call typechecks
892        // against an empty client by going through the language
893        // validator (which fires before the request).
894        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}