openmeteo-rs 1.0.0

Rust client for the Open-Meteo weather API.
Documentation
//! Geocoding endpoint.

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;

/// Builder for the `/v1/search` geocoding endpoint.
#[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,
        }
    }

    /// Limits the number of returned matches.
    ///
    /// Open-Meteo accepts values from 1 to 100. If called repeatedly, the most
    /// recent value wins.
    pub fn count(mut self, count: u8) -> Self {
        self.count = Some(count);
        self
    }

    /// Sets the two-letter result language code, such as `en` or `de`.
    ///
    /// The code must be lowercase ASCII. If called repeatedly, the most recent
    /// value wins.
    pub fn language(mut self, language: impl Into<String>) -> Self {
        self.language = Some(language.into());
        self
    }

    /// Restricts results to an ISO 3166-1 alpha-2 country code, such as `CH`.
    ///
    /// The value is normalized to uppercase ASCII. If called repeatedly, the
    /// most recent value wins.
    pub fn country_code(mut self, country_code: impl Into<String>) -> Self {
        self.country_code = Some(country_code.into().to_ascii_uppercase());
        self
    }

    /// Sends the request and decodes matching locations.
    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(())
    }
}

/// A location returned by the Open-Meteo geocoding endpoint.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct GeocodedLocation {
    /// GeoNames location identifier.
    pub id: u64,
    /// Display name.
    #[serde(deserialize_with = "deserialize_non_empty_string")]
    pub name: String,
    /// Latitude in decimal degrees.
    pub latitude: f64,
    /// Longitude in decimal degrees.
    pub longitude: f64,
    /// Elevation in metres, when known.
    pub elevation: Option<f64>,
    /// GeoNames feature code.
    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
    pub feature_code: Option<String>,
    /// ISO 3166-1 alpha-2 country code.
    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
    pub country_code: Option<String>,
    /// Country display name.
    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
    pub country: Option<String>,
    /// GeoNames country identifier.
    pub country_id: Option<u64>,
    /// IANA timezone name.
    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
    pub timezone: Option<String>,
    /// Population, when known.
    pub population: Option<u64>,
    /// Postal codes associated with the result.
    pub postcodes: Option<Vec<String>>,
    /// First-level administrative area.
    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
    pub admin1: Option<String>,
    /// Second-level administrative area.
    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
    pub admin2: Option<String>,
    /// Third-level administrative area.
    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
    pub admin3: Option<String>,
    /// Fourth-level administrative area.
    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
    pub admin4: Option<String>,
    /// GeoNames first-level administrative identifier.
    pub admin1_id: Option<u64>,
    /// GeoNames second-level administrative identifier.
    pub admin2_id: Option<u64>,
    /// GeoNames third-level administrative identifier.
    pub admin3_id: Option<u64>,
    /// GeoNames fourth-level administrative identifier.
    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());
    }
}