Skip to main content

helios_subscriptions/manager/
filters.rs

1//! Subscription filter parsing and validation.
2
3use crate::error::SubscriptionError;
4use crate::topics::FilterDefinition;
5
6/// A parsed subscription filter criterion.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct SubscriptionFilter {
9    /// The resource type (e.g., "Observation").
10    pub resource_type: Option<String>,
11
12    /// The filter parameter name (e.g., "code").
13    pub filter_parameter: String,
14
15    /// The comparator (e.g., "eq", "in").
16    pub comparator: String,
17
18    /// The filter value (e.g., "http://loinc.org|1234-5").
19    pub value: String,
20}
21
22/// Parses a filter criteria string in backport IG format.
23///
24/// The format is: `[ResourceType?]param=value` or `[ResourceType?]param=comparator:value`.
25///
26/// Examples:
27/// - `Observation?code=http://loinc.org|1234-5`
28/// - `Encounter?patient=Patient/123`
29/// - `code=http://loinc.org|1234-5` (no resource type prefix)
30pub fn parse_filter_string(filter: &str) -> Result<SubscriptionFilter, SubscriptionError> {
31    let (resource_type, param_str) = if let Some(idx) = filter.find('?') {
32        (Some(filter[..idx].to_string()), &filter[idx + 1..])
33    } else {
34        (None, filter)
35    };
36
37    let (param_name, value_str) =
38        param_str
39            .split_once('=')
40            .ok_or_else(|| SubscriptionError::InvalidFilter {
41                message: format!("filter missing '=' separator: {filter}"),
42            })?;
43
44    if param_name.is_empty() {
45        return Err(SubscriptionError::InvalidFilter {
46            message: "filter parameter name is empty".to_string(),
47        });
48    }
49
50    if value_str.is_empty() {
51        return Err(SubscriptionError::InvalidFilter {
52            message: format!("filter value is empty for parameter '{param_name}'"),
53        });
54    }
55
56    // Check for comparator prefix (e.g., "gt:5" or "ne:finished").
57    let (comparator, value) = if let Some((comp, val)) = value_str.split_once(':') {
58        // Only treat as comparator if it's a known FHIR comparator.
59        if is_known_comparator(comp) {
60            (comp.to_string(), val.to_string())
61        } else {
62            // Not a comparator — the colon is part of the value (e.g., system|code).
63            ("eq".to_string(), value_str.to_string())
64        }
65    } else {
66        ("eq".to_string(), value_str.to_string())
67    };
68
69    Ok(SubscriptionFilter {
70        resource_type,
71        filter_parameter: param_name.to_string(),
72        comparator,
73        value,
74    })
75}
76
77fn is_known_comparator(s: &str) -> bool {
78    matches!(
79        s,
80        "eq" | "ne" | "gt" | "lt" | "ge" | "le" | "sa" | "eb" | "in" | "not-in"
81    )
82}
83
84/// Validates a list of subscription filters against a topic's `canFilterBy` definitions.
85///
86/// Returns `Ok(())` if all filters are valid, or an error describing the first invalid filter.
87pub fn validate_filters(
88    filters: &[SubscriptionFilter],
89    can_filter_by: &[FilterDefinition],
90) -> Result<(), SubscriptionError> {
91    for filter in filters {
92        let matching_def = can_filter_by.iter().find(|def| {
93            // Match filter parameter name.
94            def.filter_parameter == filter.filter_parameter
95                // If the topic filter has a resource type constraint, it must match.
96                && def
97                    .resource_type
98                    .as_ref()
99                    .is_none_or(|rt| filter.resource_type.as_ref().is_none_or(|frt| frt == rt))
100        });
101
102        match matching_def {
103            None => {
104                return Err(SubscriptionError::InvalidFilter {
105                    message: format!(
106                        "filter parameter '{}' is not supported by the topic",
107                        filter.filter_parameter
108                    ),
109                });
110            }
111            Some(def) => {
112                // If the topic defines allowed comparators, check the filter uses one.
113                if !def.comparators.is_empty() && !def.comparators.contains(&filter.comparator) {
114                    return Err(SubscriptionError::InvalidFilter {
115                        message: format!(
116                            "comparator '{}' not supported for filter parameter '{}' (supported: {:?})",
117                            filter.comparator, filter.filter_parameter, def.comparators
118                        ),
119                    });
120                }
121            }
122        }
123    }
124
125    Ok(())
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_parse_filter_with_resource_type() {
134        let filter = parse_filter_string("Observation?code=http://loinc.org|1234-5").unwrap();
135        assert_eq!(filter.resource_type.as_deref(), Some("Observation"));
136        assert_eq!(filter.filter_parameter, "code");
137        assert_eq!(filter.comparator, "eq");
138        assert_eq!(filter.value, "http://loinc.org|1234-5");
139    }
140
141    #[test]
142    fn test_parse_filter_without_resource_type() {
143        let filter = parse_filter_string("code=http://loinc.org|1234-5").unwrap();
144        assert!(filter.resource_type.is_none());
145        assert_eq!(filter.filter_parameter, "code");
146        assert_eq!(filter.value, "http://loinc.org|1234-5");
147    }
148
149    #[test]
150    fn test_parse_filter_with_comparator() {
151        let filter = parse_filter_string("Observation?value-quantity=gt:5").unwrap();
152        assert_eq!(filter.comparator, "gt");
153        assert_eq!(filter.value, "5");
154    }
155
156    #[test]
157    fn test_parse_filter_colon_in_value_not_comparator() {
158        // "http://loinc.org|1234-5" contains a colon but "http" is not a comparator.
159        let filter = parse_filter_string("code=http://loinc.org|1234-5").unwrap();
160        assert_eq!(filter.comparator, "eq");
161        assert_eq!(filter.value, "http://loinc.org|1234-5");
162    }
163
164    #[test]
165    fn test_parse_filter_reference() {
166        let filter = parse_filter_string("Encounter?patient=Patient/123").unwrap();
167        assert_eq!(filter.filter_parameter, "patient");
168        assert_eq!(filter.value, "Patient/123");
169    }
170
171    #[test]
172    fn test_parse_filter_missing_equals() {
173        let result = parse_filter_string("Observation?code");
174        assert!(result.is_err());
175    }
176
177    #[test]
178    fn test_parse_filter_empty_param() {
179        let result = parse_filter_string("=value");
180        assert!(result.is_err());
181    }
182
183    #[test]
184    fn test_parse_filter_empty_value() {
185        let result = parse_filter_string("code=");
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn test_validate_filters_success() {
191        let filters = vec![SubscriptionFilter {
192            resource_type: Some("Observation".to_string()),
193            filter_parameter: "code".to_string(),
194            comparator: "eq".to_string(),
195            value: "http://loinc.org|1234-5".to_string(),
196        }];
197
198        let can_filter_by = vec![FilterDefinition {
199            resource_type: Some("Observation".to_string()),
200            filter_parameter: "code".to_string(),
201            comparators: vec!["eq".to_string(), "in".to_string()],
202            modifiers: vec![],
203        }];
204
205        assert!(validate_filters(&filters, &can_filter_by).is_ok());
206    }
207
208    #[test]
209    fn test_validate_filters_unknown_parameter() {
210        let filters = vec![SubscriptionFilter {
211            resource_type: None,
212            filter_parameter: "unknown-param".to_string(),
213            comparator: "eq".to_string(),
214            value: "value".to_string(),
215        }];
216
217        let can_filter_by = vec![FilterDefinition {
218            resource_type: Some("Observation".to_string()),
219            filter_parameter: "code".to_string(),
220            comparators: vec!["eq".to_string()],
221            modifiers: vec![],
222        }];
223
224        let result = validate_filters(&filters, &can_filter_by);
225        assert!(result.is_err());
226    }
227
228    #[test]
229    fn test_validate_filters_unsupported_comparator() {
230        let filters = vec![SubscriptionFilter {
231            resource_type: None,
232            filter_parameter: "code".to_string(),
233            comparator: "gt".to_string(),
234            value: "value".to_string(),
235        }];
236
237        let can_filter_by = vec![FilterDefinition {
238            resource_type: Some("Observation".to_string()),
239            filter_parameter: "code".to_string(),
240            comparators: vec!["eq".to_string()],
241            modifiers: vec![],
242        }];
243
244        let result = validate_filters(&filters, &can_filter_by);
245        assert!(result.is_err());
246    }
247
248    #[test]
249    fn test_validate_filters_empty_comparators_allows_any() {
250        let filters = vec![SubscriptionFilter {
251            resource_type: None,
252            filter_parameter: "code".to_string(),
253            comparator: "gt".to_string(),
254            value: "value".to_string(),
255        }];
256
257        let can_filter_by = vec![FilterDefinition {
258            resource_type: None,
259            filter_parameter: "code".to_string(),
260            comparators: vec![], // No restriction.
261            modifiers: vec![],
262        }];
263
264        assert!(validate_filters(&filters, &can_filter_by).is_ok());
265    }
266
267    #[test]
268    fn test_validate_empty_filters_always_valid() {
269        assert!(validate_filters(&[], &[]).is_ok());
270    }
271}