Skip to main content

modo/geolocation/
locator.rs

1use std::net::IpAddr;
2use std::sync::Arc;
3
4use maxminddb::geoip2;
5
6use crate::error::Error;
7
8use super::config::GeolocationConfig;
9use super::location::Location;
10
11struct Inner {
12    reader: maxminddb::Reader<Vec<u8>>,
13}
14
15/// MaxMind GeoLite2/GeoIP2 database reader.
16///
17/// Wraps the mmdb reader in an `Arc` so cloning is cheap. Register a
18/// `GeoLocator` in the service registry and extract it in handlers via
19/// `Service<GeoLocator>`.
20pub struct GeoLocator {
21    inner: Arc<Inner>,
22}
23
24impl Clone for GeoLocator {
25    fn clone(&self) -> Self {
26        Self {
27            inner: Arc::clone(&self.inner),
28        }
29    }
30}
31
32impl GeoLocator {
33    /// Open the mmdb file specified in `config` and return a ready locator.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`Error::internal`](crate::Error::internal) when `mmdb_path` is
38    /// empty or the file cannot be opened.
39    pub fn from_config(config: &GeolocationConfig) -> crate::Result<Self> {
40        if config.mmdb_path.is_empty() {
41            return Err(Error::internal("geolocation mmdb_path is not configured"));
42        }
43
44        let reader = maxminddb::Reader::open_readfile(&config.mmdb_path).map_err(|e| match e {
45            maxminddb::MaxMindDbError::Io(_) => Error::internal(format!(
46                "geolocation mmdb file not found: {}",
47                config.mmdb_path
48            ))
49            .chain(e),
50            _ => Error::internal("failed to open mmdb file").chain(e),
51        })?;
52
53        Ok(Self {
54            inner: Arc::new(Inner { reader }),
55        })
56    }
57
58    /// Look up `ip` in the MaxMind database and return geolocation data.
59    ///
60    /// Returns a [`Location`] with all-`None` fields when the IP is not found
61    /// in the database (private ranges, loopback addresses, etc.).
62    ///
63    /// # Errors
64    ///
65    /// Returns [`Error::internal`](crate::Error::internal) when the database
66    /// lookup or record decoding fails.
67    pub fn lookup(&self, ip: IpAddr) -> crate::Result<Location> {
68        let result = self
69            .inner
70            .reader
71            .lookup(ip)
72            .map_err(|e| Error::internal("geolocation lookup failed").chain(e))?;
73
74        if !result.has_data() {
75            return Ok(Location::default());
76        }
77
78        let city: geoip2::City = match result
79            .decode()
80            .map_err(|e| Error::internal("geolocation decode failed").chain(e))?
81        {
82            Some(c) => c,
83            None => return Ok(Location::default()),
84        };
85
86        Ok(Location {
87            country_code: city.country.iso_code.map(|s| s.to_owned()),
88            country_name: city.country.names.english.map(|s| s.to_owned()),
89            region: city
90                .subdivisions
91                .first()
92                .and_then(|s| s.names.english)
93                .map(|s| s.to_owned()),
94            city: city.city.names.english.map(|s| s.to_owned()),
95            latitude: city.location.latitude,
96            longitude: city.location.longitude,
97            timezone: city.location.time_zone.map(|s| s.to_owned()),
98        })
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::net::IpAddr;
106
107    fn test_config() -> GeolocationConfig {
108        GeolocationConfig {
109            mmdb_path: "tests/fixtures/GeoIP2-City-Test.mmdb".to_string(),
110        }
111    }
112
113    #[test]
114    fn from_config_with_empty_path() {
115        let config = GeolocationConfig::default();
116        let result = GeoLocator::from_config(&config);
117        assert!(result.is_err());
118    }
119
120    #[test]
121    fn from_config_with_missing_file() {
122        let config = GeolocationConfig {
123            mmdb_path: "nonexistent.mmdb".to_string(),
124        };
125        let result = GeoLocator::from_config(&config);
126        assert!(result.is_err());
127    }
128
129    #[test]
130    fn from_config_with_valid_file() {
131        let geo = GeoLocator::from_config(&test_config());
132        assert!(geo.is_ok());
133    }
134
135    #[test]
136    fn lookup_known_ip() {
137        let geo = GeoLocator::from_config(&test_config()).unwrap();
138        // 81.2.69.142 is a known test IP in the MaxMind test database
139        let ip: IpAddr = "81.2.69.142".parse().unwrap();
140        let loc = geo.lookup(ip).unwrap();
141        assert!(loc.country_code.is_some() || loc.city.is_some());
142    }
143
144    #[test]
145    fn lookup_private_ip_returns_default() {
146        let geo = GeoLocator::from_config(&test_config()).unwrap();
147        let ip: IpAddr = "10.0.0.1".parse().unwrap();
148        let loc = geo.lookup(ip).unwrap();
149        assert!(loc.country_code.is_none());
150        assert!(loc.city.is_none());
151    }
152
153    #[test]
154    fn clone_is_cheap() {
155        let geo = GeoLocator::from_config(&test_config()).unwrap();
156        let _geo2 = geo.clone();
157        // Both point to the same Arc — just verifying Clone compiles and works.
158    }
159}