Skip to main content

openmeteo_rs/endpoints/
elevation.rs

1//! Elevation endpoint.
2
3use reqwest::Url;
4use serde::Deserialize;
5
6use super::url::build_endpoint_url;
7use super::validation::validate_coordinates as validate_coordinate_pair;
8use crate::json_sanitize::sanitize_open_meteo_floats;
9use crate::{Client, Error, Result};
10
11const MAX_ELEVATION_COORDINATES: usize = 100;
12
13impl Client {
14    /// Looks up elevations for one or more coordinates.
15    ///
16    /// Returned values are metres above sea level and preserve the input
17    /// coordinate order. A value is `None` when Open-Meteo has no elevation
18    /// coverage for that coordinate. Open-Meteo accepts at most 100 coordinate
19    /// pairs per elevation request.
20    ///
21    /// ```
22    /// use openmeteo_rs::Client;
23    ///
24    /// # async fn example() -> openmeteo_rs::Result<()> {
25    /// let client = Client::new();
26    /// let elevations = client
27    ///     .elevation([(52.52, 13.41), (47.3769, 8.5417)])
28    ///     .await?;
29    ///
30    /// assert_eq!(elevations.len(), 2);
31    /// # Ok(())
32    /// # }
33    /// ```
34    pub async fn elevation<I>(&self, points: I) -> Result<Vec<Option<f32>>>
35    where
36        I: IntoIterator<Item = (f64, f64)>,
37    {
38        let points = points.into_iter().collect::<Vec<_>>();
39        let url = build_elevation_url(self, &points)?;
40        let body = self.execute(self.http.get(url)).await?;
41        decode_elevation_json(&body)
42    }
43}
44
45pub(crate) fn build_elevation_url(client: &Client, points: &[(f64, f64)]) -> Result<Url> {
46    validate_coordinates(points)?;
47
48    let latitude = points
49        .iter()
50        .map(|(latitude, _)| latitude.to_string())
51        .collect::<Vec<_>>()
52        .join(",");
53    let longitude = points
54        .iter()
55        .map(|(_, longitude)| longitude.to_string())
56        .collect::<Vec<_>>()
57        .join(",");
58
59    build_endpoint_url(
60        &client.elevation_base,
61        "elevation_base_url",
62        "v1/elevation",
63        client.api_key.as_deref(),
64        vec![("latitude", latitude), ("longitude", longitude)],
65    )
66}
67
68fn decode_elevation_json(bytes: &[u8]) -> Result<Vec<Option<f32>>> {
69    let sanitized = sanitize_open_meteo_floats(bytes);
70    let raw: RawElevationResponse = serde_json::from_slice(sanitized.as_ref())?;
71    Ok(raw.elevation)
72}
73
74fn validate_coordinates(points: &[(f64, f64)]) -> Result<()> {
75    if points.is_empty() {
76        return Err(Error::InvalidParam {
77            field: "coordinates",
78            reason: "set at least one coordinate pair".into(),
79        });
80    }
81
82    if points.len() > MAX_ELEVATION_COORDINATES {
83        return Err(Error::InvalidParam {
84            field: "coordinates",
85            reason: format!("must contain at most {MAX_ELEVATION_COORDINATES} coordinate pairs"),
86        });
87    }
88
89    for (latitude, longitude) in points {
90        validate_coordinate_pair(*latitude, *longitude)?;
91    }
92
93    Ok(())
94}
95
96#[derive(Debug, Deserialize)]
97struct RawElevationResponse {
98    elevation: Vec<Option<f32>>,
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn build_elevation_url_with_multiple_coordinates() {
107        let client = Client::builder()
108            .elevation_base_url("https://example.com/openmeteo?token=abc")
109            .unwrap()
110            .build()
111            .unwrap();
112
113        let url = build_elevation_url(&client, &[(52.52, 13.41), (47.3769, 8.5417)]).unwrap();
114
115        assert_eq!(
116            url.as_str(),
117            "https://example.com/openmeteo/v1/elevation?token=abc&latitude=52.52%2C47.3769&longitude=13.41%2C8.5417"
118        );
119    }
120
121    #[test]
122    fn build_elevation_url_with_api_key() {
123        let client = Client::builder()
124            .elevation_base_url("https://example.com")
125            .unwrap()
126            .api_key("secret")
127            .build()
128            .unwrap();
129
130        let url = build_elevation_url(&client, &[(52.52, 13.41)]).unwrap();
131
132        assert_eq!(
133            url.as_str(),
134            "https://example.com/v1/elevation?latitude=52.52&longitude=13.41&apikey=secret"
135        );
136    }
137
138    #[test]
139    fn rejects_empty_elevation_coordinates() {
140        let client = Client::new();
141        let err = build_elevation_url(&client, &[]).unwrap_err();
142
143        assert!(matches!(
144            err,
145            Error::InvalidParam {
146                field: "coordinates",
147                ..
148            }
149        ));
150    }
151
152    #[test]
153    fn rejects_too_many_elevation_coordinates() {
154        let client = Client::new();
155        let points = vec![(0.0, 0.0); MAX_ELEVATION_COORDINATES + 1];
156        let err = build_elevation_url(&client, &points).unwrap_err();
157
158        assert!(matches!(
159            err,
160            Error::InvalidParam {
161                field: "coordinates",
162                ..
163            }
164        ));
165    }
166
167    #[test]
168    fn rejects_invalid_elevation_coordinate() {
169        let client = Client::new();
170        let err = build_elevation_url(&client, &[(91.0, 0.0)]).unwrap_err();
171
172        assert!(matches!(
173            err,
174            Error::InvalidParam {
175                field: "latitude",
176                ..
177            }
178        ));
179    }
180
181    #[test]
182    fn decodes_elevation_json() {
183        let elevations = decode_elevation_json(br#"{"elevation":[38.0,409.0]}"#).unwrap();
184        assert_eq!(elevations, vec![Some(38.0), Some(409.0)]);
185    }
186
187    #[test]
188    fn decodes_elevation_nan_as_missing_value() {
189        let elevations = decode_elevation_json(br#"{"elevation":[0.0,nan,0.0]}"#).unwrap();
190        assert_eq!(elevations, vec![Some(0.0), None, Some(0.0)]);
191    }
192}