1use 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#[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 pub fn count(mut self, count: u8) -> Self {
38 self.count = Some(count);
39 self
40 }
41
42 pub fn language(mut self, language: impl Into<String>) -> Self {
47 self.language = Some(language.into());
48 self
49 }
50
51 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 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#[derive(Debug, Clone, PartialEq, Deserialize)]
132#[non_exhaustive]
133pub struct GeocodedLocation {
134 pub id: u64,
136 #[serde(deserialize_with = "deserialize_non_empty_string")]
138 pub name: String,
139 pub latitude: f64,
141 pub longitude: f64,
143 pub elevation: Option<f64>,
145 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
147 pub feature_code: Option<String>,
148 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
150 pub country_code: Option<String>,
151 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
153 pub country: Option<String>,
154 pub country_id: Option<u64>,
156 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
158 pub timezone: Option<String>,
159 pub population: Option<u64>,
161 pub postcodes: Option<Vec<String>>,
163 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
165 pub admin1: Option<String>,
166 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
168 pub admin2: Option<String>,
169 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
171 pub admin3: Option<String>,
172 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
174 pub admin4: Option<String>,
175 pub admin1_id: Option<u64>,
177 pub admin2_id: Option<u64>,
179 pub admin3_id: Option<u64>,
181 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}