Skip to main content

aviso_validators/
point_cloud.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//! Point-cloud identifier validation.
10
11use crate::coordinate::{Coordinate, coordinates_to_json, parse_coordinate};
12use serde_json::Value;
13use thiserror::Error;
14
15/// Default and current hard limit for points in one cloud.
16pub const DEFAULT_MAX_POINTS: usize = 10_000;
17/// Current hard upper limit for configured point-cloud sizes.
18pub const HARD_MAX_POINTS: usize = 10_000;
19/// Conservative Aviso interoperability limit for canonical point-cloud JSON.
20///
21/// Point clouds are carried in backend metadata, including NATS headers for the
22/// JetStream backend. The limit is based on tested round trips with room for
23/// Aviso's other metadata, not a protocol-wide NATS header-size limit.
24pub const MAX_SERIALIZED_POINT_CLOUD_BYTES: usize = 60 * 1024;
25
26/// Errors produced by point-cloud validation.
27#[derive(Debug, Error)]
28#[non_exhaustive]
29pub enum PointCloudError {
30    /// The configured maximum was outside the supported range.
31    #[error("field '{field}' max_points must be between 1 and {hard_maximum}, got {configured}")]
32    InvalidMaxPoints {
33        field: String,
34        configured: usize,
35        hard_maximum: usize,
36    },
37    /// The value was not a JSON array.
38    #[error("field '{field}' must be a JSON array of [lat,lon] points")]
39    NotArray { field: String },
40    /// The cloud contained no points.
41    #[error("field '{field}' point cloud must contain at least one point")]
42    Empty { field: String },
43    /// The cloud exceeded its configured point count.
44    #[error("field '{field}' point cloud contains {actual} points, maximum is {maximum}")]
45    TooManyPoints {
46        field: String,
47        actual: usize,
48        maximum: usize,
49    },
50    /// One point was malformed or outside the coordinate ranges.
51    #[error("field '{field}' point {index} is invalid: {source}")]
52    InvalidPoint {
53        field: String,
54        index: usize,
55        #[source]
56        source: crate::coordinate::CoordinateError,
57    },
58    /// The canonical serialized cloud exceeded the storage safety limit.
59    #[error(
60        "field '{field}' point cloud canonical JSON is {actual} bytes, maximum is {maximum} bytes"
61    )]
62    SerializedSizeExceeded {
63        field: String,
64        actual: usize,
65        maximum: usize,
66    },
67    /// Canonical coordinate conversion failed.
68    #[error("field '{field}' point cloud canonicalization failed: {source}")]
69    Canonicalization {
70        field: String,
71        #[source]
72        source: crate::coordinate::CoordinateError,
73    },
74}
75
76/// Point-cloud coordinate validator.
77pub struct PointCloudHandler;
78
79/// Parsed coordinates and canonical JSON from one point-cloud validation pass.
80#[derive(Debug)]
81pub struct ValidatedPointCloud {
82    coordinates: Vec<Coordinate>,
83    canonical: Value,
84}
85
86impl ValidatedPointCloud {
87    /// Consume the validated cloud without reparsing its canonical JSON.
88    pub fn into_parts(self) -> (Vec<Coordinate>, Value) {
89        (self.coordinates, self.canonical)
90    }
91}
92
93impl PointCloudHandler {
94    /// Validate once and retain both parsed coordinates and canonical JSON.
95    pub fn validate(
96        value: &Value,
97        max_points: usize,
98        field_name: &str,
99    ) -> Result<ValidatedPointCloud, PointCloudError> {
100        let coordinates = Self::parse_coordinates(value, max_points, field_name)?;
101        let canonical = coordinates_to_json(&coordinates).map_err(|source| {
102            PointCloudError::Canonicalization {
103                field: field_name.to_string(),
104                source,
105            }
106        })?;
107        validate_serialized_size(field_name, canonical.to_string().len())?;
108        Ok(ValidatedPointCloud {
109            coordinates,
110            canonical,
111        })
112    }
113
114    /// Validate a non-empty JSON point array and return canonical JSON.
115    pub fn validate_and_canonicalize(
116        value: &Value,
117        max_points: usize,
118        field_name: &str,
119    ) -> Result<Value, PointCloudError> {
120        let (_, canonical) = Self::validate(value, max_points, field_name)?.into_parts();
121        Ok(canonical)
122    }
123
124    /// Parse a point-cloud JSON array while preserving order and duplicates.
125    pub fn parse_coordinates(
126        value: &Value,
127        max_points: usize,
128        field_name: &str,
129    ) -> Result<Vec<Coordinate>, PointCloudError> {
130        if max_points == 0 || max_points > HARD_MAX_POINTS {
131            return Err(PointCloudError::InvalidMaxPoints {
132                field: field_name.to_string(),
133                configured: max_points,
134                hard_maximum: HARD_MAX_POINTS,
135            });
136        }
137        let points = value.as_array().ok_or_else(|| PointCloudError::NotArray {
138            field: field_name.to_string(),
139        })?;
140        if points.is_empty() {
141            return Err(PointCloudError::Empty {
142                field: field_name.to_string(),
143            });
144        }
145        if points.len() > max_points {
146            return Err(PointCloudError::TooManyPoints {
147                field: field_name.to_string(),
148                actual: points.len(),
149                maximum: max_points,
150            });
151        }
152
153        points
154            .iter()
155            .enumerate()
156            .map(|(index, point)| {
157                parse_coordinate(point).map_err(|source| PointCloudError::InvalidPoint {
158                    field: field_name.to_string(),
159                    index,
160                    source,
161                })
162            })
163            .collect()
164    }
165}
166
167fn validate_serialized_size(field_name: &str, actual: usize) -> Result<(), PointCloudError> {
168    if actual > MAX_SERIALIZED_POINT_CLOUD_BYTES {
169        return Err(PointCloudError::SerializedSizeExceeded {
170            field: field_name.to_string(),
171            actual,
172            maximum: MAX_SERIALIZED_POINT_CLOUD_BYTES,
173        });
174    }
175    Ok(())
176}
177
178#[cfg(test)]
179mod tests {
180    use super::{MAX_SERIALIZED_POINT_CLOUD_BYTES, PointCloudError, PointCloudHandler};
181    use serde_json::{Value, json};
182
183    #[test]
184    fn accepts_one_point_and_preserves_duplicates_and_order() {
185        let value = json!([[1.0, 2.0], [1.0, 2.0], [-3.0, 4.0]]);
186        let canonical = PointCloudHandler::validate_and_canonicalize(&value, 10, "point_cloud")
187            .expect("valid cloud");
188        assert_eq!(canonical, json!([[1.0, 2.0], [1.0, 2.0], [-3.0, 4.0]]));
189
190        let one =
191            PointCloudHandler::validate_and_canonicalize(&json!([[52.55, 13.5]]), 1, "point_cloud");
192        assert!(one.is_ok());
193    }
194
195    #[test]
196    fn rejects_strings_empty_clouds_and_malformed_points() {
197        assert!(matches!(
198            PointCloudHandler::validate_and_canonicalize(&json!("1,2,3,4"), 10, "point_cloud"),
199            Err(PointCloudError::NotArray { .. })
200        ));
201        assert!(matches!(
202            PointCloudHandler::validate_and_canonicalize(&json!([]), 10, "point_cloud"),
203            Err(PointCloudError::Empty { .. })
204        ));
205        for malformed in [json!([[1.0]]), json!([[1.0, 2.0, 3.0]]), json!([["1", 2]])] {
206            assert!(matches!(
207                PointCloudHandler::validate_and_canonicalize(&malformed, 10, "point_cloud"),
208                Err(PointCloudError::InvalidPoint { .. })
209            ));
210        }
211    }
212
213    #[test]
214    fn rejects_out_of_range_and_too_many_points() {
215        for invalid in [json!([[91.0, 0.0]]), json!([[0.0, 181.0]])] {
216            assert!(matches!(
217                PointCloudHandler::validate_and_canonicalize(&invalid, 10, "point_cloud"),
218                Err(PointCloudError::InvalidPoint { .. })
219            ));
220        }
221        assert!(matches!(
222            PointCloudHandler::validate_and_canonicalize(
223                &json!([[1.0, 2.0], [3.0, 4.0]]),
224                1,
225                "point_cloud"
226            ),
227            Err(PointCloudError::TooManyPoints { .. })
228        ));
229    }
230
231    #[test]
232    fn serialized_size_boundary_is_inclusive() {
233        assert!(
234            super::validate_serialized_size("point_cloud", MAX_SERIALIZED_POINT_CLOUD_BYTES)
235                .is_ok()
236        );
237        assert!(matches!(
238            super::validate_serialized_size(
239                "point_cloud",
240                MAX_SERIALIZED_POINT_CLOUD_BYTES + 1
241            ),
242            Err(PointCloudError::SerializedSizeExceeded {
243                actual,
244                maximum,
245                ..
246            }) if actual == MAX_SERIALIZED_POINT_CLOUD_BYTES + 1
247                && maximum == MAX_SERIALIZED_POINT_CLOUD_BYTES
248        ));
249    }
250
251    #[test]
252    fn custom_max_points_accepts_exact_limit_and_rejects_next_point() {
253        assert!(
254            PointCloudHandler::validate_and_canonicalize(
255                &json!([[1.0, 2.0], [3.0, 4.0]]),
256                2,
257                "point_cloud"
258            )
259            .is_ok()
260        );
261        assert!(matches!(
262            PointCloudHandler::validate_and_canonicalize(
263                &json!([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]),
264                2,
265                "point_cloud"
266            ),
267            Err(PointCloudError::TooManyPoints {
268                actual: 3,
269                maximum: 2,
270                ..
271            })
272        ));
273    }
274
275    #[test]
276    fn rejects_oversized_canonical_json_independently_of_point_count() {
277        let point = json!([-89.12345678901234, -179.12345678901235]);
278        let cloud = Value::Array(vec![point; 5_000]);
279        assert!(matches!(
280            PointCloudHandler::validate_and_canonicalize(&cloud, 10_000, "point_cloud"),
281            Err(PointCloudError::SerializedSizeExceeded { .. })
282        ));
283    }
284
285    #[test]
286    fn rejects_configured_maximum_above_hard_limit() {
287        assert!(matches!(
288            PointCloudHandler::validate_and_canonicalize(
289                &json!([[1.0, 2.0]]),
290                10_001,
291                "point_cloud"
292            ),
293            Err(PointCloudError::InvalidMaxPoints { .. })
294        ));
295    }
296}