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            // Range check after parse. `RangeInclusive::contains` uses f64's
112            // PartialOrd comparisons, so this single guard rejects NaN (every
113            // ordering comparison against NaN is false) and rejects ±inf (out
114            // of any finite range) without a separate is_finite() check.
115            if !(-90.0..=90.0).contains(&lat) {
116                bail!("latitude {} is outside the valid range [-90, 90]", lat);
117            }
118            if !(-180.0..=180.0).contains(&lon) {
119                bail!("longitude {} is outside the valid range [-180, 180]", lon);
120            }
121
122            coordinates.push((lat, lon));
123        }
124
125        Ok(coordinates)
126    }
127
128    /// Validates polygon geometry requirements
129    fn validate_polygon_geometry(coordinates: &[(f64, f64)]) -> Result<()> {
130        if coordinates.len() < 4 {
131            bail!(
132                "polygon must have at least 4 coordinate pairs (3 unique vertices plus a \
133                 closing repeat of the first vertex)"
134            );
135        }
136
137        let first = coordinates.first().unwrap();
138        let last = coordinates.last().unwrap();
139
140        if first != last {
141            bail!("polygon must be closed (first and last coordinates must be identical)");
142        }
143
144        Ok(())
145    }
146
147    /// Calculates bounding box for spatial filtering optimization
148    /// This will be used later when we handle the payload and headers
149    pub fn calculate_bounding_box(coordinates: &[(f64, f64)]) -> String {
150        let mut min_lat = f64::INFINITY;
151        let mut min_lon = f64::INFINITY;
152        let mut max_lat = f64::NEG_INFINITY;
153        let mut max_lon = f64::NEG_INFINITY;
154
155        for &(lat, lon) in coordinates {
156            min_lat = min_lat.min(lat);
157            min_lon = min_lon.min(lon);
158            max_lat = max_lat.max(lat);
159            max_lon = max_lon.max(lon);
160        }
161
162        format!("{},{},{},{}", min_lat, min_lon, max_lat, max_lon)
163    }
164
165    pub fn parse_bbox_coordinates(s: &str) -> Result<(f64, f64, f64, f64)> {
166        // expects (lat_min,lon_min,lat_max,lon_max)
167        let s = s.trim_matches(|c| c == '(' || c == ')');
168        let coords: Vec<f64> = s
169            .split(',')
170            .map(|part| part.trim().parse())
171            .collect::<Result<_, _>>()?;
172        if coords.len() != 4 {
173            anyhow::bail!("BBox must have 4 numbers");
174        }
175        Ok((coords[0], coords[1], coords[2], coords[3]))
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_validate_and_canonicalize_valid_polygon() {
185        let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
186        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
187
188        assert!(result.is_ok());
189        assert_eq!(result.unwrap(), polygon_str);
190    }
191
192    #[test]
193    fn test_validate_and_canonicalize_with_spaces() {
194        let polygon_str = "( 52.5 , 13.4 , 52.6 , 13.5 , 52.5 , 13.6 , 52.4 , 13.5 , 52.5 , 13.4 )";
195        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
196
197        assert!(result.is_ok());
198        assert_eq!(result.unwrap(), polygon_str);
199    }
200
201    #[test]
202    fn test_validate_and_canonicalize_not_closed() {
203        let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5)"; // Missing closing point
204        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
205
206        assert!(result.is_err());
207    }
208
209    #[test]
210    fn test_validate_and_canonicalize_too_few_points() {
211        let polygon_str = "(52.5,13.4,52.6,13.5)"; // Only 2 points
212        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
213
214        assert!(result.is_err());
215    }
216
217    #[test]
218    fn test_validate_and_canonicalize_empty_string() {
219        let polygon_str = "";
220        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
221
222        assert!(result.is_err());
223    }
224
225    #[test]
226    fn test_validate_and_canonicalize_empty_parentheses() {
227        let polygon_str = "()";
228        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
229
230        assert!(result.is_err());
231    }
232
233    #[test]
234    fn test_parse_polygon_coordinates_valid() {
235        let coord_string = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
236        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
237
238        assert!(result.is_ok());
239        let coordinates = result.unwrap();
240        assert_eq!(coordinates.len(), 5);
241        assert_eq!(coordinates[0], (52.5, 13.4));
242        assert_eq!(coordinates[1], (52.6, 13.5));
243        assert_eq!(coordinates[4], (52.5, 13.4)); // Should be closed
244    }
245
246    #[test]
247    fn test_parse_polygon_coordinates_without_parentheses() {
248        let coord_string = "52.5,13.4,52.6,13.5,52.5,13.6,52.4,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(), 5);
254        assert_eq!(coordinates[0], (52.5, 13.4));
255    }
256
257    #[test]
258    fn test_parse_polygon_coordinates_with_spaces() {
259        let coord_string = "( 52.5 , 13.4 , 52.6 , 13.5 , 52.5 , 13.4 )";
260        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
261
262        assert!(result.is_ok());
263        let coordinates = result.unwrap();
264        assert_eq!(coordinates.len(), 3);
265        assert_eq!(coordinates[0], (52.5, 13.4));
266        assert_eq!(coordinates[1], (52.6, 13.5));
267        assert_eq!(coordinates[2], (52.5, 13.4));
268    }
269
270    #[test]
271    fn test_parse_polygon_coordinates_odd_number() {
272        let coord_string = "(52.5,13.4,52.6)"; // Odd number of coordinates
273        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
274
275        assert!(result.is_err());
276    }
277
278    #[test]
279    fn test_parse_polygon_coordinates_invalid_latitude() {
280        let coord_string = "(invalid,13.4,52.6,13.5,52.5,13.4)";
281        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
282
283        assert!(result.is_err());
284    }
285
286    #[test]
287    fn test_parse_polygon_coordinates_invalid_longitude() {
288        let coord_string = "(52.5,invalid,52.6,13.5,52.5,13.4)";
289        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
290
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn test_parse_polygon_coordinates_empty() {
296        let coord_string = "()";
297        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
298
299        assert!(result.is_err());
300    }
301
302    #[test]
303    fn rejects_polygon_with_opening_paren_but_no_closing_paren() {
304        let coord_string = "(50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0";
305        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
306            .expect_err("unbalanced parens must be rejected");
307        let msg = err.to_string();
308        assert!(
309            msg.contains("opening") && msg.contains("missing the closing"),
310            "error should pinpoint the missing closing paren; got: {msg}"
311        );
312    }
313
314    #[test]
315    fn rejects_polygon_with_closing_paren_but_no_opening_paren() {
316        let coord_string = "50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0)";
317        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
318            .expect_err("unbalanced parens must be rejected");
319        let msg = err.to_string();
320        assert!(
321            msg.contains("closing") && msg.contains("missing the opening"),
322            "error should pinpoint the missing opening paren; got: {msg}"
323        );
324    }
325
326    #[test]
327    fn rejects_polygon_with_extra_nested_parens() {
328        let coord_string = "(50.0,10.0),52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0)";
329        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
330            .expect_err("nested parens must be rejected, not produce a confusing parse error");
331        let msg = err.to_string();
332        assert!(
333            msg.contains("nested") || msg.contains("outer pair"),
334            "error should mention parentheses placement, not e.g. a number-parse failure; got: {msg}"
335        );
336    }
337
338    #[test]
339    fn rejects_latitude_above_90() {
340        let coord_string = "(91.0,10.0,50.0,10.0,50.0,11.0,91.0,10.0)";
341        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
342            .expect_err("latitude > 90 must be rejected");
343        let msg = err.to_string();
344        assert!(
345            msg.contains("latitude") && msg.contains("-90") && msg.contains("90"),
346            "error should pinpoint the latitude range; got: {msg}"
347        );
348    }
349
350    #[test]
351    fn rejects_latitude_below_minus_90() {
352        let coord_string = "(-90.5,10.0,50.0,10.0,50.0,11.0,-90.5,10.0)";
353        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
354            .expect_err("latitude < -90 must be rejected");
355        let msg = err.to_string();
356        assert!(msg.contains("latitude") && msg.contains("-90.5"));
357    }
358
359    #[test]
360    fn rejects_longitude_above_180() {
361        let coord_string = "(50.0,181.0,50.0,10.0,50.0,11.0,50.0,181.0)";
362        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
363            .expect_err("longitude > 180 must be rejected");
364        let msg = err.to_string();
365        assert!(
366            msg.contains("longitude") && msg.contains("-180") && msg.contains("180"),
367            "error should pinpoint the longitude range; got: {msg}"
368        );
369    }
370
371    #[test]
372    fn rejects_longitude_below_minus_180() {
373        let coord_string = "(50.0,-180.5,50.0,10.0,50.0,11.0,50.0,-180.5)";
374        let err = PolygonHandler::parse_polygon_coordinates(coord_string)
375            .expect_err("longitude < -180 must be rejected");
376        let msg = err.to_string();
377        assert!(msg.contains("longitude") && msg.contains("-180.5"));
378    }
379
380    #[test]
381    fn rejects_non_finite_coordinates_via_range_check() {
382        // NaN and ±inf parse as valid f64 but fall outside any finite range, so
383        // the existing range check rejects them without a separate is_finite() guard.
384        for bad_value in &["NaN", "inf", "-inf"] {
385            let coord_string =
386                format!("({bad_value},10.0,50.0,10.0,50.0,11.0,{bad_value},10.0)");
387            assert!(
388                PolygonHandler::parse_polygon_coordinates(&coord_string).is_err(),
389                "non-finite coordinate `{bad_value}` must be rejected"
390            );
391        }
392    }
393
394    #[test]
395    fn accepts_exact_boundary_coordinates() {
396        let polygon = "(90.0,-180.0,89.0,-180.0,89.0,-179.0,90.0,-180.0)";
397        let result = PolygonHandler::parse_polygon_coordinates(polygon);
398        assert!(
399            result.is_ok(),
400            "lat=90 and lon=-180 are valid (inclusive) boundary values: {:?}",
401            result.err()
402        );
403    }
404
405    #[test]
406    fn validate_and_canonicalize_wraps_errors_with_field_name_and_validation_marker() {
407        // The classifier in handlers::notification_processor matches "field '" and
408        // "must be a valid" to route polygon errors to a 400 response. This test
409        // pins both substrings so the public error-classification contract does
410        // not silently drift.
411        let bad = "(50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0";
412        let err = PolygonHandler::validate_and_canonicalize(bad, "polygon")
413            .expect_err("unbalanced polygon must error");
414        let msg = err.to_string();
415        assert!(
416            msg.contains("field 'polygon'") && msg.contains("must be a valid"),
417            "error must carry the validation-classifier markers; got: {msg}"
418        );
419    }
420
421    #[test]
422    fn test_validate_polygon_geometry_valid_triangle() {
423        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)];
424        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
425
426        assert!(result.is_ok());
427    }
428
429    #[test]
430    fn test_validate_polygon_geometry_valid_rectangle() {
431        let coordinates = vec![
432            (52.5, 13.4),
433            (52.6, 13.4),
434            (52.6, 13.5),
435            (52.5, 13.5),
436            (52.5, 13.4),
437        ];
438        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
439
440        assert!(result.is_ok());
441    }
442
443    #[test]
444    fn test_validate_polygon_geometry_too_few_points() {
445        let coordinates = vec![(0.0, 0.0), (1.0, 0.0)]; // Only 2 points
446        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
447
448        assert!(result.is_err());
449    }
450
451    #[test]
452    fn rejects_three_pair_closed_line_segment_as_degenerate_polygon() {
453        // Three pairs is two unique vertices closed back on the first, i.e. a line
454        // segment, not a polygon. The downstream geo conversion in
455        // src/notification/spatial.rs requires 4+ pairs; without rejecting here
456        // the request silently degraded to a 500 NOTIFICATION_PROCESSING_FAILED.
457        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.0, 0.0)];
458        let err = PolygonHandler::validate_polygon_geometry(&coordinates)
459            .expect_err("3-pair closed line segment must be rejected as a polygon");
460        let msg = err.to_string();
461        assert!(
462            msg.contains("at least 4 coordinate pairs"),
463            "error must specify the new minimum; got: {msg}"
464        );
465    }
466
467    #[test]
468    fn test_validate_polygon_geometry_not_closed() {
469        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.1, 0.1)]; // Not closed
470        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
471
472        assert!(result.is_err());
473    }
474
475    #[test]
476    fn test_validate_polygon_geometry_minimum_valid() {
477        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)]; // Minimum valid triangle
478        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
479
480        assert!(result.is_ok());
481    }
482
483    #[test]
484    fn test_calculate_bounding_box_rectangle() {
485        let coordinates = vec![
486            (52.5, 13.4),
487            (52.6, 13.4),
488            (52.6, 13.5),
489            (52.5, 13.5),
490            (52.5, 13.4),
491        ];
492        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
493
494        assert_eq!(bbox, "52.5,13.4,52.6,13.5");
495    }
496
497    #[test]
498    fn test_calculate_bounding_box_triangle() {
499        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)];
500        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
501
502        assert_eq!(bbox, "0,0,1,1");
503    }
504
505    #[test]
506    fn test_calculate_bounding_box_single_point() {
507        let coordinates = vec![(52.5, 13.4), (52.5, 13.4), (52.5, 13.4), (52.5, 13.4)];
508        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
509
510        assert_eq!(bbox, "52.5,13.4,52.5,13.4");
511    }
512
513    #[test]
514    fn test_calculate_bounding_box_negative_coordinates() {
515        let coordinates = vec![
516            (-1.0, -1.0),
517            (1.0, -1.0),
518            (1.0, 1.0),
519            (-1.0, 1.0),
520            (-1.0, -1.0),
521        ];
522        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
523
524        assert_eq!(bbox, "-1,-1,1,1");
525    }
526
527    #[test]
528    fn test_integration_parse_and_validate() {
529        let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
530
531        // Test the full pipeline: parse -> validate -> calculate bbox
532        let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
533        let validation_result = PolygonHandler::validate_polygon_geometry(&coordinates);
534        assert!(validation_result.is_ok());
535
536        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
537        assert_eq!(bbox, "52.4,13.4,52.6,13.6");
538    }
539
540    #[test]
541    fn test_real_world_berlin_polygon() {
542        // Real-world coordinates around Berlin
543        let polygon_str =
544            "(52.5200,13.4050,52.5200,13.4500,52.4800,13.4500,52.4800,13.4050,52.5200,13.4050)";
545        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "berlin_area");
546
547        assert!(result.is_ok());
548
549        let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
550        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
551        assert_eq!(bbox, "52.48,13.405,52.52,13.45");
552    }
553
554    #[test]
555    fn test_precision_handling() {
556        // Test with high precision coordinates
557        let polygon_str = "(52.123456789,13.987654321,52.234567890,13.876543210,52.345678901,13.765432109,52.123456789,13.987654321)";
558        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "precision_test");
559
560        assert!(result.is_ok());
561
562        let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
563        assert_eq!(coordinates[0].0, 52.123456789);
564        assert_eq!(coordinates[0].1, 13.987654321);
565    }
566}