modo/geolocation/
locator.rs1use 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
15pub 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 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 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 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 }
159}