Skip to main content

openmeteo_rs/endpoints/
geocoding.rs

1//! Geocoding endpoint.
2
3use reqwest::Url;
4use serde::{Deserialize, Deserializer, de};
5
6use super::url::build_endpoint_url;
7use crate::{Client, Error, Result};
8
9const MAX_GEOCODING_RESULTS: u8 = 100;
10
11/// Builder for the `/v1/search` geocoding endpoint.
12#[derive(Debug)]
13#[must_use = "geocoding builders do nothing until `.send().await` is called"]
14pub struct GeocodingBuilder<'a> {
15    client: &'a Client,
16    name: String,
17    count: Option<u8>,
18    language: Option<String>,
19    country_code: Option<String>,
20}
21
22impl<'a> GeocodingBuilder<'a> {
23    pub(crate) fn new(client: &'a Client, name: String) -> Self {
24        Self {
25            client,
26            name,
27            count: None,
28            language: None,
29            country_code: None,
30        }
31    }
32
33    /// Limits the number of returned matches.
34    ///
35    /// Open-Meteo accepts values from 1 to 100. If called repeatedly, the most
36    /// recent value wins.
37    pub fn count(mut self, count: u8) -> Self {
38        self.count = Some(count);
39        self
40    }
41
42    /// Sets the two-letter result language code, such as `en` or `de`.
43    ///
44    /// The code must be lowercase ASCII. If called repeatedly, the most recent
45    /// value wins.
46    pub fn language(mut self, language: impl Into<String>) -> Self {
47        self.language = Some(language.into());
48        self
49    }
50
51    /// Restricts results to an ISO 3166-1 alpha-2 country code, such as `CH`.
52    ///
53    /// The value is normalized to uppercase ASCII. If called repeatedly, the
54    /// most recent value wins.
55    pub fn country_code(mut self, country_code: impl Into<String>) -> Self {
56        self.country_code = Some(country_code.into().to_ascii_uppercase());
57        self
58    }
59
60    /// Sends the request and decodes matching locations.
61    pub async fn send(self) -> Result<Vec<GeocodedLocation>> {
62        let url = self.build_url()?;
63        let body = self.client.execute(self.client.http.get(url)).await?;
64        decode_geocoding_json(&body)
65    }
66
67    pub(crate) fn build_url(&self) -> Result<Url> {
68        self.validate()?;
69
70        let mut params = vec![("name", self.name.clone())];
71        if let Some(count) = self.count {
72            params.push(("count", count.to_string()));
73        }
74        if let Some(language) = &self.language {
75            params.push(("language", language.clone()));
76        }
77        if let Some(country_code) = &self.country_code {
78            params.push(("country_code", country_code.clone()));
79        }
80
81        build_endpoint_url(
82            &self.client.geocoding_base,
83            "geocoding_base_url",
84            "v1/search",
85            self.client.api_key.as_deref(),
86            params,
87        )
88    }
89
90    fn validate(&self) -> Result<()> {
91        if self.name.trim().is_empty() {
92            return Err(Error::InvalidParam {
93                field: "name",
94                reason: "must not be empty".into(),
95            });
96        }
97
98        if let Some(count) = self.count
99            && !(1..=MAX_GEOCODING_RESULTS).contains(&count)
100        {
101            return Err(Error::InvalidParam {
102                field: "count",
103                reason: format!("must be between 1 and {MAX_GEOCODING_RESULTS}"),
104            });
105        }
106
107        if let Some(language) = &self.language
108            && (language.len() != 2 || !language.bytes().all(|byte| byte.is_ascii_lowercase()))
109        {
110            return Err(Error::InvalidParam {
111                field: "language",
112                reason: "must be a two-letter lowercase ISO 639-1 code".into(),
113            });
114        }
115
116        if let Some(country_code) = &self.country_code
117            && (country_code.len() != 2
118                || !country_code.bytes().all(|byte| byte.is_ascii_alphabetic()))
119        {
120            return Err(Error::InvalidParam {
121                field: "country_code",
122                reason: "must be a two-letter ISO 3166-1 alpha-2 code".into(),
123            });
124        }
125
126        Ok(())
127    }
128}
129
130/// A location returned by the Open-Meteo geocoding endpoint.
131#[derive(Debug, Clone, PartialEq, Deserialize)]
132#[non_exhaustive]
133pub struct GeocodedLocation {
134    /// GeoNames location identifier.
135    pub id: u64,
136    /// Display name.
137    #[serde(deserialize_with = "deserialize_non_empty_string")]
138    pub name: String,
139    /// Latitude in decimal degrees.
140    pub latitude: f64,
141    /// Longitude in decimal degrees.
142    pub longitude: f64,
143    /// Elevation in metres, when known.
144    pub elevation: Option<f64>,
145    /// GeoNames feature code.
146    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
147    pub feature_code: Option<String>,
148    /// ISO 3166-1 alpha-2 country code.
149    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
150    pub country_code: Option<String>,
151    /// Country display name.
152    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
153    pub country: Option<String>,
154    /// GeoNames country identifier.
155    pub country_id: Option<u64>,
156    /// IANA timezone name.
157    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
158    pub timezone: Option<String>,
159    /// Population, when known.
160    pub population: Option<u64>,
161    /// Postal codes associated with the result.
162    pub postcodes: Option<Vec<String>>,
163    /// First-level administrative area.
164    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
165    pub admin1: Option<String>,
166    /// Second-level administrative area.
167    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
168    pub admin2: Option<String>,
169    /// Third-level administrative area.
170    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
171    pub admin3: Option<String>,
172    /// Fourth-level administrative area.
173    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
174    pub admin4: Option<String>,
175    /// GeoNames first-level administrative identifier.
176    pub admin1_id: Option<u64>,
177    /// GeoNames second-level administrative identifier.
178    pub admin2_id: Option<u64>,
179    /// GeoNames third-level administrative identifier.
180    pub admin3_id: Option<u64>,
181    /// GeoNames fourth-level administrative identifier.
182    pub admin4_id: Option<u64>,
183}
184
185fn decode_geocoding_json(bytes: &[u8]) -> Result<Vec<GeocodedLocation>> {
186    let raw: RawGeocodingResponse = serde_json::from_slice(bytes)?;
187    Ok(raw.results.unwrap_or_default())
188}
189
190#[derive(Debug, Deserialize)]
191struct RawGeocodingResponse {
192    results: Option<Vec<GeocodedLocation>>,
193}
194
195fn deserialize_non_empty_string<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
196where
197    D: Deserializer<'de>,
198{
199    let value = String::deserialize(deserializer)?;
200    if value.trim().is_empty() {
201        return Err(de::Error::custom("string must not be empty"));
202    }
203    Ok(value)
204}
205
206fn deserialize_optional_non_empty_string<'de, D>(
207    deserializer: D,
208) -> std::result::Result<Option<String>, D::Error>
209where
210    D: Deserializer<'de>,
211{
212    let value = Option::<String>::deserialize(deserializer)?;
213    Ok(value.and_then(|value| if value.is_empty() { None } else { Some(value) }))
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn build_geocoding_url_with_options() {
222        let client = Client::builder()
223            .geocoding_base_url("https://example.com/geocoding?token=abc")
224            .unwrap()
225            .build()
226            .unwrap();
227
228        let url = client
229            .geocode("Zurich")
230            .count(2)
231            .language("en")
232            .country_code("CH")
233            .build_url()
234            .unwrap();
235
236        assert_eq!(
237            url.as_str(),
238            "https://example.com/geocoding/v1/search?token=abc&name=Zurich&count=2&language=en&country_code=CH"
239        );
240    }
241
242    #[test]
243    fn build_geocoding_url_with_api_key() {
244        let client = Client::builder()
245            .geocoding_base_url("https://example.com")
246            .unwrap()
247            .api_key("secret")
248            .build()
249            .unwrap();
250
251        let url = client
252            .geocode("Zurich")
253            .country_code("ch")
254            .build_url()
255            .unwrap();
256
257        assert_eq!(
258            url.as_str(),
259            "https://example.com/v1/search?name=Zurich&country_code=CH&apikey=secret"
260        );
261    }
262
263    #[test]
264    fn rejects_empty_geocoding_name() {
265        let client = Client::new();
266        let err = client.geocode("  ").build_url().unwrap_err();
267
268        assert!(matches!(err, Error::InvalidParam { field: "name", .. }));
269    }
270
271    #[test]
272    fn rejects_zero_geocoding_count() {
273        let client = Client::new();
274        let err = client.geocode("Zurich").count(0).build_url().unwrap_err();
275
276        assert!(matches!(err, Error::InvalidParam { field: "count", .. }));
277    }
278
279    #[test]
280    fn rejects_invalid_country_code() {
281        let client = Client::new();
282        let err = client
283            .geocode("Zurich")
284            .country_code("CHE")
285            .build_url()
286            .unwrap_err();
287
288        assert!(matches!(
289            err,
290            Error::InvalidParam {
291                field: "country_code",
292                ..
293            }
294        ));
295    }
296
297    #[test]
298    fn rejects_invalid_language() {
299        let client = Client::new();
300        let err = client
301            .geocode("Zurich")
302            .language("EN")
303            .build_url()
304            .unwrap_err();
305
306        assert!(matches!(
307            err,
308            Error::InvalidParam {
309                field: "language",
310                ..
311            }
312        ));
313    }
314
315    #[test]
316    fn decodes_geocoding_json() {
317        let locations = decode_geocoding_json(
318            br#"{"results":[{"id":2657896,"name":"Zurich","latitude":47.36667,"longitude":8.55,"country_code":"CH"}]}"#,
319        )
320        .unwrap();
321
322        assert_eq!(locations.len(), 1);
323        assert_eq!(locations[0].id, 2657896);
324        assert_eq!(locations[0].country_code.as_deref(), Some("CH"));
325    }
326
327    #[test]
328    fn rejects_empty_required_location_name() {
329        assert!(
330            decode_geocoding_json(
331                br#"{"results":[{"id":1,"name":"","latitude":47.36667,"longitude":8.55}]}"#,
332            )
333            .is_err()
334        );
335    }
336
337    #[test]
338    fn decodes_empty_optional_strings_as_none() {
339        let locations = decode_geocoding_json(
340            br#"{"results":[{"id":2950159,"name":"Berlin","latitude":52.52437,"longitude":13.41053,"admin1":"Berlin","admin2":"","country_code":"DE"}]}"#,
341        )
342        .unwrap();
343
344        assert_eq!(locations[0].admin1.as_deref(), Some("Berlin"));
345        assert_eq!(locations[0].admin2, None);
346    }
347
348    #[test]
349    fn decodes_empty_geocoding_json() {
350        let locations = decode_geocoding_json(br#"{}"#).unwrap();
351        assert!(locations.is_empty());
352    }
353}