Skip to content

Commit 71b4fd3

Browse files
authored
Merge pull request #94 from ecmwf/fix/cloudevent-roundtrip-polygon
fix: polygon validation correctness pass + homepage polish (release 0.6.1)
2 parents dc8255a + 8b366ad commit 71b4fd3

18 files changed

Lines changed: 573 additions & 58 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "aviso-server"
3-
version = "0.6.0"
3+
version = "0.6.1"
44
edition = "2024"
55
authors = ["Samet Demir <samet.demir@ecmwf.int>",
66
"James Hawkes <james.hawkes@ecmwf.int>"]
@@ -23,7 +23,7 @@ path = "src/main.rs"
2323
name = "aviso_server"
2424

2525
[dependencies]
26-
aviso-validators = { version = "0.6.0", path = "aviso-validators", features = ["openapi"] }
26+
aviso-validators = { version = "0.6.1", path = "aviso-validators", features = ["openapi"] }
2727
actix-web = "4.13"
2828
actix-files = "0.6"
2929
utoipa = { version = "5.5", features = ["actix_extras", "chrono", "uuid"] }
@@ -55,7 +55,7 @@ tokio-util = "0.7"
5555
geo = "0.33"
5656
geo-types = "0.7"
5757
prometheus = { version = "0.14", features = ["process"] }
58-
aviso-ecpds = { version = "0.6.0", path = "aviso-ecpds", optional = true }
58+
aviso-ecpds = { version = "0.6.1", path = "aviso-ecpds", optional = true }
5959

6060
[features]
6161
default = []

aviso-ecpds/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

aviso-ecpds/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "aviso-ecpds"
3-
version = "0.6.0"
3+
version = "0.6.1"
44
edition = "2024"
55
description = "ECPDS destination authorization plugin for aviso-server."
66
license = "Apache-2.0"

aviso-validators/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

aviso-validators/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "aviso-validators"
3-
version = "0.6.0"
3+
version = "0.6.1"
44
edition = "2024"
55
description = "Validation primitives used by aviso-server for identifier and payload checks."
66
license = "Apache-2.0"

aviso-validators/src/polygon.rs

Lines changed: 125 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// granted to it by virtue of its status as an intergovernmental organisation nor
77
// does it submit to any jurisdiction.
88

9-
use anyhow::{Result, bail};
9+
use anyhow::{Result, anyhow, bail};
1010
use tracing::debug;
1111

1212
/// Polygon coordinate validator
@@ -22,45 +22,74 @@ impl PolygonHandler {
2222
field_name, value
2323
);
2424

25-
// Parse the coordinate string
26-
let coordinates = Self::parse_polygon_coordinates(value)?;
25+
let coordinates = Self::parse_polygon_coordinates(value).map_err(|e| {
26+
anyhow!("field '{}' must be a valid polygon: {}", field_name, e)
27+
})?;
2728
debug!(
2829
"Parsed {} coordinate pairs for field '{}'",
2930
coordinates.len(),
3031
field_name
3132
);
3233

33-
// Validate the polygon
34-
Self::validate_polygon_geometry(&coordinates)?;
34+
Self::validate_polygon_geometry(&coordinates).map_err(|e| {
35+
anyhow!("field '{}' must be a valid polygon: {}", field_name, e)
36+
})?;
3537
debug!(
3638
"Polygon geometry validation passed for field '{}'",
3739
field_name
3840
);
3941

40-
// Return the original validated string
41-
// (JSON conversion will happen elsewhere when building the payload)
4242
Ok(value.to_string())
4343
}
4444

45-
/// Parse a string of coordinates "(lat,lon,lat,lon,...)" into a vector of (lat, lon) tuples.
45+
/// Parse a polygon coordinate string into a vector of `(lat, lon)` tuples.
4646
///
47-
/// This function ALWAYS returns (lat, lon)
48-
/// DO NOT swap here. Only swap to (lon, lat) when passing to geo crate.
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.
4960
pub fn parse_polygon_coordinates(coord_string: &str) -> Result<Vec<(f64, f64)>> {
50-
let trimmed = coord_string
51-
.trim()
52-
.trim_start_matches('(')
53-
.trim_end_matches(')')
54-
.trim();
55-
56-
if trimmed.is_empty() {
57-
bail!("Empty polygon coordinate string");
61+
let raw = coord_string.trim();
62+
if raw.is_empty() {
63+
bail!("polygon coordinate string is empty");
5864
}
5965

60-
let coord_parts: Vec<&str> = trimmed.split(',').collect();
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();
6190

6291
if !coord_parts.len().is_multiple_of(2) {
63-
bail!("Polygon coordinates must be in pairs (lat,lon)");
92+
bail!("polygon coordinates must be in lat,lon pairs (got an odd number of values)");
6493
}
6594

6695
let mut coordinates = Vec::new();
@@ -72,12 +101,12 @@ impl PolygonHandler {
72101
let lat: f64 = lat_str
73102
.trim()
74103
.parse()
75-
.map_err(|_| anyhow::anyhow!("Invalid latitude value: {}", lat_str))?;
104+
.map_err(|_| anyhow!("could not parse latitude '{}' as a number", lat_str.trim()))?;
76105

77106
let lon: f64 = lon_str
78107
.trim()
79108
.parse()
80-
.map_err(|_| anyhow::anyhow!("Invalid longitude value: {}", lon_str))?;
109+
.map_err(|_| anyhow!("could not parse longitude '{}' as a number", lon_str.trim()))?;
81110

82111
coordinates.push((lat, lon));
83112
}
@@ -87,16 +116,18 @@ impl PolygonHandler {
87116

88117
/// Validates polygon geometry requirements
89118
fn validate_polygon_geometry(coordinates: &[(f64, f64)]) -> Result<()> {
90-
if coordinates.len() < 3 {
91-
bail!("Polygon must have at least 3 coordinate pairs");
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+
);
92124
}
93125

94-
// Check if polygon is closed (first and last coordinates are the same)
95126
let first = coordinates.first().unwrap();
96127
let last = coordinates.last().unwrap();
97128

98129
if first != last {
99-
bail!("Polygon must be closed (first and last coordinates must be identical)");
130+
bail!("polygon must be closed (first and last coordinates must be identical)");
100131
}
101132

102133
Ok(())
@@ -257,6 +288,58 @@ mod tests {
257288
assert!(result.is_err());
258289
}
259290

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+
260343
#[test]
261344
fn test_validate_polygon_geometry_valid_triangle() {
262345
let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)];
@@ -287,6 +370,22 @@ mod tests {
287370
assert!(result.is_err());
288371
}
289372

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+
290389
#[test]
291390
fn test_validate_polygon_geometry_not_closed() {
292391
let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.1, 0.1)]; // Not closed

docs/src/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ Expected response:
146146
{
147147
"status": "success",
148148
"request_id": "0d4f6758-1ce3-4dda-a0f3-0ccf5fcb50d6",
149-
"processed_at": "2026-03-04T10:00:00.123456+00:00"
149+
"processed_at": "2026-03-04T10:00:00Z"
150150
}
151151
```
152152

0 commit comments

Comments
 (0)