Skip to main content

aviso_validators/
constraints.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
9//! Constraint parsing and evaluation helpers for identifier filters.
10//!
11//! These helpers are schema-agnostic and validate one constraint object at a time.
12
13use anyhow::{Context, Result, bail};
14use serde_json::Value;
15
16#[derive(Debug, Clone, PartialEq)]
17pub enum NumericConstraint<T> {
18    Eq(T),
19    In(Vec<T>),
20    Gt(T),
21    Gte(T),
22    Lt(T),
23    Lte(T),
24    Between(T, T),
25}
26
27impl NumericConstraint<i64> {
28    pub fn matches(&self, value: i64) -> bool {
29        matches_numeric(self, value)
30    }
31}
32
33impl NumericConstraint<f64> {
34    pub fn matches(&self, value: f64) -> bool {
35        if !value.is_finite() {
36            return false;
37        }
38        // Exact comparison keeps replay/live filtering deterministic and avoids hidden
39        // tolerance behavior when identifiers are canonicalized into topic tokens.
40        matches_numeric(self, value)
41    }
42}
43
44fn matches_numeric<T>(constraint: &NumericConstraint<T>, value: T) -> bool
45where
46    T: PartialOrd + PartialEq + Copy,
47{
48    match constraint {
49        NumericConstraint::Eq(v) => value == *v,
50        NumericConstraint::In(values) => values.contains(&value),
51        NumericConstraint::Gt(v) => value > *v,
52        NumericConstraint::Gte(v) => value >= *v,
53        NumericConstraint::Lt(v) => value < *v,
54        NumericConstraint::Lte(v) => value <= *v,
55        NumericConstraint::Between(min, max) => value >= *min && value <= *max,
56    }
57}
58
59#[derive(Debug, Clone, PartialEq)]
60pub enum EnumConstraint {
61    Eq(String),
62    In(Vec<String>),
63}
64
65impl EnumConstraint {
66    pub fn matches(&self, value: &str) -> bool {
67        let canonical = value.to_lowercase();
68        match self {
69            EnumConstraint::Eq(v) => canonical == *v,
70            EnumConstraint::In(values) => values.iter().any(|v| v == &canonical),
71        }
72    }
73}
74
75pub fn parse_int_constraint(
76    field_name: &str,
77    value: &Value,
78    range: Option<&[i64; 2]>,
79) -> Result<NumericConstraint<i64>> {
80    let object = parse_constraint_object(field_name, value)?;
81    let (operator, operand) = single_operator(field_name, object)?;
82
83    let constraint = match operator {
84        "eq" => NumericConstraint::Eq(parse_int_operand(field_name, operator, operand)?),
85        "in" => NumericConstraint::In(parse_int_array_operand(field_name, operator, operand)?),
86        "gt" => NumericConstraint::Gt(parse_int_operand(field_name, operator, operand)?),
87        "gte" => NumericConstraint::Gte(parse_int_operand(field_name, operator, operand)?),
88        "lt" => NumericConstraint::Lt(parse_int_operand(field_name, operator, operand)?),
89        "lte" => NumericConstraint::Lte(parse_int_operand(field_name, operator, operand)?),
90        "between" => {
91            let (min, max) = parse_int_between_operand(field_name, operator, operand)?;
92            NumericConstraint::Between(min, max)
93        }
94        _ => {
95            bail!(
96                "Field '{}' has invalid operator '{}'. Allowed: eq,in,gt,gte,lt,lte,between",
97                field_name,
98                operator
99            )
100        }
101    };
102
103    validate_int_constraint_against_range(field_name, &constraint, range)?;
104    Ok(constraint)
105}
106
107pub fn parse_float_constraint(
108    field_name: &str,
109    value: &Value,
110    range: Option<&[f64; 2]>,
111) -> Result<NumericConstraint<f64>> {
112    let object = parse_constraint_object(field_name, value)?;
113    let (operator, operand) = single_operator(field_name, object)?;
114
115    let constraint = match operator {
116        "eq" => NumericConstraint::Eq(parse_float_operand(field_name, operator, operand)?),
117        "in" => NumericConstraint::In(parse_float_array_operand(field_name, operator, operand)?),
118        "gt" => NumericConstraint::Gt(parse_float_operand(field_name, operator, operand)?),
119        "gte" => NumericConstraint::Gte(parse_float_operand(field_name, operator, operand)?),
120        "lt" => NumericConstraint::Lt(parse_float_operand(field_name, operator, operand)?),
121        "lte" => NumericConstraint::Lte(parse_float_operand(field_name, operator, operand)?),
122        "between" => {
123            let (min, max) = parse_float_between_operand(field_name, operator, operand)?;
124            NumericConstraint::Between(min, max)
125        }
126        _ => {
127            bail!(
128                "Field '{}' has invalid operator '{}'. Allowed: eq,in,gt,gte,lt,lte,between",
129                field_name,
130                operator
131            )
132        }
133    };
134
135    validate_float_constraint_against_range(field_name, &constraint, range)?;
136    Ok(constraint)
137}
138
139pub fn parse_enum_constraint(
140    field_name: &str,
141    value: &Value,
142    allowed_values: &[String],
143) -> Result<EnumConstraint> {
144    let object = parse_constraint_object(field_name, value)?;
145    let (operator, operand) = single_operator(field_name, object)?;
146    let normalized_allowed: Vec<String> = allowed_values.iter().map(|v| v.to_lowercase()).collect();
147
148    let constraint = match operator {
149        "eq" => {
150            let parsed = parse_enum_operand(field_name, operator, operand)?;
151            validate_enum_value(field_name, &parsed, &normalized_allowed)?;
152            EnumConstraint::Eq(parsed)
153        }
154        "in" => {
155            let parsed = parse_enum_array_operand(field_name, operator, operand)?;
156            for value in &parsed {
157                validate_enum_value(field_name, value, &normalized_allowed)?;
158            }
159            EnumConstraint::In(parsed)
160        }
161        _ => {
162            bail!(
163                "Field '{}' has invalid operator '{}'. Allowed for enum constraints: eq,in",
164                field_name,
165                operator
166            )
167        }
168    };
169
170    Ok(constraint)
171}
172
173fn parse_constraint_object<'a>(
174    field_name: &str,
175    value: &'a Value,
176) -> Result<&'a serde_json::Map<String, Value>> {
177    value.as_object().ok_or_else(|| {
178        anyhow::anyhow!(
179            "Field '{}' constraint must be an object (for example {{\"gte\": 4}})",
180            field_name
181        )
182    })
183}
184
185fn single_operator<'a>(
186    field_name: &str,
187    object: &'a serde_json::Map<String, Value>,
188) -> Result<(&'a str, &'a Value)> {
189    if object.len() != 1 {
190        bail!(
191            "Field '{}' constraint must contain exactly one operator",
192            field_name
193        );
194    }
195    let (operator, operand) = object
196        .iter()
197        .next()
198        .context("constraint object unexpectedly empty")?;
199    Ok((operator.as_str(), operand))
200}
201
202fn parse_int_operand(field_name: &str, operator: &str, value: &Value) -> Result<i64> {
203    value.as_i64().ok_or_else(|| {
204        anyhow::anyhow!(
205            "Field '{}' operator '{}' expects integer value",
206            field_name,
207            operator
208        )
209    })
210}
211
212fn parse_int_array_operand(field_name: &str, operator: &str, value: &Value) -> Result<Vec<i64>> {
213    let values = value.as_array().ok_or_else(|| {
214        anyhow::anyhow!(
215            "Field '{}' operator '{}' expects array of integers",
216            field_name,
217            operator
218        )
219    })?;
220    if values.is_empty() {
221        bail!(
222            "Field '{}' operator '{}' must contain at least one value",
223            field_name,
224            operator
225        );
226    }
227    values
228        .iter()
229        .map(|v| parse_int_operand(field_name, operator, v))
230        .collect()
231}
232
233fn parse_int_between_operand(
234    field_name: &str,
235    operator: &str,
236    value: &Value,
237) -> Result<(i64, i64)> {
238    let values = value.as_array().ok_or_else(|| {
239        anyhow::anyhow!(
240            "Field '{}' operator '{}' expects [min,max]",
241            field_name,
242            operator
243        )
244    })?;
245    if values.len() != 2 {
246        bail!(
247            "Field '{}' operator '{}' expects exactly two values [min,max]",
248            field_name,
249            operator
250        );
251    }
252    let min = parse_int_operand(field_name, operator, &values[0])?;
253    let max = parse_int_operand(field_name, operator, &values[1])?;
254    if min > max {
255        bail!(
256            "Field '{}' operator '{}' expects min <= max",
257            field_name,
258            operator
259        );
260    }
261    Ok((min, max))
262}
263
264fn parse_float_operand(field_name: &str, operator: &str, value: &Value) -> Result<f64> {
265    let parsed = value.as_f64().ok_or_else(|| {
266        anyhow::anyhow!(
267            "Field '{}' operator '{}' expects numeric value",
268            field_name,
269            operator
270        )
271    })?;
272
273    if !parsed.is_finite() {
274        bail!(
275            "Field '{}' operator '{}' expects finite numeric value",
276            field_name,
277            operator
278        );
279    }
280
281    Ok(parsed)
282}
283
284fn parse_float_array_operand(field_name: &str, operator: &str, value: &Value) -> Result<Vec<f64>> {
285    let values = value.as_array().ok_or_else(|| {
286        anyhow::anyhow!(
287            "Field '{}' operator '{}' expects array of numbers",
288            field_name,
289            operator
290        )
291    })?;
292    if values.is_empty() {
293        bail!(
294            "Field '{}' operator '{}' must contain at least one value",
295            field_name,
296            operator
297        );
298    }
299    values
300        .iter()
301        .map(|v| parse_float_operand(field_name, operator, v))
302        .collect()
303}
304
305fn parse_float_between_operand(
306    field_name: &str,
307    operator: &str,
308    value: &Value,
309) -> Result<(f64, f64)> {
310    let values = value.as_array().ok_or_else(|| {
311        anyhow::anyhow!(
312            "Field '{}' operator '{}' expects [min,max]",
313            field_name,
314            operator
315        )
316    })?;
317    if values.len() != 2 {
318        bail!(
319            "Field '{}' operator '{}' expects exactly two values [min,max]",
320            field_name,
321            operator
322        );
323    }
324    let min = parse_float_operand(field_name, operator, &values[0])?;
325    let max = parse_float_operand(field_name, operator, &values[1])?;
326    if min > max {
327        bail!(
328            "Field '{}' operator '{}' expects min <= max",
329            field_name,
330            operator
331        );
332    }
333    Ok((min, max))
334}
335
336fn parse_enum_operand(field_name: &str, operator: &str, value: &Value) -> Result<String> {
337    value.as_str().map(|v| v.to_lowercase()).ok_or_else(|| {
338        anyhow::anyhow!(
339            "Field '{}' operator '{}' expects string value",
340            field_name,
341            operator
342        )
343    })
344}
345
346fn parse_enum_array_operand(
347    field_name: &str,
348    operator: &str,
349    value: &Value,
350) -> Result<Vec<String>> {
351    let values = value.as_array().ok_or_else(|| {
352        anyhow::anyhow!(
353            "Field '{}' operator '{}' expects array of strings",
354            field_name,
355            operator
356        )
357    })?;
358    if values.is_empty() {
359        bail!(
360            "Field '{}' operator '{}' must contain at least one value",
361            field_name,
362            operator
363        );
364    }
365    values
366        .iter()
367        .map(|v| parse_enum_operand(field_name, operator, v))
368        .collect()
369}
370
371fn validate_enum_value(field_name: &str, value: &str, allowed_values: &[String]) -> Result<()> {
372    if !allowed_values.iter().any(|allowed| allowed == value) {
373        bail!(
374            "Field '{}' has invalid enum value '{}'. Allowed: [{}]",
375            field_name,
376            value,
377            allowed_values.join(", ")
378        );
379    }
380    Ok(())
381}
382
383fn validate_int_constraint_against_range(
384    field_name: &str,
385    constraint: &NumericConstraint<i64>,
386    range: Option<&[i64; 2]>,
387) -> Result<()> {
388    let Some([min, max]) = range else {
389        return Ok(());
390    };
391
392    validate_constraint_against_range(constraint, |value| {
393        if value < *min || value > *max {
394            bail!(
395                "Field '{}' constraint value {} is outside allowed range [{}, {}]",
396                field_name,
397                value,
398                min,
399                max
400            );
401        }
402        Ok(())
403    })
404}
405
406fn validate_float_constraint_against_range(
407    field_name: &str,
408    constraint: &NumericConstraint<f64>,
409    range: Option<&[f64; 2]>,
410) -> Result<()> {
411    let Some([min, max]) = range else {
412        return Ok(());
413    };
414
415    // Float operands are validated as finite during parse_*_constraint.
416    validate_constraint_against_range(constraint, |value| {
417        if value < *min || value > *max {
418            bail!(
419                "Field '{}' constraint value {} is outside allowed range [{}, {}]",
420                field_name,
421                value,
422                min,
423                max
424            );
425        }
426        Ok(())
427    })
428}
429
430fn validate_constraint_against_range<T, F>(
431    constraint: &NumericConstraint<T>,
432    mut validate_operand: F,
433) -> Result<()>
434where
435    T: Copy,
436    F: FnMut(T) -> Result<()>,
437{
438    match constraint {
439        NumericConstraint::Eq(v)
440        | NumericConstraint::Gt(v)
441        | NumericConstraint::Gte(v)
442        | NumericConstraint::Lt(v)
443        | NumericConstraint::Lte(v) => validate_operand(*v),
444        NumericConstraint::In(values) => {
445            for value in values {
446                validate_operand(*value)?;
447            }
448            Ok(())
449        }
450        NumericConstraint::Between(from, to) => {
451            validate_operand(*from)?;
452            validate_operand(*to)
453        }
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use serde_json::json;
461
462    #[test]
463    fn int_constraint_parsing_and_matching_works() {
464        let constraint = parse_int_constraint("severity", &json!({"gte": 4}), Some(&[1, 7]))
465            .expect("constraint should parse");
466        assert!(constraint.matches(4));
467        assert!(constraint.matches(7));
468        assert!(!constraint.matches(3));
469    }
470
471    #[test]
472    fn int_constraint_rejects_unknown_operator() {
473        let result = parse_int_constraint("severity", &json!({"ge": 4}), Some(&[1, 7]));
474        assert!(result.is_err());
475    }
476
477    #[test]
478    fn int_constraint_rejects_out_of_range_operand() {
479        let result = parse_int_constraint("severity", &json!({"gte": 9}), Some(&[1, 7]));
480        assert!(result.is_err());
481    }
482
483    #[test]
484    fn int_between_requires_ordered_bounds() {
485        let result = parse_int_constraint("severity", &json!({"between": [6, 2]}), Some(&[1, 7]));
486        assert!(result.is_err());
487    }
488
489    #[test]
490    fn enum_constraint_in_works_case_insensitive() {
491        let constraint = parse_enum_constraint(
492            "alert_level",
493            &json!({"in": ["High", "Critical"]}),
494            &[
495                "low".to_string(),
496                "high".to_string(),
497                "critical".to_string(),
498            ],
499        )
500        .expect("enum constraint should parse");
501        assert!(constraint.matches("high"));
502        assert!(constraint.matches("CRITICAL"));
503        assert!(!constraint.matches("low"));
504    }
505
506    #[test]
507    fn enum_constraint_rejects_invalid_members() {
508        let result = parse_enum_constraint(
509            "alert_level",
510            &json!({"in": ["high", "urgent"]}),
511            &[
512                "low".to_string(),
513                "high".to_string(),
514                "critical".to_string(),
515            ],
516        );
517        assert!(result.is_err());
518    }
519
520    #[test]
521    fn float_constraint_parses_and_matches() {
522        let constraint =
523            parse_float_constraint("temperature", &json!({"between": [10.5, 20.5]}), None)
524                .expect("float constraint should parse");
525        assert!(constraint.matches(15.0));
526        assert!(!constraint.matches(22.0));
527    }
528
529    #[test]
530    fn float_eq_uses_exact_comparison() {
531        let constraint =
532            parse_float_constraint("temperature", &json!({"eq": 0.3}), None).expect("valid eq");
533        assert!(constraint.matches(0.3));
534        assert!(!constraint.matches(0.1 + 0.2));
535    }
536
537    #[test]
538    fn float_in_uses_exact_comparison() {
539        let constraint = parse_float_constraint("temperature", &json!({"in": [0.3, 1.5]}), None)
540            .expect("valid in");
541        assert!(constraint.matches(0.3));
542        assert!(!constraint.matches(0.1 + 0.2));
543    }
544
545    #[test]
546    fn float_between_is_inclusive() {
547        let constraint =
548            parse_float_constraint("temperature", &json!({"between": [10.5, 20.5]}), None)
549                .expect("float between should parse");
550        assert!(constraint.matches(10.5));
551        assert!(constraint.matches(20.5));
552        assert!(!constraint.matches(20.5000001));
553    }
554
555    #[test]
556    fn float_constraint_rejects_non_finite_notification_values() {
557        let constraint =
558            parse_float_constraint("temperature", &json!({"gt": 3.0}), None).expect("valid gt");
559        assert!(!constraint.matches(f64::NAN));
560        assert!(!constraint.matches(f64::INFINITY));
561        assert!(!constraint.matches(f64::NEG_INFINITY));
562    }
563
564    #[test]
565    fn float_constraint_rejects_out_of_range_operand() {
566        let result =
567            parse_float_constraint("temperature", &json!({"gt": 21.0}), Some(&[10.0, 20.0]));
568        assert!(result.is_err());
569    }
570}