Skip to main content

rfham_geo/geoip/providers/
mod.rs

1//! Concrete [`Provider`](crate::geoip::Provider) implementations.
2//!
3//! | Type | Source | License |
4//! |------|--------|---------|
5//! | [`GeoIpLookup`] | `json.geoiplookup.io` public REST API | Public |
6//! | [`NoOp`] | Always returns `None` — useful for tests | Public |
7//!
8//! The [`local`] sub-module additionally provides [`local::IpNetwork`] for CIDR-range
9//! matching, which is used by local lookup tables that may be added in the future.
10
11use crate::{
12    error::{GeoError, GeoResult},
13    geoip::{Asn, Code, GeoLocation, IpGeoData, Locale, Location, Provider, ProviderDataLicense},
14};
15use lat_long::{Coordinate, Latitude, Longitude};
16use rfham_core::error::CoreError;
17use serde::{Deserialize, Serialize};
18use std::{fmt::Debug, net::IpAddr};
19
20// ------------------------------------------------------------------------------------------------
21// Public Macros
22// ------------------------------------------------------------------------------------------------
23
24// ------------------------------------------------------------------------------------------------
25// Public Types
26// ------------------------------------------------------------------------------------------------
27
28/// Describe this struct.
29///
30/// # Examples
31///
32/// ```rust,no_run
33/// use rfham_geo::geoip::{Provider, providers::GeoIpLookup};
34/// use std::{net::IpAddr, str::FromStr};
35///
36/// let service = GeoIpLookup();
37/// match service.lookup(&IpAddr::from_str("23.64.167.34").unwrap()) {
38///     Ok(Some(data)) => println!("Found data: {data:#?}"),
39///     Ok(None) => println!("No data for IP address"),
40///     Err(e) => println!("Service error: {e}"),
41/// }
42///
43/// ```
44///
45/// This uses the publicly accessible API at `https://json.geoiplookup.io/{ip}` which returns a
46/// structure as shown below.
47///
48/// ```json
49/// {
50///     "ip": "23.64.167.34",
51///     "isp": "Akamai Technologies, Inc.",
52///     "org": "Akamai Technologies, Inc.",
53///     "hostname": "",
54///     "latitude": 32.814,
55///     "longitude": -96.9489,
56///     "postal_code": "",
57///     "city": "Irving",
58///     "country_code": "US",
59///     "country_name": "United States",
60///     "continent_code": "NA",
61///     "continent_name": "North America",
62///     "region": "Texas",
63///     "district": "",
64///     "timezone_name": "America/Chicago",
65///     "connection_type": "Corporate",
66///     "asn_number": 16625,
67///     "asn_org": "Akamai Technologies, Inc.",
68///     "asn": "AS16625 - Akamai Technologies, Inc.",
69///     "currency_code": "USD",
70///     "currency_name": "United States Dollar",
71///     "language_code": "en",
72///     "language_name": "English",
73///     "success": true,
74///     "premium": false
75/// }
76/// ```
77///
78#[derive(Clone, Copy, Debug, Default, PartialEq)]
79pub struct GeoIpLookup();
80
81#[derive(Clone, Copy, Debug, Default, PartialEq)]
82pub struct NoOp();
83
84// ------------------------------------------------------------------------------------------------
85// Public Functions
86// ------------------------------------------------------------------------------------------------
87
88// ------------------------------------------------------------------------------------------------
89// Private Macros
90// ------------------------------------------------------------------------------------------------
91
92// ------------------------------------------------------------------------------------------------
93// Private Types
94// ------------------------------------------------------------------------------------------------
95
96#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
97struct GeoIpResponse {
98    ip: IpAddr,
99    isp: String,
100    org: String,
101    hostname: String,
102    latitude: Latitude,
103    longitude: Longitude,
104    postal_code: String,
105    city: String,
106    country_code: String,
107    country_name: String,
108    continent_code: String,
109    continent_name: String,
110    region: String,
111    district: String,
112    timezone_name: String,
113    connection_type: String,
114    asn_number: u64,
115    asn_org: String,
116    asn: String,
117    currency_name: String,
118    currency_code: String,
119    language_code: String,
120    language_name: String,
121    success: bool,
122    premium: bool,
123}
124
125// ------------------------------------------------------------------------------------------------
126// Implementations
127// ------------------------------------------------------------------------------------------------
128
129const GEOIP_LOOKUP_URL: &str = "https://json.geoiplookup.io/";
130
131impl Provider for GeoIpLookup {
132    fn lookup(&self, address: &std::net::IpAddr) -> GeoResult<Option<IpGeoData>> {
133        let response = reqwest::blocking::get(format!("{GEOIP_LOOKUP_URL}{address}"))?;
134        if response.status().is_success() {
135            let body = response.text()?;
136            let parsed: GeoIpResponse = serde_json::from_str(&body)?;
137            if parsed.success {
138                Ok(Some(parsed.try_into()?))
139            } else {
140                Ok(None)
141            }
142        } else {
143            Err(GeoError::Http(response.status()))
144        }
145    }
146
147    fn license(&self) -> ProviderDataLicense {
148        ProviderDataLicense::Public
149    }
150}
151
152// ------------------------------------------------------------------------------------------------
153
154impl TryFrom<GeoIpResponse> for IpGeoData {
155    type Error = CoreError;
156
157    fn try_from(response: GeoIpResponse) -> Result<Self, Self::Error> {
158        let location = Location::new(
159            Code::new(response.continent_code.parse()?, response.continent_name),
160            Code::new(response.country_code.parse()?, response.country_name),
161        );
162        let location = if !response.region.is_empty() {
163            location.with_region(response.region)
164        } else {
165            location
166        };
167        let location = if !response.city.is_empty() {
168            location.with_city(response.city)
169        } else {
170            location
171        };
172        let location = if !response.district.is_empty() {
173            location.with_district(response.district)
174        } else {
175            location
176        };
177        let location = if !response.postal_code.is_empty() {
178            location.with_postal_code(response.postal_code)
179        } else {
180            location
181        };
182        let location = location.with_location(GeoLocation::new(Coordinate::new(
183            response.latitude,
184            response.longitude,
185        )));
186
187        let data = IpGeoData::new(response.ip, location);
188
189        let data = if !response.timezone_name.is_empty()
190            || !response.currency_code.is_empty()
191            || !response.language_code.is_empty()
192        {
193            let locale = Locale::default();
194
195            let locale = if !response.timezone_name.is_empty() {
196                locale.with_timezone(response.timezone_name)
197            } else {
198                locale
199            };
200            let locale = if !response.currency_code.is_empty() {
201                locale.with_currency(Code::new(
202                    response.currency_code.parse()?,
203                    response.currency_name,
204                ))
205            } else {
206                locale
207            };
208            let locale = if !response.language_code.is_empty() {
209                locale.with_language(Code::new(
210                    response.language_code.parse()?,
211                    response.language_name,
212                ))
213            } else {
214                locale
215            };
216            data.with_locale(locale)
217        } else {
218            data
219        };
220
221        let data = if response.asn_number != 0 {
222            data.with_asn(Asn::new(
223                response.asn_number,
224                response.asn,
225                response.asn_org,
226            ))
227        } else {
228            data
229        };
230
231        Ok(data)
232    }
233}
234
235// ------------------------------------------------------------------------------------------------
236
237impl Provider for NoOp {
238    fn lookup(&self, _: &std::net::IpAddr) -> GeoResult<Option<IpGeoData>> {
239        Ok(None)
240    }
241
242    fn license(&self) -> ProviderDataLicense {
243        ProviderDataLicense::Public
244    }
245}
246
247// ------------------------------------------------------------------------------------------------
248// Private Functions
249// ------------------------------------------------------------------------------------------------
250
251// ------------------------------------------------------------------------------------------------
252// Sub-Modules
253// ------------------------------------------------------------------------------------------------
254
255pub mod local;
256
257// ------------------------------------------------------------------------------------------------
258// Unit Tests
259// ------------------------------------------------------------------------------------------------
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use pretty_assertions::assert_eq;
265
266    #[test]
267    fn test_parse_geoip_response() {
268        const RESPONSE: &str = r##"{
269    "ip": "23.64.167.34",
270    "isp": "Akamai Technologies, Inc.",
271    "org": "Akamai Technologies, Inc.",
272    "hostname": "",
273    "latitude": 32.814,
274    "longitude": -96.9489,
275    "postal_code": "",
276    "city": "Irving",
277    "country_code": "US",
278    "country_name": "United States",
279    "continent_code": "NA",
280    "continent_name": "North America",
281    "region": "Texas",
282    "district": "",
283    "timezone_name": "America/Chicago",
284    "connection_type": "Corporate",
285    "asn_number": 16625,
286    "asn_org": "Akamai Technologies, Inc.",
287    "asn": "AS16625 - Akamai Technologies, Inc.",
288    "currency_code": "USD",
289    "currency_name": "United States Dollar",
290    "language_code": "en",
291    "language_name": "English",
292    "success": true,
293    "premium": false
294}"##;
295        let parsed: Result<GeoIpResponse, serde_json::Error> = serde_json::from_str(RESPONSE);
296        assert!(parsed.is_ok());
297        let parsed = parsed.unwrap();
298        assert_eq!("23.64.167.34".to_string(), parsed.ip.to_string());
299    }
300
301    #[test]
302    fn test_geoip_response_to_data() {
303        const RESPONSE: &str = r##"{
304    "ip": "23.64.167.34",
305    "isp": "Akamai Technologies, Inc.",
306    "org": "Akamai Technologies, Inc.",
307    "hostname": "",
308    "latitude": 32.814,
309    "longitude": -96.9489,
310    "postal_code": "",
311    "city": "Irving",
312    "country_code": "US",
313    "country_name": "United States",
314    "continent_code": "NA",
315    "continent_name": "North America",
316    "region": "Texas",
317    "district": "",
318    "timezone_name": "America/Chicago",
319    "connection_type": "Corporate",
320    "asn_number": 16625,
321    "asn_org": "Akamai Technologies, Inc.",
322    "asn": "AS16625 - Akamai Technologies, Inc.",
323    "currency_code": "USD",
324    "currency_name": "United States Dollar",
325    "language_code": "en",
326    "language_name": "English",
327    "success": true,
328    "premium": false
329}"##;
330        let parsed: GeoIpResponse = serde_json::from_str(RESPONSE).unwrap();
331        let data: IpGeoData = parsed.try_into().unwrap();
332        assert_eq!("NA", data.location().continent().code().to_string());
333        assert_eq!("US", data.location().country().code().to_string());
334        assert_eq!(16625, data.asn().unwrap().number());
335    }
336
337    #[test]
338    fn test_noop_provider() {
339        let provider = NoOp::default();
340        let ip_address: IpAddr = "23.64.167.34".parse().unwrap();
341        assert_eq!(None, provider.lookup(&ip_address).unwrap())
342    }
343}