1use reqwest::Url;
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4
5#[derive(Debug, Error)]
11pub enum IpWhoError {
12 #[error("HTTP request failed: {0}")]
14 Http(#[from] reqwest::Error),
15
16 #[error("API error (status {status}): {message}")]
18 Api { status: reqwest::StatusCode, message: String },
19
20 #[error("API returned success=false: {0}")]
22 ApiLogical(String),
23
24 #[error("JSON deserialization error: {0}")]
26 Json(#[from] serde_json::Error),
27
28 #[error("URL parse error: {0}")]
30 Url(#[from] url::ParseError),
31
32 #[error("{0}")]
34 Validation(String),
35}
36
37#[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#[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#[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#[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#[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#[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 #[serde(default, rename = "isThreat")]
141 pub is_threat: Option<String>,
142}
143
144#[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#[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#[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#[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 #[serde(default, rename = "message")]
231 pub message: Option<String>,
232}
233
234#[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#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct BulkData {
246 #[serde(default, rename = "responseArray")]
247 pub response_array: Option<Vec<IpGeoResponse>>,
248}
249
250#[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#[derive(Clone, Debug)]
269pub struct IPWhoClient {
270 http: reqwest::Client,
271 base_url: Url,
272 api_key: String,
273}
274
275impl IPWhoClient {
276 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 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 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 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 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 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