1use crate::SyncError;
10use sioul_core::places::Place;
11use std::time::Duration;
12
13const ENDPOINT: &str = "https://nominatim.openstreetmap.org/search";
14
15pub const PAUSE: Duration = Duration::from_millis(1100);
17
18pub fn locate(address: &str, language: &str) -> Result<Option<Place>, SyncError> {
20 locate_at(ENDPOINT, address, language)
21}
22
23fn locate_at(endpoint: &str, address: &str, language: &str) -> Result<Option<Place>, SyncError> {
24 let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(20))).http_status_as_error(false).build().into();
25 let query = sioul_core::places::normalize(address);
26 let mut response = agent
27 .get(endpoint)
28 .query("format", "jsonv2")
29 .query("limit", "1")
30 .query("q", &query)
31 .header("User-Agent", concat!("Sioul/", env!("CARGO_PKG_VERSION"), " (desktop mail and contacts client)"))
32 .header("Accept-Language", language)
33 .call()
34 .map_err(|e| SyncError::Network(e.to_string()))?;
35 let status = response.status().as_u16();
36 let text = response.body_mut().read_to_string().unwrap_or_default();
37 if status != 200 {
38 return Err(SyncError::Server(format!("Nominatim: {status}")));
39 }
40 let found: serde_json::Value = serde_json::from_str(&text).map_err(|e| SyncError::Server(e.to_string()))?;
41 let first = found.as_array().and_then(|a| a.first());
42 let number = |key: &str| first.and_then(|f| f[key].as_str()).and_then(|v| v.parse::<f64>().ok());
43 Ok(match (number("lat"), number("lon")) {
44 (Some(lat), Some(lon)) => Some(Place { lat, lon }),
45 _ => None,
46 })
47}
48
49#[cfg(test)]
50mod tests {
51 use super::*;
52 use std::io::{Read, Write};
53
54 fn stand_in(body: &'static str) -> (String, std::thread::JoinHandle<String>) {
55 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
56 let url = format!("http://{}/search", listener.local_addr().unwrap());
57 let served = std::thread::spawn(move || {
58 let (mut stream, _) = listener.accept().unwrap();
59 stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
60 let mut request = vec![0u8; 8192];
61 let n = stream.read(&mut request).unwrap();
62 write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
63 String::from_utf8_lossy(&request[..n]).to_string()
64 });
65 (url, served)
66 }
67
68 #[test]
69 fn an_address_found_or_not() {
70 let (url, served) = stand_in(r#"[{"place_id":1,"lat":"45.5","lon":"4.25","display_name":"3, Rue des Exemples, Exempleville"}]"#);
71 let place = locate_at(&url, "3 rue des Exemples\n99999 Exempleville", "fr").unwrap().unwrap();
72 assert_eq!((place.lat, place.lon), (45.5, 4.25));
73 let request = served.join().unwrap();
74 assert!(request.contains("q=3+rue+des+Exemples%2C+99999+Exempleville") || request.contains("q=3%20rue%20des%20Exemples%2C%2099999%20Exempleville"), "{request}");
75 assert!(request.to_lowercase().contains("user-agent: sioul/"), "{request}");
76 let (url, served) = stand_in("[]");
77 assert_eq!(locate_at(&url, "Nowhere", "en").unwrap(), None);
78 served.join().unwrap();
79 }
80}