Skip to main content

aviso_validators/
coordinate.rs

1// (C) Copyright 2024- ECMWF and individual contributors.
2//
3// This software is licensed under the terms of the Apache Licence Version 2.0
4// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5// In applying this licence, ECMWF does not waive the privileges and immunities
6// granted to it by virtue of its status as an intergovernmental organisation nor
7// does it submit to any jurisdiction.
8
9//! Shared latitude/longitude coordinate parsing.
10
11use serde_json::{Number, Value};
12use thiserror::Error;
13
14/// A coordinate in API order: latitude, then longitude.
15pub type Coordinate = (f64, f64);
16
17/// Errors produced while parsing a JSON coordinate pair.
18#[derive(Debug, Error, PartialEq)]
19#[non_exhaustive]
20pub enum CoordinateError {
21    /// The coordinate was not a two-element JSON array.
22    #[error("coordinate must be a two-element array [lat,lon]")]
23    InvalidShape,
24    /// A coordinate component was not a JSON number.
25    #[error("{component} must be a number")]
26    NotNumber { component: &'static str },
27    /// A coordinate component was not finite.
28    #[error("{component} must be finite")]
29    NotFinite { component: &'static str },
30    /// Latitude was outside its valid range.
31    #[error("latitude {value} is outside the valid range [-90, 90]")]
32    LatitudeOutOfRange { value: f64 },
33    /// Longitude was outside its valid range.
34    #[error("longitude {value} is outside the valid range [-180, 180]")]
35    LongitudeOutOfRange { value: f64 },
36}
37
38/// Parse one JSON `[lat,lon]` pair with finite and range checks.
39///
40/// Valid: `[52.55,13.5]`. Invalid: `[13.5,52.55,1]`.
41pub fn parse_coordinate(value: &Value) -> Result<Coordinate, CoordinateError> {
42    let pair = value.as_array().ok_or(CoordinateError::InvalidShape)?;
43    if pair.len() != 2 {
44        return Err(CoordinateError::InvalidShape);
45    }
46
47    let lat = parse_component(&pair[0], "latitude")?;
48    let lon = parse_component(&pair[1], "longitude")?;
49    validate_coordinate(lat, lon)
50}
51
52/// Validate a latitude/longitude pair parsed from a compatible string format.
53pub fn validate_coordinate(lat: f64, lon: f64) -> Result<Coordinate, CoordinateError> {
54    if !lat.is_finite() {
55        return Err(CoordinateError::NotFinite {
56            component: "latitude",
57        });
58    }
59    if !lon.is_finite() {
60        return Err(CoordinateError::NotFinite {
61            component: "longitude",
62        });
63    }
64    if !(-90.0..=90.0).contains(&lat) {
65        return Err(CoordinateError::LatitudeOutOfRange { value: lat });
66    }
67    if !(-180.0..=180.0).contains(&lon) {
68        return Err(CoordinateError::LongitudeOutOfRange { value: lon });
69    }
70    Ok((lat, lon))
71}
72
73/// Convert coordinates to their canonical JSON array representation.
74pub fn coordinates_to_json(coordinates: &[Coordinate]) -> Result<Value, CoordinateError> {
75    coordinates
76        .iter()
77        .map(|&(lat, lon)| coordinate_to_json(validate_coordinate(lat, lon)?))
78        .collect::<Result<Vec<_>, _>>()
79        .map(Value::Array)
80}
81
82/// Convert one validated coordinate to canonical `[lat,lon]` JSON.
83pub fn coordinate_to_json(coordinate: Coordinate) -> Result<Value, CoordinateError> {
84    let (lat, lon) = validate_coordinate(coordinate.0, coordinate.1)?;
85    let latitude = Number::from_f64(lat).ok_or(CoordinateError::NotFinite {
86        component: "latitude",
87    })?;
88    let longitude = Number::from_f64(lon).ok_or(CoordinateError::NotFinite {
89        component: "longitude",
90    })?;
91    Ok(Value::Array(vec![
92        Value::Number(latitude),
93        Value::Number(longitude),
94    ]))
95}
96
97fn parse_component(value: &Value, component: &'static str) -> Result<f64, CoordinateError> {
98    let parsed = value
99        .as_f64()
100        .ok_or(CoordinateError::NotNumber { component })?;
101    if !parsed.is_finite() {
102        return Err(CoordinateError::NotFinite { component });
103    }
104    Ok(parsed)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::{CoordinateError, coordinates_to_json, parse_coordinate};
110    use serde_json::json;
111
112    #[test]
113    fn parses_and_canonicalizes_coordinate() {
114        let coordinate = parse_coordinate(&json!([52.5500, 13.5000])).unwrap();
115        assert_eq!(coordinate, (52.55, 13.5));
116        assert_eq!(
117            coordinates_to_json(&[coordinate]).unwrap(),
118            json!([[52.55, 13.5]])
119        );
120    }
121
122    #[test]
123    fn rejects_malformed_and_out_of_range_coordinates() {
124        assert_eq!(
125            parse_coordinate(&json!([1.0])),
126            Err(CoordinateError::InvalidShape)
127        );
128        assert!(matches!(
129            parse_coordinate(&json!([91.0, 0.0])),
130            Err(CoordinateError::LatitudeOutOfRange { .. })
131        ));
132        assert!(matches!(
133            parse_coordinate(&json!([0.0, 181.0])),
134            Err(CoordinateError::LongitudeOutOfRange { .. })
135        ));
136    }
137}