openmeteo-rs 1.0.0

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

use reqwest::Url;
use serde::Deserialize;

use super::url::build_endpoint_url;
use super::validation::validate_coordinates as validate_coordinate_pair;
use crate::json_sanitize::sanitize_open_meteo_floats;
use crate::{Client, Error, Result};

const MAX_ELEVATION_COORDINATES: usize = 100;

impl Client {
    /// Looks up elevations for one or more coordinates.
    ///
    /// Returned values are metres above sea level and preserve the input
    /// coordinate order. A value is `None` when Open-Meteo has no elevation
    /// coverage for that coordinate. Open-Meteo accepts at most 100 coordinate
    /// pairs per elevation request.
    ///
    /// ```
    /// use openmeteo_rs::Client;
    ///
    /// # async fn example() -> openmeteo_rs::Result<()> {
    /// let client = Client::new();
    /// let elevations = client
    ///     .elevation([(52.52, 13.41), (47.3769, 8.5417)])
    ///     .await?;
    ///
    /// assert_eq!(elevations.len(), 2);
    /// # Ok(())
    /// # }
    /// ```
    pub async fn elevation<I>(&self, points: I) -> Result<Vec<Option<f32>>>
    where
        I: IntoIterator<Item = (f64, f64)>,
    {
        let points = points.into_iter().collect::<Vec<_>>();
        let url = build_elevation_url(self, &points)?;
        let body = self.execute(self.http.get(url)).await?;
        decode_elevation_json(&body)
    }
}

pub(crate) fn build_elevation_url(client: &Client, points: &[(f64, f64)]) -> Result<Url> {
    validate_coordinates(points)?;

    let latitude = points
        .iter()
        .map(|(latitude, _)| latitude.to_string())
        .collect::<Vec<_>>()
        .join(",");
    let longitude = points
        .iter()
        .map(|(_, longitude)| longitude.to_string())
        .collect::<Vec<_>>()
        .join(",");

    build_endpoint_url(
        &client.elevation_base,
        "elevation_base_url",
        "v1/elevation",
        client.api_key.as_deref(),
        vec![("latitude", latitude), ("longitude", longitude)],
    )
}

fn decode_elevation_json(bytes: &[u8]) -> Result<Vec<Option<f32>>> {
    let sanitized = sanitize_open_meteo_floats(bytes);
    let raw: RawElevationResponse = serde_json::from_slice(sanitized.as_ref())?;
    Ok(raw.elevation)
}

fn validate_coordinates(points: &[(f64, f64)]) -> Result<()> {
    if points.is_empty() {
        return Err(Error::InvalidParam {
            field: "coordinates",
            reason: "set at least one coordinate pair".into(),
        });
    }

    if points.len() > MAX_ELEVATION_COORDINATES {
        return Err(Error::InvalidParam {
            field: "coordinates",
            reason: format!("must contain at most {MAX_ELEVATION_COORDINATES} coordinate pairs"),
        });
    }

    for (latitude, longitude) in points {
        validate_coordinate_pair(*latitude, *longitude)?;
    }

    Ok(())
}

#[derive(Debug, Deserialize)]
struct RawElevationResponse {
    elevation: Vec<Option<f32>>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_elevation_url_with_multiple_coordinates() {
        let client = Client::builder()
            .elevation_base_url("https://example.com/openmeteo?token=abc")
            .unwrap()
            .build()
            .unwrap();

        let url = build_elevation_url(&client, &[(52.52, 13.41), (47.3769, 8.5417)]).unwrap();

        assert_eq!(
            url.as_str(),
            "https://example.com/openmeteo/v1/elevation?token=abc&latitude=52.52%2C47.3769&longitude=13.41%2C8.5417"
        );
    }

    #[test]
    fn build_elevation_url_with_api_key() {
        let client = Client::builder()
            .elevation_base_url("https://example.com")
            .unwrap()
            .api_key("secret")
            .build()
            .unwrap();

        let url = build_elevation_url(&client, &[(52.52, 13.41)]).unwrap();

        assert_eq!(
            url.as_str(),
            "https://example.com/v1/elevation?latitude=52.52&longitude=13.41&apikey=secret"
        );
    }

    #[test]
    fn rejects_empty_elevation_coordinates() {
        let client = Client::new();
        let err = build_elevation_url(&client, &[]).unwrap_err();

        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "coordinates",
                ..
            }
        ));
    }

    #[test]
    fn rejects_too_many_elevation_coordinates() {
        let client = Client::new();
        let points = vec![(0.0, 0.0); MAX_ELEVATION_COORDINATES + 1];
        let err = build_elevation_url(&client, &points).unwrap_err();

        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "coordinates",
                ..
            }
        ));
    }

    #[test]
    fn rejects_invalid_elevation_coordinate() {
        let client = Client::new();
        let err = build_elevation_url(&client, &[(91.0, 0.0)]).unwrap_err();

        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "latitude",
                ..
            }
        ));
    }

    #[test]
    fn decodes_elevation_json() {
        let elevations = decode_elevation_json(br#"{"elevation":[38.0,409.0]}"#).unwrap();
        assert_eq!(elevations, vec![Some(38.0), Some(409.0)]);
    }

    #[test]
    fn decodes_elevation_nan_as_missing_value() {
        let elevations = decode_elevation_json(br#"{"elevation":[0.0,nan,0.0]}"#).unwrap();
        assert_eq!(elevations, vec![Some(0.0), None, Some(0.0)]);
    }
}