Skip to main content

ip_api_io/
client.rs

1use std::time::Duration;
2
3use serde::de::DeserializeOwned;
4use serde_json::json;
5
6use crate::error::{classify, extract_message, Error};
7use crate::models::*;
8use crate::{check_batch, encode_segment, DEFAULT_BASE_URL, USER_AGENT};
9
10/// Async client for the ip-api.io API.
11///
12/// ```no_run
13/// # async fn run() -> Result<(), ip_api_io::Error> {
14/// let client = ip_api_io::Client::with_api_key("YOUR_API_KEY");
15/// let info = client.lookup_ip("8.8.8.8").await?;
16/// println!("{:?}", info.location.country);
17/// # Ok(())
18/// # }
19/// ```
20#[derive(Debug, Clone)]
21pub struct Client {
22    api_key: Option<String>,
23    base_url: String,
24    http: reqwest::Client,
25}
26
27/// Builder for [`Client`].
28#[derive(Debug, Default)]
29pub struct ClientBuilder {
30    api_key: Option<String>,
31    base_url: Option<String>,
32    timeout: Option<Duration>,
33}
34
35impl ClientBuilder {
36    /// API key — get a free key at <https://ip-api.io>. Sent as the `api_key`
37    /// query parameter.
38    pub fn api_key(mut self, key: impl Into<String>) -> Self {
39        self.api_key = Some(key.into());
40        self
41    }
42
43    /// Override the API origin (testing).
44    pub fn base_url(mut self, base: impl Into<String>) -> Self {
45        self.base_url = Some(base.into());
46        self
47    }
48
49    /// Per-request timeout (default 10s).
50    pub fn timeout(mut self, timeout: Duration) -> Self {
51        self.timeout = Some(timeout);
52        self
53    }
54
55    pub fn build(self) -> Client {
56        let http = reqwest::Client::builder()
57            .timeout(self.timeout.unwrap_or(Duration::from_secs(10)))
58            .build()
59            .expect("failed to build reqwest client");
60        Client {
61            api_key: self.api_key,
62            base_url: self
63                .base_url
64                .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
65                .trim_end_matches('/')
66                .to_string(),
67            http,
68        }
69    }
70}
71
72impl Default for Client {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl Client {
79    /// Client without an API key (the live API rejects keyless requests —
80    /// prefer [`Client::with_api_key`]).
81    pub fn new() -> Self {
82        Self::builder().build()
83    }
84
85    /// Client authenticated with an API key.
86    pub fn with_api_key(key: impl Into<String>) -> Self {
87        Self::builder().api_key(key).build()
88    }
89
90    pub fn builder() -> ClientBuilder {
91        ClientBuilder::default()
92    }
93
94    // -- IP intelligence ------------------------------------------------------
95
96    /// Geolocation + threat intelligence for the caller's IP.
97    pub async fn lookup(&self) -> Result<IpInfo, Error> {
98        self.get("/api/v1/ip".into()).await
99    }
100
101    /// Geolocation + threat intelligence for a specific IP.
102    pub async fn lookup_ip(&self, ip: &str) -> Result<IpInfo, Error> {
103        self.get(format!("/api/v1/ip/{}", encode_segment(ip))).await
104    }
105
106    /// Look up to 100 IP addresses in a single request.
107    pub async fn lookup_batch(&self, ips: &[&str]) -> Result<BatchIpLookupResponse, Error> {
108        check_batch(ips, "ips")?;
109        self.post("/api/v1/ip/batch".into(), json!({ "ips": ips })).await
110    }
111
112    /// IP reputation check (untyped map — shape may evolve).
113    pub async fn ip_reputation(&self, ip: &str) -> Result<serde_json::Value, Error> {
114        self.get(format!("/api/v1/ip-reputation/{}", encode_segment(ip))).await
115    }
116
117    /// Whether an IP is a Tor exit node.
118    pub async fn tor_check(&self, ip: &str) -> Result<TorDetection, Error> {
119        self.get(format!("/api/v1/tor/{}", encode_segment(ip))).await
120    }
121
122    /// Autonomous system lookup for an IP.
123    pub async fn asn(&self, ip: &str) -> Result<AsnLookup, Error> {
124        self.get(format!("/api/v1/asn/{}", encode_segment(ip))).await
125    }
126
127    // -- Email validation -------------------------------------------------------
128
129    /// Syntax, disposability and MX analysis of an email address.
130    pub async fn email_info(&self, email: &str) -> Result<EmailInfo, Error> {
131        self.get(format!("/api/v1/email/{}", encode_segment(email))).await
132    }
133
134    /// Advanced validation including SMTP deliverability checks.
135    pub async fn validate_email(&self, email: &str) -> Result<AdvancedEmailValidation, Error> {
136        self.get(format!("/api/v1/email/advanced/{}", encode_segment(email))).await
137    }
138
139    /// Advanced-validate up to 100 email addresses in a single request.
140    pub async fn validate_email_batch(
141        &self,
142        emails: &[&str],
143    ) -> Result<BatchEmailValidationResponse, Error> {
144        check_batch(emails, "emails")?;
145        self.post("/api/v1/email/advanced/batch".into(), json!({ "emails": emails }))
146            .await
147    }
148
149    // -- Risk scoring -----------------------------------------------------------
150
151    /// Fraud risk score for the caller's IP.
152    pub async fn risk_score(&self) -> Result<RiskScore, Error> {
153        self.get("/api/v1/risk-score".into()).await
154    }
155
156    /// Fraud risk score for a specific IP.
157    pub async fn risk_score_ip(&self, ip: &str) -> Result<RiskScore, Error> {
158        self.get(format!("/api/v1/risk-score/{}", encode_segment(ip))).await
159    }
160
161    /// Fraud risk score for an email address.
162    pub async fn email_risk_score(&self, email: &str) -> Result<RiskScore, Error> {
163        self.get(format!("/api/v1/risk-score/email/{}", encode_segment(email)))
164            .await
165    }
166
167    // -- DNS & domains ----------------------------------------------------------
168
169    pub async fn whois(&self, domain: &str) -> Result<Whois, Error> {
170        self.get(format!("/api/v1/dns/whois/{}", encode_segment(domain))).await
171    }
172
173    pub async fn reverse_dns(&self, ip: &str) -> Result<ReverseDns, Error> {
174        self.get(format!("/api/v1/dns/reverse/{}", encode_segment(ip))).await
175    }
176
177    pub async fn forward_dns(&self, hostname: &str) -> Result<ForwardDns, Error> {
178        self.get(format!("/api/v1/dns/forward/{}", encode_segment(hostname)))
179            .await
180    }
181
182    pub async fn mx_records(&self, domain: &str) -> Result<MxLookup, Error> {
183        self.get(format!("/api/v1/dns/mx/{}", encode_segment(domain))).await
184    }
185
186    pub async fn domain_age(&self, domain: &str) -> Result<DomainAge, Error> {
187        self.get(format!("/api/v1/domain/age/{}", encode_segment(domain))).await
188    }
189
190    pub async fn domain_age_batch(
191        &self,
192        domains: &[&str],
193    ) -> Result<BatchDomainAgeResponse, Error> {
194        if domains.is_empty() {
195            return Err(Error::InvalidArgument("domains must not be empty".into()));
196        }
197        self.post("/api/v1/domain/age/batch".into(), json!({ "domains": domains }))
198            .await
199    }
200
201    // -- Account ----------------------------------------------------------------
202
203    pub async fn rate_limit(&self) -> Result<RateLimitInfo, Error> {
204        self.get("/api/v1/ratelimit".into()).await
205    }
206
207    pub async fn usage_summary(&self) -> Result<UsageSummary, Error> {
208        self.get("/api/v1/usage/summary".into()).await
209    }
210
211    // -- Internals ------------------------------------------------------------
212
213    async fn get<T: DeserializeOwned>(&self, path: String) -> Result<T, Error> {
214        self.request(reqwest::Method::GET, path, None).await
215    }
216
217    async fn post<T: DeserializeOwned>(
218        &self,
219        path: String,
220        body: serde_json::Value,
221    ) -> Result<T, Error> {
222        self.request(reqwest::Method::POST, path, Some(body)).await
223    }
224
225    async fn request<T: DeserializeOwned>(
226        &self,
227        method: reqwest::Method,
228        path: String,
229        body: Option<serde_json::Value>,
230    ) -> Result<T, Error> {
231        let mut request = self
232            .http
233            .request(method, format!("{}{}", self.base_url, path))
234            .header(reqwest::header::USER_AGENT, USER_AGENT)
235            .header(reqwest::header::ACCEPT, "application/json");
236        if let Some(key) = &self.api_key {
237            request = request.query(&[("api_key", key)]);
238        }
239        if let Some(body) = body {
240            request = request.json(&body);
241        }
242
243        let response = request.send().await?;
244        let status = response.status().as_u16();
245        if !response.status().is_success() {
246            let limit = header_i64(&response, "x-ratelimit-limit");
247            let remaining = header_i64(&response, "x-ratelimit-remaining");
248            let reset = header_i64(&response, "x-ratelimit-reset");
249            let body = response.text().await.unwrap_or_default();
250            let message = extract_message(status, &body);
251            if status == 429 {
252                return Err(Error::RateLimit {
253                    status,
254                    message,
255                    body,
256                    limit,
257                    remaining,
258                    reset,
259                });
260            }
261            return Err(classify(status, message, body));
262        }
263        Ok(response.json::<T>().await?)
264    }
265}
266
267fn header_i64(response: &reqwest::Response, name: &str) -> Option<i64> {
268    response
269        .headers()
270        .get(name)
271        .and_then(|value| value.to_str().ok())
272        .and_then(|value| value.parse().ok())
273}