1use crate::coordinate::{Coordinate, coordinates_to_json, parse_coordinate};
12use serde_json::Value;
13use thiserror::Error;
14
15pub const DEFAULT_MAX_POINTS: usize = 10_000;
17pub const HARD_MAX_POINTS: usize = 10_000;
19pub const MAX_SERIALIZED_POINT_CLOUD_BYTES: usize = 60 * 1024;
25
26#[derive(Debug, Error)]
28#[non_exhaustive]
29pub enum PointCloudError {
30 #[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 #[error("field '{field}' must be a JSON array of [lat,lon] points")]
39 NotArray { field: String },
40 #[error("field '{field}' point cloud must contain at least one point")]
42 Empty { field: String },
43 #[error("field '{field}' point cloud contains {actual} points, maximum is {maximum}")]
45 TooManyPoints {
46 field: String,
47 actual: usize,
48 maximum: usize,
49 },
50 #[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 #[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 #[error("field '{field}' point cloud canonicalization failed: {source}")]
69 Canonicalization {
70 field: String,
71 #[source]
72 source: crate::coordinate::CoordinateError,
73 },
74}
75
76pub struct PointCloudHandler;
78
79#[derive(Debug)]
81pub struct ValidatedPointCloud {
82 coordinates: Vec<Coordinate>,
83 canonical: Value,
84}
85
86impl ValidatedPointCloud {
87 pub fn into_parts(self) -> (Vec<Coordinate>, Value) {
89 (self.coordinates, self.canonical)
90 }
91}
92
93impl PointCloudHandler {
94 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 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 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}