Skip to main content

aviso_validators/
polygon.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
9use anyhow::{Result, anyhow, bail};
10use tracing::debug;
11
12/// Polygon coordinate validator
13///
14/// Validates polygon coordinate strings in the format "(lat1,lon1,lat2,lon2,lat1,lon1)"
15/// and ensures proper polygon geometry (closed, minimum vertices, valid coordinates).
16pub struct PolygonHandler;
17
18impl PolygonHandler {
19    pub fn validate_and_canonicalize(value: &str, field_name: &str) -> Result<String> {
20        debug!(
21            "Validating polygon field '{}' with value: {}",
22            field_name, value
23        );
24
25        let coordinates = Self::parse_polygon_coordinates(value).map_err(|e| {
26            anyhow!("field '{}' must be a valid polygon: {}", field_name, e)
27        })?;
28        debug!(
29            "Parsed {} coordinate pairs for field '{}'",
30            coordinates.len(),
31            field_name
32        );
33
34        Self::validate_polygon_geometry(&coordinates).map_err(|e| {
35            anyhow!("field '{}' must be a valid polygon: {}", field_name, e)
36        })?;
37        debug!(
38            "Polygon geometry validation passed for field '{}'",
39            field_name
40        );
41
42        Ok(value.to_string())
43    }
44
45    /// Parse a polygon coordinate string into a vector of `(lat, lon)` tuples.
46    ///
47    /// Accepted forms (whitespace tolerated everywhere):
48    ///   * `"(lat1,lon1,...,lat1,lon1)"` — parenthesised, balanced
49    ///   * `"lat1,lon1,...,lat1,lon1"`   — no parentheses
50    ///
51    /// Rejected forms (each with a specific error message):
52    ///   * Opening `(` without a matching closing `)` (or vice versa)
53    ///   * Embedded `(` or `)` anywhere except as the single outer pair
54    ///   * Empty string or `()`
55    ///   * Odd number of comma-separated values
56    ///   * Any value that does not parse as `f64`
57    ///
58    /// This function ALWAYS returns `(lat, lon)` pairs. DO NOT swap here; only
59    /// swap to `(lon, lat)` when passing to the `geo` crate.
60    pub fn parse_polygon_coordinates(coord_string: &str) -> Result<Vec<(f64, f64)>> {
61        let raw = coord_string.trim();
62        if raw.is_empty() {
63            bail!("polygon coordinate string is empty");
64        }
65
66        let inner = match (raw.starts_with('('), raw.ends_with(')')) {
67            (true, true) => &raw[1..raw.len() - 1],
68            (false, false) => raw,
69            (true, false) => bail!(
70                "polygon coordinate string has opening '(' but is missing the closing ')'"
71            ),
72            (false, true) => bail!(
73                "polygon coordinate string has closing ')' but is missing the opening '('"
74            ),
75        };
76
77        if inner.contains('(') || inner.contains(')') {
78            bail!(
79                "polygon coordinate string must have at most one outer pair of parentheses; \
80                 nested '(' or ')' are not allowed"
81            );
82        }
83
84        let inner = inner.trim();
85        if inner.is_empty() {
86            bail!("polygon coordinate string is empty between parentheses");
87        }
88
89        let coord_parts: Vec<&str> = inner.split(',').collect();
90
91        if !coord_parts.len().is_multiple_of(2) {
92            bail!("polygon coordinates must be in lat,lon pairs (got an odd number of values)");
93        }
94
95        let mut coordinates = Vec::new();
96        let mut iter = coord_parts.iter();
97
98        while let Some(lat_str) = iter.next() {
99            let lon_str = iter.next().unwrap(); // Already checked length above
100
101            let lat: f64 = lat_str
102                .trim()
103                .parse()
104                .map_err(|_| anyhow!("could not parse latitude '{}' as a number", lat_str.trim()))?;
105
106            let lon: f64 = lon_str
107                .trim()
108                .parse()
109                .map_err(|_| anyhow!("could not parse longitude '{}' as a number", lon_str.trim()))?;
110
111            coordinates.push((lat, lon));
112        }
113
114        Ok(coordinates)
115    }
116
117    /// Validates polygon geometry requirements
118    fn validate_polygon_geometry(coordinates: &[(f64, f64)]) -> Result<()> {
119        if coordinates.len() < 4 {
120            bail!(
121                "polygon must have at least 4 coordinate pairs (3 unique vertices plus a \
122                 closing repeat of the first vertex)"
123            );
124        }
125
126        let first = coordinates.first().unwrap();
127        let last = coordinates.last().unwrap();
128
129        if first != last {
130            bail!("polygon must be closed (first and last coordinates must be identical)");
131        }
132
133        Ok(())
134    }
135
136    /// Calculates bounding box for spatial filtering optimization
137    /// This will be used later when we handle the payload and headers
138    pub fn calculate_bounding_box(coordinates: &[(f64, f64)]) -> String {
139        let mut min_lat = f64::INFINITY;
140        let mut min_lon = f64::INFINITY;
141        let mut max_lat = f64::NEG_INFINITY;
142        let mut max_lon = f64::NEG_INFINITY;
143
144        for &(lat, lon) in coordinates {
145            min_lat = min_lat.min(lat);
146            min_lon = min_lon.min(lon);
147            max_lat = max_lat.max(lat);
148            max_lon = max_lon.max(lon);
149        }
150
151        format!("{},{},{},{}", min_lat, min_lon, max_lat, max_lon)
152    }
153
154    pub fn parse_bbox_coordinates(s: &str) -> Result<(f64, f64, f64, f64)> {
155        // expects (lat_min,lon_min,lat_max,lon_max)
156        let s = s.trim_matches(|c| c == '(' || c == ')');
157        let coords: Vec<f64> = s
158            .split(',')
159            .map(|part| part.trim().parse())
160            .collect::<Result<_, _>>()?;
161        if coords.len() != 4 {
162            anyhow::bail!("BBox must have 4 numbers");
163        }
164        Ok((coords[0], coords[1], coords[2], coords[3]))
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_validate_and_canonicalize_valid_polygon() {
174        let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
175        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
176
177        assert!(result.is_ok());
178        assert_eq!(result.unwrap(), polygon_str);
179    }
180
181    #[test]
182    fn test_validate_and_canonicalize_with_spaces() {
183        let polygon_str = "( 52.5 , 13.4 , 52.6 , 13.5 , 52.5 , 13.6 , 52.4 , 13.5 , 52.5 , 13.4 )";
184        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
185
186        assert!(result.is_ok());
187        assert_eq!(result.unwrap(), polygon_str);
188    }
189
190    #[test]
191    fn test_validate_and_canonicalize_not_closed() {
192        let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5)"; // Missing closing point
193        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
194
195        assert!(result.is_err());
196    }
197
198    #[test]
199    fn test_validate_and_canonicalize_too_few_points() {
200        let polygon_str = "(52.5,13.4,52.6,13.5)"; // Only 2 points
201        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
202
203        assert!(result.is_err());
204    }
205
206    #[test]
207    fn test_validate_and_canonicalize_empty_string() {
208        let polygon_str = "";
209        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
210
211        assert!(result.is_err());
212    }
213
214    #[test]
215    fn test_validate_and_canonicalize_empty_parentheses() {
216        let polygon_str = "()";
217        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
218
219        assert!(result.is_err());
220    }
221
222    #[test]
223    fn test_parse_polygon_coordinates_valid() {
224        let coord_string = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
225        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
226
227        assert!(result.is_ok());
228        let coordinates = result.unwrap();
229        assert_eq!(coordinates.len(), 5);
230        assert_eq!(coordinates[0], (52.5, 13.4));
231        assert_eq!(coordinates[1], (52.6, 13.5));
232        assert_eq!(coordinates[4], (52.5, 13.4)); // Should be closed
233    }
234
235    #[test]
236    fn test_parse_polygon_coordinates_without_parentheses() {
237        let coord_string = "52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4";
238        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
239
240        assert!(result.is_ok());
241        let coordinates = result.unwrap();
242        assert_eq!(coordinates.len(), 5);
243        assert_eq!(coordinates[0], (52.5, 13.4));
244    }
245
246    #[test]
247    fn test_parse_polygon_coordinates_with_spaces() {
248        let coord_string = "( 52.5 , 13.4 , 52.6 , 13.5 , 52.5 , 13.4 )";
249        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
250
251        assert!(result.is_ok());
252        let coordinates = result.unwrap();
253        assert_eq!(coordinates.len(), 3);
254        assert_eq!(coordinates[0], (52.5, 13.4));
255        assert_eq!(coordinates[1], (52.6, 13.5));
256        assert_eq!(coordinates[2], (52.5, 13.4));
257    }
258
259    #[test]
260    fn test_parse_polygon_coordinates_odd_number() {
261        let coord_string = "(52.5,13.4,52.6)"; // Odd number of coordinates
262        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
263
264        assert!(result.is_err());
265    }
266
267    #[test]
268    fn test_parse_polygon_coordinates_invalid_latitude() {
269        let coord_string = "(invalid,13.4,52.6,13.5,52.5,13.4)";
270        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
271
272        assert!(result.is_err());
273    }
274
275    #[test]
276    fn test_parse_polygon_coordinates_invalid_longitude() {
277        let coord_string = "(52.5,invalid,52.6,13.5,52.5,13.4)";
278        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
279
280        assert!(result.is_err());
281    }
282
283    #[test]
284    fn test_parse_polygon_coordinates_empty() {
285        let coord_string = "()";
286        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
287
288        assert!(result.is_err());
289    }
290
291    #[test]
292    fn rejects_polygon_with_opening_paren_but_no_closing_paren() {
293        let coord_string = "(50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0";
294        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
295            .expect_err("unbalanced parens must be rejected");
296        let msg = err.to_string();
297        assert!(
298            msg.contains("opening") && msg.contains("missing the closing"),
299            "error should pinpoint the missing closing paren; got: {msg}"
300        );
301    }
302
303    #[test]
304    fn rejects_polygon_with_closing_paren_but_no_opening_paren() {
305        let coord_string = "50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0)";
306        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
307            .expect_err("unbalanced parens must be rejected");
308        let msg = err.to_string();
309        assert!(
310            msg.contains("closing") && msg.contains("missing the opening"),
311            "error should pinpoint the missing opening paren; got: {msg}"
312        );
313    }
314
315    #[test]
316    fn rejects_polygon_with_extra_nested_parens() {
317        let coord_string = "(50.0,10.0),52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0)";
318        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
319            .expect_err("nested parens must be rejected, not produce a confusing parse error");
320        let msg = err.to_string();
321        assert!(
322            msg.contains("nested") || msg.contains("outer pair"),
323            "error should mention parentheses placement, not e.g. a number-parse failure; got: {msg}"
324        );
325    }
326
327    #[test]
328    fn validate_and_canonicalize_wraps_errors_with_field_name_and_validation_marker() {
329        // The classifier in handlers::notification_processor matches "field '" and
330        // "must be a valid" to route polygon errors to a 400 response. This test
331        // pins both substrings so the public error-classification contract does
332        // not silently drift.
333        let bad = "(50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0";
334        let err = PolygonHandler::validate_and_canonicalize(bad, "polygon")
335            .expect_err("unbalanced polygon must error");
336        let msg = err.to_string();
337        assert!(
338            msg.contains("field 'polygon'") && msg.contains("must be a valid"),
339            "error must carry the validation-classifier markers; got: {msg}"
340        );
341    }
342
343    #[test]
344    fn test_validate_polygon_geometry_valid_triangle() {
345        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)];
346        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
347
348        assert!(result.is_ok());
349    }
350
351    #[test]
352    fn test_validate_polygon_geometry_valid_rectangle() {
353        let coordinates = vec![
354            (52.5, 13.4),
355            (52.6, 13.4),
356            (52.6, 13.5),
357            (52.5, 13.5),
358            (52.5, 13.4),
359        ];
360        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
361
362        assert!(result.is_ok());
363    }
364
365    #[test]
366    fn test_validate_polygon_geometry_too_few_points() {
367        let coordinates = vec![(0.0, 0.0), (1.0, 0.0)]; // Only 2 points
368        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
369
370        assert!(result.is_err());
371    }
372
373    #[test]
374    fn rejects_three_pair_closed_line_segment_as_degenerate_polygon() {
375        // Three pairs is two unique vertices closed back on the first, i.e. a line
376        // segment, not a polygon. The downstream geo conversion in
377        // src/notification/spatial.rs requires 4+ pairs; without rejecting here
378        // the request silently degraded to a 500 NOTIFICATION_PROCESSING_FAILED.
379        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.0, 0.0)];
380        let err = PolygonHandler::validate_polygon_geometry(&coordinates)
381            .expect_err("3-pair closed line segment must be rejected as a polygon");
382        let msg = err.to_string();
383        assert!(
384            msg.contains("at least 4 coordinate pairs"),
385            "error must specify the new minimum; got: {msg}"
386        );
387    }
388
389    #[test]
390    fn test_validate_polygon_geometry_not_closed() {
391        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.1, 0.1)]; // Not closed
392        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
393
394        assert!(result.is_err());
395    }
396
397    #[test]
398    fn test_validate_polygon_geometry_minimum_valid() {
399        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)]; // Minimum valid triangle
400        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
401
402        assert!(result.is_ok());
403    }
404
405    #[test]
406    fn test_calculate_bounding_box_rectangle() {
407        let coordinates = vec![
408            (52.5, 13.4),
409            (52.6, 13.4),
410            (52.6, 13.5),
411            (52.5, 13.5),
412            (52.5, 13.4),
413        ];
414        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
415
416        assert_eq!(bbox, "52.5,13.4,52.6,13.5");
417    }
418
419    #[test]
420    fn test_calculate_bounding_box_triangle() {
421        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)];
422        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
423
424        assert_eq!(bbox, "0,0,1,1");
425    }
426
427    #[test]
428    fn test_calculate_bounding_box_single_point() {
429        let coordinates = vec![(52.5, 13.4), (52.5, 13.4), (52.5, 13.4), (52.5, 13.4)];
430        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
431
432        assert_eq!(bbox, "52.5,13.4,52.5,13.4");
433    }
434
435    #[test]
436    fn test_calculate_bounding_box_negative_coordinates() {
437        let coordinates = vec![
438            (-1.0, -1.0),
439            (1.0, -1.0),
440            (1.0, 1.0),
441            (-1.0, 1.0),
442            (-1.0, -1.0),
443        ];
444        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
445
446        assert_eq!(bbox, "-1,-1,1,1");
447    }
448
449    #[test]
450    fn test_integration_parse_and_validate() {
451        let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
452
453        // Test the full pipeline: parse -> validate -> calculate bbox
454        let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
455        let validation_result = PolygonHandler::validate_polygon_geometry(&coordinates);
456        assert!(validation_result.is_ok());
457
458        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
459        assert_eq!(bbox, "52.4,13.4,52.6,13.6");
460    }
461
462    #[test]
463    fn test_real_world_berlin_polygon() {
464        // Real-world coordinates around Berlin
465        let polygon_str =
466            "(52.5200,13.4050,52.5200,13.4500,52.4800,13.4500,52.4800,13.4050,52.5200,13.4050)";
467        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "berlin_area");
468
469        assert!(result.is_ok());
470
471        let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
472        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
473        assert_eq!(bbox, "52.48,13.405,52.52,13.45");
474    }
475
476    #[test]
477    fn test_precision_handling() {
478        // Test with high precision coordinates
479        let polygon_str = "(52.123456789,13.987654321,52.234567890,13.876543210,52.345678901,13.765432109,52.123456789,13.987654321)";
480        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "precision_test");
481
482        assert!(result.is_ok());
483
484        let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
485        assert_eq!(coordinates[0].0, 52.123456789);
486        assert_eq!(coordinates[0].1, 13.987654321);
487    }
488}