Skip to main content

ipwho_sdk/
lib.rs

1use reqwest::Url;
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4
5// ═══════════════════════════════════════════════════════════════════════════
6// Error types
7// ═══════════════════════════════════════════════════════════════════════════
8
9/// Errors that can occur when using the IPWho API.
10#[derive(Debug, Error)]
11pub enum IpWhoError {
12    /// Network / HTTP transport error.
13    #[error("HTTP request failed: {0}")]
14    Http(#[from] reqwest::Error),
15
16    /// The API returned an unsuccessful HTTP status.
17    #[error("API error (status {status}): {message}")]
18    Api { status: reqwest::StatusCode, message: String },
19
20    /// The API responded with `success: false` and an optional message.
21    #[error("API returned success=false: {0}")]
22    ApiLogical(String),
23
24    /// Failed to parse the JSON response body.
25    #[error("JSON deserialization error: {0}")]
26    Json(#[from] serde_json::Error),
27
28    /// URL construction error.
29    #[error("URL parse error: {0}")]
30    Url(#[from] url::ParseError),
31
32    /// Client-side validation error.
33    #[error("{0}")]
34    Validation(String),
35}
36
37// ═══════════════════════════════════════════════════════════════════════════
38// Domain models — exact schema from ipwho-openapi.yaml
39// ═══════════════════════════════════════════════════════════════════════════
40
41/// Geographic location data for the queried IP.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct GeoLocation {
44    #[serde(default, rename = "continent")]
45    pub continent: Option<String>,
46    #[serde(default, rename = "continentCode")]
47    pub continent_code: Option<String>,
48    #[serde(default, rename = "country")]
49    pub country: Option<String>,
50    #[serde(default, rename = "countryCode")]
51    pub country_code: Option<String>,
52    #[serde(default, rename = "capital")]
53    pub capital: Option<String>,
54    #[serde(default, rename = "region")]
55    pub region: Option<String>,
56    #[serde(default, rename = "regionCode")]
57    pub region_code: Option<String>,
58    #[serde(default, rename = "city")]
59    pub city: Option<String>,
60    #[serde(default, rename = "postal_Code")]
61    pub postal_code: Option<String>,
62    #[serde(default, rename = "dial_code")]
63    pub dial_code: Option<String>,
64    #[serde(default, rename = "is_in_eu")]
65    pub is_in_eu: Option<bool>,
66    #[serde(default, rename = "latitude")]
67    pub latitude: Option<f64>,
68    #[serde(default, rename = "longitude")]
69    pub longitude: Option<f64>,
70    #[serde(default, rename = "accuracy_radius")]
71    pub accuracy_radius: Option<f64>,
72}
73
74/// Timezone information for the queried IP.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Timezone {
77    #[serde(default, rename = "time_zone")]
78    pub time_zone: Option<String>,
79    #[serde(default, rename = "abbr")]
80    pub abbr: Option<String>,
81    #[serde(default, rename = "offset")]
82    pub offset: Option<i64>,
83    #[serde(default, rename = "is_dst")]
84    pub is_dst: Option<bool>,
85    #[serde(default, rename = "utc")]
86    pub utc: Option<String>,
87    #[serde(default, rename = "current_time")]
88    pub current_time: Option<String>,
89}
90
91/// Flag information for the IP's country.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Flag {
94    #[serde(default, rename = "flag_Icon")]
95    pub flag_icon: Option<String>,
96    #[serde(default, rename = "flag_unicode")]
97    pub flag_unicode: Option<String>,
98}
99
100/// Currency information for the IP's country.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Currency {
103    #[serde(default, rename = "code")]
104    pub code: Option<String>,
105    #[serde(default, rename = "symbol")]
106    pub symbol: Option<String>,
107    #[serde(default, rename = "name")]
108    pub name: Option<String>,
109    #[serde(default, rename = "name_plural")]
110    pub name_plural: Option<String>,
111    #[serde(default, rename = "hex_unicode")]
112    pub hex_unicode: Option<String>,
113}
114
115/// Network connection details for the queried IP.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Connection {
118    #[serde(default, rename = "asn_number")]
119    pub asn_number: Option<i64>,
120    #[serde(default, rename = "asn_org")]
121    pub asn_org: Option<String>,
122    #[serde(default, rename = "isp")]
123    pub isp: Option<String>,
124    #[serde(default, rename = "org")]
125    pub org: Option<String>,
126    #[serde(default, rename = "domain")]
127    pub domain: Option<String>,
128    #[serde(default, rename = "connection_type")]
129    pub connection_type: Option<String>,
130}
131
132/// Security / threat intelligence for the queried IP.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct Security {
135    #[serde(default, rename = "isVpn")]
136    pub is_vpn: Option<bool>,
137    #[serde(default, rename = "isTor")]
138    pub is_tor: Option<bool>,
139    /// One of: "low", "medium", "high"
140    #[serde(default, rename = "isThreat")]
141    pub is_threat: Option<String>,
142}
143
144// User-agent sub-types
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct Browser {
148    #[serde(default, rename = "name")]
149    pub name: Option<String>,
150    #[serde(default, rename = "version")]
151    pub version: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct Engine {
156    #[serde(default, rename = "name")]
157    pub name: Option<String>,
158    #[serde(default, rename = "version")]
159    pub version: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct OS {
164    #[serde(default, rename = "name")]
165    pub name: Option<String>,
166    #[serde(default, rename = "version")]
167    pub version: Option<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct Device {
172    #[serde(default, rename = "type")]
173    pub type_field: Option<String>,
174    #[serde(default, rename = "vendor")]
175    pub vendor: Option<String>,
176    #[serde(default, rename = "model")]
177    pub model: Option<String>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct CPU {
182    #[serde(default, rename = "architecture")]
183    pub architecture: Option<String>,
184}
185
186/// User-agent details parsed from the IP's traffic.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct UserAgent {
189    #[serde(default, rename = "browser")]
190    pub browser: Option<Browser>,
191    #[serde(default, rename = "engine")]
192    pub engine: Option<Engine>,
193    #[serde(default, rename = "os")]
194    pub os: Option<OS>,
195    #[serde(default, rename = "device")]
196    pub device: Option<Device>,
197    #[serde(default, rename = "cpu")]
198    pub cpu: Option<CPU>,
199}
200
201/// The `data` payload inside a successful `IpGeoResponse`.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct GeoData {
204    #[serde(default, rename = "ip")]
205    pub ip: String,
206    #[serde(default, rename = "geoLocation")]
207    pub geo_location: Option<GeoLocation>,
208    #[serde(default, rename = "timezone")]
209    pub timezone: Option<Timezone>,
210    #[serde(default, rename = "flag")]
211    pub flag: Option<Flag>,
212    #[serde(default, rename = "currency")]
213    pub currency: Option<Currency>,
214    #[serde(default, rename = "connection")]
215    pub connection: Option<Connection>,
216    #[serde(default, rename = "security")]
217    pub security: Option<Security>,
218    #[serde(default, rename = "userAgent")]
219    pub user_agent: Option<UserAgent>,
220}
221
222/// Top-level API response.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct IpGeoResponse {
225    #[serde(default, rename = "success")]
226    pub success: bool,
227    #[serde(default, rename = "data")]
228    pub data: Option<GeoData>,
229    /// Present only when `success` is `false`.
230    #[serde(default, rename = "message")]
231    pub message: Option<String>,
232}
233
234/// Error payload returned on non-200 responses.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct ErrorResponse {
237    #[serde(default, rename = "success")]
238    pub success: bool,
239    #[serde(default, rename = "message")]
240    pub message: Option<String>,
241}
242
243/// Wraps the `responseArray` from the bulk endpoint.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct BulkData {
246    #[serde(default, rename = "responseArray")]
247    pub response_array: Option<Vec<IpGeoResponse>>,
248}
249
250/// Response from the `/bulk/{bulkIP}` endpoint.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct BulkResponse {
253    #[serde(default, rename = "success")]
254    pub success: bool,
255    #[serde(default = "default_data")]
256    pub data: Option<BulkData>,
257}
258
259fn default_data() -> Option<BulkData> {
260    None
261}
262
263// ═══════════════════════════════════════════════════════════════════════════
264// Client
265// ═══════════════════════════════════════════════════════════════════════════
266
267/// Rust client for the IPWho IP Geolocation API.
268#[derive(Clone, Debug)]
269pub struct IPWhoClient {
270    http: reqwest::Client,
271    base_url: Url,
272    api_key: String,
273}
274
275impl IPWhoClient {
276    /// Create a new client with the given API key.
277    ///
278    /// `api_key` is required by the API as the `apiKey` query parameter.
279    pub fn new<S: Into<String>>(api_key: S) -> Result<Self, IpWhoError> {
280        let http = reqwest::Client::builder()
281            .user_agent("ipwho-rust-sdk/1.0.0")
282            .build()?;
283        let base_url = Url::parse("https://api.ipwho.org")?;
284        Ok(Self {
285            http,
286            base_url,
287            api_key: api_key.into(),
288        })
289    }
290
291    /// Create a new client with a custom HTTP client and base URL.
292    pub fn with_client<S: Into<String>>(
293        api_key: S,
294        http: reqwest::Client,
295        base_url: Url,
296    ) -> Self {
297        Self {
298            http,
299            base_url,
300            api_key: api_key.into(),
301        }
302    }
303
304    // ── Public API ─────────────────────────────────────────────────────
305
306    /// Look up geolocation data for a specific IP address.
307    ///
308    /// - `ip`: IPv4 or IPv6 address.
309    /// - `format`: Response format (`"json"`, `"xml"`, `"csv"`). Default is `"json"`.
310    /// - `fields`: Optional comma-separated field filter (e.g. `"geoLocation,timezone"`).
311    pub async fn lookup(
312        &self,
313        ip: &str,
314        format: Option<&str>,
315        fields: Option<&str>,
316    ) -> Result<IpGeoResponse, IpWhoError> {
317        let path = format!("/ip/{ip}");
318        self.request(&path, format, fields).await
319    }
320
321    /// Look up geolocation data for the caller's own IP address.
322    ///
323    /// - `format`: Response format.
324    /// - `fields`: Optional comma-separated field filter.
325    pub async fn me(
326        &self,
327        format: Option<&str>,
328        fields: Option<&str>,
329    ) -> Result<IpGeoResponse, IpWhoError> {
330        self.request("/me", format, fields).await
331    }
332
333    /// Perform a bulk IP lookup.
334    ///
335    /// - `ips`: Slice of IPv4/IPv6 addresses.
336    pub async fn bulk(&self, ips: &[&str]) -> Result<BulkResponse, IpWhoError> {
337        if ips.is_empty() {
338            return Err(IpWhoError::Validation("IP list must not be empty".into()));
339        }
340        let bulk_param = ips.join(",");
341        let path = format!("/bulk/{bulk_param}");
342
343        let mut url = self.base_url.join(&path).map_err(IpWhoError::Url)?;
344        url.query_pairs_mut()
345            .append_pair("apiKey", &self.api_key);
346
347        let resp = self.http.get(url.clone()).send().await?;
348        let status = resp.status();
349        let text = resp.text().await?;
350
351        if !status.is_success() {
352            return Err(self.parse_error(status, &text));
353        }
354
355        let parsed: BulkResponse = serde_json::from_str(&text)?;
356        if !parsed.success {
357            return Err(IpWhoError::ApiLogical(
358                "Bulk API returned success=false".into(),
359            ));
360        }
361        Ok(parsed)
362    }
363
364    // ── Internal ───────────────────────────────────────────────────────
365
366    async fn request(
367        &self,
368        path: &str,
369        format: Option<&str>,
370        fields: Option<&str>,
371    ) -> Result<IpGeoResponse, IpWhoError> {
372        let mut url = self.base_url.join(path).map_err(IpWhoError::Url)?;
373
374        {
375            let mut qp = url.query_pairs_mut();
376            qp.append_pair("apiKey", &self.api_key);
377            if let Some(f) = format {
378                if f != "json" {
379                    qp.append_pair("format", f);
380                }
381            }
382            if let Some(f) = fields {
383                if !f.is_empty() {
384                    qp.append_pair("get", f);
385                }
386            }
387        }
388
389        let resp = self.http.get(url.clone()).send().await?;
390        let status = resp.status();
391        let text = resp.text().await?;
392
393        if !status.is_success() {
394            return Err(self.parse_error(status, &text));
395        }
396
397        let parsed: IpGeoResponse = serde_json::from_str(&text)?;
398        if !parsed.success {
399            return Err(IpWhoError::ApiLogical(
400                parsed
401                    .message
402                    .clone()
403                    .unwrap_or_else(|| "API returned success=false".into()),
404            ));
405        }
406        Ok(parsed)
407    }
408
409    fn parse_error(&self, status: reqwest::StatusCode, body: &str) -> IpWhoError {
410        if let Ok(err) = serde_json::from_str::<ErrorResponse>(body) {
411            IpWhoError::Api {
412                status,
413                message: err.message.unwrap_or_else(|| "unknown error".into()),
414            }
415        } else {
416            IpWhoError::Api {
417                status,
418                message: body.to_string(),
419            }
420        }
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn deserialize_single_ip_response() {
430        let json = r#"{
431            "success": true,
432            "data": {
433                "ip": "8.8.8.8",
434                "geoLocation": {
435                    "continent": "North America",
436                    "continent_code": "NA",
437                    "country": "United States",
438                    "country_code": "US",
439                    "capital": "Washington",
440                    "region": "California",
441                    "region_code": "CA",
442                    "city": "Mountain View",
443                    "postal_Code": "94043",
444                    "dial_code": "1",
445                    "is_in_eu": false,
446                    "latitude": 37.4056,
447                    "longitude": -122.0775,
448                    "accuracy_radius": 10.0
449                }
450            }
451        }"#;
452        let resp: IpGeoResponse = serde_json::from_str(json).unwrap();
453        assert!(resp.success);
454        let data = resp.data.unwrap();
455        assert_eq!(data.ip, "8.8.8.8");
456        let gl = data.geo_location.unwrap();
457        assert_eq!(gl.country.as_deref(), Some("United States"));
458        assert_eq!(gl.city.as_deref(), Some("Mountain View"));
459    }
460
461    #[test]
462    fn deserialize_bulk_response() {
463        let json = r#"{
464            "success": true,
465            "data": {
466                "responseArray": [
467                    {
468                        "success": true,
469                        "data": {
470                            "ip": "8.8.8.8"
471                        }
472                    },
473                    {
474                        "success": true,
475                        "data": {
476                            "ip": "1.1.1.1"
477                        }
478                    }
479                ]
480            }
481        }"#;
482        let resp: BulkResponse = serde_json::from_str(json).unwrap();
483        assert!(resp.success);
484        let items = resp.data.unwrap().response_array.unwrap();
485        assert_eq!(items.len(), 2);
486        assert_eq!(items[0].data.as_ref().unwrap().ip, "8.8.8.8");
487        assert_eq!(items[1].data.as_ref().unwrap().ip, "1.1.1.1");
488    }
489
490    #[test]
491    fn deserialize_error() {
492        let json = r#"{"success": false, "message": "Invalid API key"}"#;
493        let err: ErrorResponse = serde_json::from_str(json).unwrap();
494        assert!(!err.success);
495        assert_eq!(err.message.as_deref(), Some("Invalid API key"));
496    }
497}
498