use reqwest::Url;
use serde::{Deserialize, Deserializer, de};
use super::url::build_endpoint_url;
use crate::{Client, Error, Result};
const MAX_GEOCODING_RESULTS: u8 = 100;
#[derive(Debug)]
#[must_use = "geocoding builders do nothing until `.send().await` is called"]
pub struct GeocodingBuilder<'a> {
client: &'a Client,
name: String,
count: Option<u8>,
language: Option<String>,
country_code: Option<String>,
}
impl<'a> GeocodingBuilder<'a> {
pub(crate) fn new(client: &'a Client, name: String) -> Self {
Self {
client,
name,
count: None,
language: None,
country_code: None,
}
}
pub fn count(mut self, count: u8) -> Self {
self.count = Some(count);
self
}
pub fn language(mut self, language: impl Into<String>) -> Self {
self.language = Some(language.into());
self
}
pub fn country_code(mut self, country_code: impl Into<String>) -> Self {
self.country_code = Some(country_code.into().to_ascii_uppercase());
self
}
pub async fn send(self) -> Result<Vec<GeocodedLocation>> {
let url = self.build_url()?;
let body = self.client.execute(self.client.http.get(url)).await?;
decode_geocoding_json(&body)
}
pub(crate) fn build_url(&self) -> Result<Url> {
self.validate()?;
let mut params = vec![("name", self.name.clone())];
if let Some(count) = self.count {
params.push(("count", count.to_string()));
}
if let Some(language) = &self.language {
params.push(("language", language.clone()));
}
if let Some(country_code) = &self.country_code {
params.push(("country_code", country_code.clone()));
}
build_endpoint_url(
&self.client.geocoding_base,
"geocoding_base_url",
"v1/search",
self.client.api_key.as_deref(),
params,
)
}
fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(Error::InvalidParam {
field: "name",
reason: "must not be empty".into(),
});
}
if let Some(count) = self.count
&& !(1..=MAX_GEOCODING_RESULTS).contains(&count)
{
return Err(Error::InvalidParam {
field: "count",
reason: format!("must be between 1 and {MAX_GEOCODING_RESULTS}"),
});
}
if let Some(language) = &self.language
&& (language.len() != 2 || !language.bytes().all(|byte| byte.is_ascii_lowercase()))
{
return Err(Error::InvalidParam {
field: "language",
reason: "must be a two-letter lowercase ISO 639-1 code".into(),
});
}
if let Some(country_code) = &self.country_code
&& (country_code.len() != 2
|| !country_code.bytes().all(|byte| byte.is_ascii_alphabetic()))
{
return Err(Error::InvalidParam {
field: "country_code",
reason: "must be a two-letter ISO 3166-1 alpha-2 code".into(),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct GeocodedLocation {
pub id: u64,
#[serde(deserialize_with = "deserialize_non_empty_string")]
pub name: String,
pub latitude: f64,
pub longitude: f64,
pub elevation: Option<f64>,
#[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
pub feature_code: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
pub country_code: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
pub country: Option<String>,
pub country_id: Option<u64>,
#[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
pub timezone: Option<String>,
pub population: Option<u64>,
pub postcodes: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
pub admin1: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
pub admin2: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
pub admin3: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
pub admin4: Option<String>,
pub admin1_id: Option<u64>,
pub admin2_id: Option<u64>,
pub admin3_id: Option<u64>,
pub admin4_id: Option<u64>,
}
fn decode_geocoding_json(bytes: &[u8]) -> Result<Vec<GeocodedLocation>> {
let raw: RawGeocodingResponse = serde_json::from_slice(bytes)?;
Ok(raw.results.unwrap_or_default())
}
#[derive(Debug, Deserialize)]
struct RawGeocodingResponse {
results: Option<Vec<GeocodedLocation>>,
}
fn deserialize_non_empty_string<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
if value.trim().is_empty() {
return Err(de::Error::custom("string must not be empty"));
}
Ok(value)
}
fn deserialize_optional_non_empty_string<'de, D>(
deserializer: D,
) -> std::result::Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
Ok(value.and_then(|value| if value.is_empty() { None } else { Some(value) }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_geocoding_url_with_options() {
let client = Client::builder()
.geocoding_base_url("https://example.com/geocoding?token=abc")
.unwrap()
.build()
.unwrap();
let url = client
.geocode("Zurich")
.count(2)
.language("en")
.country_code("CH")
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/geocoding/v1/search?token=abc&name=Zurich&count=2&language=en&country_code=CH"
);
}
#[test]
fn build_geocoding_url_with_api_key() {
let client = Client::builder()
.geocoding_base_url("https://example.com")
.unwrap()
.api_key("secret")
.build()
.unwrap();
let url = client
.geocode("Zurich")
.country_code("ch")
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/search?name=Zurich&country_code=CH&apikey=secret"
);
}
#[test]
fn rejects_empty_geocoding_name() {
let client = Client::new();
let err = client.geocode(" ").build_url().unwrap_err();
assert!(matches!(err, Error::InvalidParam { field: "name", .. }));
}
#[test]
fn rejects_zero_geocoding_count() {
let client = Client::new();
let err = client.geocode("Zurich").count(0).build_url().unwrap_err();
assert!(matches!(err, Error::InvalidParam { field: "count", .. }));
}
#[test]
fn rejects_invalid_country_code() {
let client = Client::new();
let err = client
.geocode("Zurich")
.country_code("CHE")
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "country_code",
..
}
));
}
#[test]
fn rejects_invalid_language() {
let client = Client::new();
let err = client
.geocode("Zurich")
.language("EN")
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "language",
..
}
));
}
#[test]
fn decodes_geocoding_json() {
let locations = decode_geocoding_json(
br#"{"results":[{"id":2657896,"name":"Zurich","latitude":47.36667,"longitude":8.55,"country_code":"CH"}]}"#,
)
.unwrap();
assert_eq!(locations.len(), 1);
assert_eq!(locations[0].id, 2657896);
assert_eq!(locations[0].country_code.as_deref(), Some("CH"));
}
#[test]
fn rejects_empty_required_location_name() {
assert!(
decode_geocoding_json(
br#"{"results":[{"id":1,"name":"","latitude":47.36667,"longitude":8.55}]}"#,
)
.is_err()
);
}
#[test]
fn decodes_empty_optional_strings_as_none() {
let locations = decode_geocoding_json(
br#"{"results":[{"id":2950159,"name":"Berlin","latitude":52.52437,"longitude":13.41053,"admin1":"Berlin","admin2":"","country_code":"DE"}]}"#,
)
.unwrap();
assert_eq!(locations[0].admin1.as_deref(), Some("Berlin"));
assert_eq!(locations[0].admin2, None);
}
#[test]
fn decodes_empty_geocoding_json() {
let locations = decode_geocoding_json(br#"{}"#).unwrap();
assert!(locations.is_empty());
}
}