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