Skip to main content

rfham_geo/geoip/
mod.rs

1//! IP-to-location lookup types and the [`Provider`] trait.
2//!
3//! This module defines the data model returned by any geo-IP lookup: [`IpGeoData`] is the
4//! top-level result, containing a [`Location`] (continent, country, city …), an optional
5//! [`Locale`] (timezone, currency, language), and an optional [`Asn`] (autonomous system).
6//!
7//! Concrete provider implementations live in [`providers`].
8//!
9//! # Data model
10//!
11//! ```text
12//! IpGeoData
13//! ├── ip_address: IpAddr
14//! ├── location: Location
15//! │   ├── continent: Code<ContinentCode>
16//! │   ├── country:   Code<CountryCode>
17//! │   └── location:  Option<GeoLocation>  (coordinate + accuracy)
18//! ├── hostname: Option<String>
19//! ├── locale:   Option<Locale>            (timezone, currency, language)
20//! └── asn:      Option<Asn>              (AS number, name, org)
21//! ```
22//!
23//! # Examples
24//!
25//! ```rust
26//! use rfham_geo::geoip::{IpGeoData, Location, Code, ContinentCode, GeoLocation};
27//! use rfham_core::CountryCode;
28//! use lat_long::{Coordinate, Latitude, Longitude};
29//! use std::{net::IpAddr, str::FromStr};
30//!
31//! let location = Location::new(
32//!     Code::new(ContinentCode::NA, "North America"),
33//!     Code::new(CountryCode::from_str("US").unwrap(), "United States"),
34//! );
35//! let data = IpGeoData::new("203.0.113.1".parse::<IpAddr>().unwrap(), location);
36//! assert_eq!(data.location().continent().code(), &ContinentCode::NA);
37//! assert_eq!(data.location().country().code().to_string(), "US");
38//! ```
39
40use crate::error::GeoResult;
41use lat_long::{Coordinate, Latitude, Longitude};
42use rfham_core::{CountryCode, error::CoreError};
43use serde::{Deserialize, Serialize};
44use serde_with::{DeserializeFromStr, SerializeDisplay};
45use std::{
46    fmt::{Debug, Display},
47    net::IpAddr,
48    str::FromStr,
49};
50use uom::si::f64::Length;
51
52// ------------------------------------------------------------------------------------------------
53// Public Macros
54// ------------------------------------------------------------------------------------------------
55
56// ------------------------------------------------------------------------------------------------
57// Public Types
58// ------------------------------------------------------------------------------------------------
59
60/// A geo-IP lookup service that maps an IP address to location data.
61pub trait Provider {
62    /// Look up location data for `address`. Returns `None` if the provider has no data
63    /// for the given address (e.g. a private/reserved range).
64    fn lookup(&self, address: &IpAddr) -> GeoResult<Option<IpGeoData>>;
65
66    /// Describes the licence under which this provider's data is available.
67    fn license(&self) -> ProviderDataLicense;
68}
69
70/// Indicates how the data returned by a provider may be used.
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
72pub enum ProviderDataLicense {
73    /// Freely accessible with no licence restrictions (e.g. a public API).
74    Public,
75    /// Requires a service agreement with the data provider.
76    ServiceLicensed,
77    /// Requires the client application to hold a licence.
78    ClientLicensed,
79}
80
81/// The result of a geo-IP lookup: location, locale, and network information.
82#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
83pub struct IpGeoData {
84    ip_address: IpAddr,
85    location: Location,
86    hostname: Option<String>,
87    locale: Option<Locale>,
88    asn: Option<Asn>,
89}
90
91#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
92pub struct Location {
93    continent: Code<ContinentCode>,
94    country: Code<CountryCode>,
95    location: Option<GeoLocation>,
96    region: Option<String>,
97    city: Option<String>,
98    district: Option<String>,
99    postal_code: Option<String>,
100}
101
102#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
103pub struct GeoLocation {
104    coordinate: Coordinate,
105    accuracy: Option<Length>,
106}
107
108#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
109pub struct Locale {
110    timezone: Option<String>,
111    currency: Option<Code<CurrencyCode>>,
112    language: Option<Code<LanguageCode>>,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
116pub struct Asn {
117    number: u64,
118    name: String,
119    organization: String,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
123pub struct Code<T>
124where
125    T: Clone + Debug + Display + PartialEq + Eq,
126{
127    code: T,
128    label: String,
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
132pub enum ContinentCode {
133    /// Africa
134    AF,
135    /// North America
136    NA,
137    /// Oceania
138    OC,
139    /// Antarctica
140    AN,
141    /// Asia
142    AS,
143    /// Europe
144    EU,
145    /// South America
146    SA,
147}
148
149/// ISO 4217 three-letter currency code (e.g. `"USD"`, `"EUR"`).
150#[derive(Clone, Debug, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
151pub struct CurrencyCode(String);
152
153/// ISO 639-1 two-letter language code (e.g. `"en"`, `"ja"`).
154#[derive(Clone, Debug, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
155pub struct LanguageCode(String);
156
157// ------------------------------------------------------------------------------------------------
158// Public Functions
159// ------------------------------------------------------------------------------------------------
160
161// ------------------------------------------------------------------------------------------------
162// Private Macros
163// ------------------------------------------------------------------------------------------------
164
165// ------------------------------------------------------------------------------------------------
166// Private Types
167// ------------------------------------------------------------------------------------------------
168
169// ------------------------------------------------------------------------------------------------
170// Implementations ❯ Structures
171// ------------------------------------------------------------------------------------------------
172
173impl IpGeoData {
174    pub const fn new(ip_address: IpAddr, location: Location) -> Self {
175        Self {
176            ip_address,
177            location,
178            hostname: None,
179            locale: None,
180            asn: None,
181        }
182    }
183
184    pub fn with_hostname<S: Into<String>>(mut self, hostname: S) -> Self {
185        self.hostname = Some(hostname.into());
186        self
187    }
188
189    pub fn with_locale(mut self, locale: Locale) -> Self {
190        self.locale = Some(locale);
191        self
192    }
193
194    pub fn with_asn(mut self, asn: Asn) -> Self {
195        self.asn = Some(asn);
196        self
197    }
198
199    pub const fn ip_address(&self) -> &IpAddr {
200        &self.ip_address
201    }
202
203    pub const fn location(&self) -> &Location {
204        &self.location
205    }
206
207    pub const fn locale(&self) -> Option<&Locale> {
208        self.locale.as_ref()
209    }
210
211    pub const fn asn(&self) -> Option<&Asn> {
212        self.asn.as_ref()
213    }
214}
215
216// ------------------------------------------------------------------------------------------------
217
218impl Location {
219    pub const fn new(continent: Code<ContinentCode>, country: Code<CountryCode>) -> Self {
220        Self {
221            continent,
222            country,
223            location: None,
224            region: None,
225            city: None,
226            district: None,
227            postal_code: None,
228        }
229    }
230    pub fn with_location(mut self, location: GeoLocation) -> Self {
231        self.location = Some(location);
232        self
233    }
234
235    pub fn with_region<S: Into<String>>(mut self, region: S) -> Self {
236        self.region = Some(region.into());
237        self
238    }
239
240    pub fn with_city<S: Into<String>>(mut self, city: S) -> Self {
241        self.city = Some(city.into());
242        self
243    }
244
245    pub fn with_district<S: Into<String>>(mut self, district: S) -> Self {
246        self.district = Some(district.into());
247        self
248    }
249
250    pub fn with_postal_code<S: Into<String>>(mut self, postal_code: S) -> Self {
251        self.postal_code = Some(postal_code.into());
252        self
253    }
254
255    pub const fn continent(&self) -> &Code<ContinentCode> {
256        &self.continent
257    }
258
259    pub const fn country(&self) -> &Code<CountryCode> {
260        &self.country
261    }
262
263    pub const fn location(&self) -> Option<&GeoLocation> {
264        self.location.as_ref()
265    }
266
267    pub const fn region(&self) -> Option<&String> {
268        self.region.as_ref()
269    }
270
271    pub const fn city(&self) -> Option<&String> {
272        self.city.as_ref()
273    }
274
275    pub const fn district(&self) -> Option<&String> {
276        self.district.as_ref()
277    }
278
279    pub const fn postal_code(&self) -> Option<&String> {
280        self.postal_code.as_ref()
281    }
282}
283
284// ------------------------------------------------------------------------------------------------
285
286impl From<Coordinate> for GeoLocation {
287    fn from(value: Coordinate) -> Self {
288        Self::new(value)
289    }
290}
291
292impl GeoLocation {
293    pub const fn new(coordinate: Coordinate) -> Self {
294        Self {
295            coordinate,
296            accuracy: None,
297        }
298    }
299
300    pub fn with_accuracy(mut self, accuracy: Length) -> Self {
301        self.accuracy = Some(accuracy);
302        self
303    }
304
305    pub const fn coordinate(&self) -> Coordinate {
306        self.coordinate
307    }
308
309    pub const fn longitude(&self) -> Longitude {
310        self.coordinate.longitude()
311    }
312
313    pub const fn latitude(&self) -> Latitude {
314        self.coordinate.latitude()
315    }
316
317    pub const fn accuracy(&self) -> Option<&Length> {
318        self.accuracy.as_ref()
319    }
320}
321
322// ------------------------------------------------------------------------------------------------
323
324impl Locale {
325    pub fn with_timezone(mut self, timezone: String) -> Self {
326        self.timezone = Some(timezone);
327        self
328    }
329
330    pub fn with_currency(mut self, currency: Code<CurrencyCode>) -> Self {
331        self.currency = Some(currency);
332        self
333    }
334
335    pub fn with_language(mut self, language: Code<LanguageCode>) -> Self {
336        self.language = Some(language);
337        self
338    }
339
340    pub const fn timezone(&self) -> Option<&String> {
341        self.timezone.as_ref()
342    }
343
344    pub const fn currency(&self) -> Option<&Code<CurrencyCode>> {
345        self.currency.as_ref()
346    }
347
348    pub const fn language(&self) -> Option<&Code<LanguageCode>> {
349        self.language.as_ref()
350    }
351}
352
353// ------------------------------------------------------------------------------------------------
354
355impl Asn {
356    pub const fn new(number: u64, name: String, organization: String) -> Self {
357        Self {
358            number,
359            name,
360            organization,
361        }
362    }
363
364    pub const fn number(&self) -> u64 {
365        self.number
366    }
367
368    pub const fn name(&self) -> &String {
369        &self.name
370    }
371
372    pub const fn organization(&self) -> &String {
373        &self.organization
374    }
375}
376
377// ------------------------------------------------------------------------------------------------
378// Implementations ❯ Codes
379// ------------------------------------------------------------------------------------------------
380
381impl<T> Display for Code<T>
382where
383    T: Clone + Debug + Display + PartialEq + Eq,
384{
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        write!(
387            f,
388            "{}",
389            if f.alternate() {
390                format!("{}: {}", self.code, self.label)
391            } else {
392                self.label.to_string()
393            }
394        )
395    }
396}
397
398impl<T> Code<T>
399where
400    T: Clone + Debug + Display + PartialEq + Eq,
401{
402    pub fn new<S: Into<String>>(code: T, label: S) -> Self {
403        Self {
404            code,
405            label: label.into(),
406        }
407    }
408
409    pub const fn code(&self) -> &T {
410        &self.code
411    }
412
413    pub const fn label(&self) -> &String {
414        &self.label
415    }
416}
417// ------------------------------------------------------------------------------------------------
418
419impl Display for ContinentCode {
420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421        write!(
422            f,
423            "{}",
424            match self {
425                Self::AF => "AF",
426                Self::AN => "AN",
427                Self::AS => "AS",
428                Self::EU => "EU",
429                Self::NA => "NA",
430                Self::OC => "OC",
431                Self::SA => "SA",
432            }
433        )
434    }
435}
436
437impl FromStr for ContinentCode {
438    type Err = CoreError;
439
440    fn from_str(s: &str) -> Result<Self, Self::Err> {
441        match s {
442            "AF" => Ok(Self::AF),
443            "AN" => Ok(Self::AN),
444            "AS" => Ok(Self::AS),
445            "EU" => Ok(Self::EU),
446            "NA" => Ok(Self::NA),
447            "OC" => Ok(Self::OC),
448            "SA" => Ok(Self::SA),
449            _ => Err(CoreError::InvalidValueFromStr(
450                s.to_string(),
451                "ContinentCode",
452            )),
453        }
454    }
455}
456
457impl ContinentCode {
458    pub fn name(&self) -> &str {
459        match self {
460            Self::AF => "Africa",
461            Self::AN => "Antarctica",
462            Self::AS => "Asia",
463            Self::EU => "Europe",
464            Self::NA => "North America",
465            Self::OC => "Oceania",
466            Self::SA => "South America",
467        }
468    }
469}
470
471// ------------------------------------------------------------------------------------------------
472
473impl Display for CurrencyCode {
474    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475        write!(f, "{}", self.0)
476    }
477}
478
479impl FromStr for CurrencyCode {
480    type Err = CoreError;
481
482    fn from_str(s: &str) -> Result<Self, Self::Err> {
483        if Self::is_valid(s) {
484            Ok(Self(s.to_string()))
485        } else {
486            Err(CoreError::InvalidValueFromStr(
487                s.to_string(),
488                "CurrencyCode",
489            ))
490        }
491    }
492}
493
494impl CurrencyCode {
495    pub fn is_valid(s: &str) -> bool {
496        s.len() == 3 && s.chars().all(|c| c.is_ascii_uppercase())
497    }
498}
499
500// ------------------------------------------------------------------------------------------------
501
502impl Display for LanguageCode {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        write!(f, "{}", self.0)
505    }
506}
507
508impl FromStr for LanguageCode {
509    type Err = CoreError;
510
511    fn from_str(s: &str) -> Result<Self, Self::Err> {
512        if Self::is_valid(s) {
513            Ok(Self(s.to_string()))
514        } else {
515            Err(CoreError::InvalidValueFromStr(
516                s.to_string(),
517                "LanguageCode",
518            ))
519        }
520    }
521}
522
523impl LanguageCode {
524    pub fn is_valid(s: &str) -> bool {
525        s.len() == 2 && s.chars().all(|c| c.is_ascii_lowercase())
526    }
527}
528
529// ------------------------------------------------------------------------------------------------
530// Private Functions
531// ------------------------------------------------------------------------------------------------
532
533// ------------------------------------------------------------------------------------------------
534// Sub-Modules
535// ------------------------------------------------------------------------------------------------
536
537// ------------------------------------------------------------------------------------------------
538// Unit Tests
539// ------------------------------------------------------------------------------------------------
540
541pub mod providers;
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use lat_long::{Latitude, Longitude};
547    use pretty_assertions::assert_eq;
548    use serde_json::to_string_pretty;
549
550    #[test]
551    fn test_serialize_roundtrip() {
552        let data = IpGeoData::new(
553            IpAddr::from_str("23.64.167.34").unwrap(),
554            Location {
555                continent: Code {
556                    code: ContinentCode::NA,
557                    label: "North America".to_string(),
558                },
559                country: Code {
560                    code: "US".parse().unwrap(),
561                    label: "United States".to_string(),
562                },
563                location: Some(GeoLocation {
564                    coordinate: Coordinate::new(
565                        Latitude::from_str("32.814").unwrap(),
566                        Longitude::from_str("-96.9489").unwrap(),
567                    ),
568                    accuracy: None,
569                }),
570                region: Some("Texas".to_string()),
571                city: Some("Irving".to_string()),
572                district: None,
573                postal_code: None,
574            },
575        )
576        .with_locale(Locale {
577            timezone: Some("America/Chicago".to_string()),
578            currency: Some(Code {
579                code: CurrencyCode::from_str("USD").unwrap(),
580                label: "United States Dollar".to_string(),
581            }),
582            language: Some(Code {
583                code: LanguageCode::from_str("en").unwrap(),
584                label: "English".to_string(),
585            }),
586        });
587
588        let json = to_string_pretty(&data).unwrap();
589        assert!(json.contains("23.64.167.34"));
590        assert!(json.contains("Texas"));
591
592        let deserialized: IpGeoData = serde_json::from_str(&json).unwrap();
593        assert_eq!(data, deserialized);
594    }
595
596    #[test]
597    fn test_continent_code_roundtrip() {
598        for (s, code) in [
599            ("AF", ContinentCode::AF),
600            ("AN", ContinentCode::AN),
601            ("AS", ContinentCode::AS),
602            ("EU", ContinentCode::EU),
603            ("NA", ContinentCode::NA),
604            ("OC", ContinentCode::OC),
605            ("SA", ContinentCode::SA),
606        ] {
607            assert_eq!(code.to_string(), s);
608            assert_eq!(ContinentCode::from_str(s).unwrap(), code);
609        }
610    }
611
612    #[test]
613    fn test_continent_code_name() {
614        assert_eq!(ContinentCode::NA.name(), "North America");
615        assert_eq!(ContinentCode::EU.name(), "Europe");
616    }
617
618    #[test]
619    fn test_continent_code_invalid() {
620        assert!(ContinentCode::from_str("XX").is_err());
621    }
622
623    #[test]
624    fn test_currency_code_valid() {
625        assert!(CurrencyCode::from_str("USD").is_ok());
626        assert!(CurrencyCode::from_str("EUR").is_ok());
627        assert!(CurrencyCode::from_str("JPY").is_ok());
628    }
629
630    #[test]
631    fn test_currency_code_invalid() {
632        assert!(CurrencyCode::from_str("us").is_err());   // lowercase
633        assert!(CurrencyCode::from_str("USDD").is_err()); // 4 chars
634        assert!(CurrencyCode::from_str("US").is_err());   // 2 chars
635    }
636
637    #[test]
638    fn test_language_code_valid() {
639        assert!(LanguageCode::from_str("en").is_ok());
640        assert!(LanguageCode::from_str("ja").is_ok());
641    }
642
643    #[test]
644    fn test_language_code_invalid() {
645        assert!(LanguageCode::from_str("EN").is_err()); // uppercase
646        assert!(LanguageCode::from_str("eng").is_err()); // 3 chars
647    }
648
649    #[test]
650    fn test_code_display() {
651        let c = Code::new(ContinentCode::EU, "Europe");
652        assert_eq!(c.to_string(), "Europe");
653        assert_eq!(format!("{c:#}"), "EU: Europe");
654    }
655
656    #[test]
657    fn test_ip_geo_data_accessors() {
658        let location = Location::new(
659            Code::new(ContinentCode::NA, "North America"),
660            Code::new("US".parse::<rfham_core::CountryCode>().unwrap(), "United States"),
661        );
662        let data = IpGeoData::new("203.0.113.1".parse::<IpAddr>().unwrap(), location);
663        assert_eq!(data.ip_address().to_string(), "203.0.113.1");
664        assert_eq!(data.location().continent().code(), &ContinentCode::NA);
665        assert!(data.locale().is_none());
666        assert!(data.asn().is_none());
667    }
668}