Skip to main content

haste_fhir_subscription_processor/
lib.rs

1use std::sync::Arc;
2
3use haste_fhir_client::url::{ParsedParameter, ParsedParameters};
4use haste_fhir_model::r4::generated::{
5    resources::{Resource, ResourceType, Subscription},
6    terminology::IssueType,
7};
8use haste_fhir_operation_error::{OperationOutcomeError, derive::OperationOutcomeError};
9use haste_fhir_search::{
10    ResolvedParameter, SearchParameterResolve,
11    indexing_conversion::{self, InsertableIndex},
12};
13use haste_jwt::{ProjectId, TenantId};
14
15pub mod traits;
16
17#[derive(OperationOutcomeError, Debug)]
18pub enum SubscriptionFilterError {
19    #[fatal(
20        code = "exception",
21        diagnostic = "Failed to evaluate fhirpath expression."
22    )]
23    FHIRPathError(#[from] haste_fhirpath::FHIRPathError),
24}
25
26#[allow(dead_code)]
27pub struct SubscriptionParameter {
28    parameter: ResolvedParameter,
29    fp_extract_expression: String,
30    value: Vec<String>,
31    modifier: Option<String>,
32}
33
34pub enum SubscriptionTrigger {
35    // Based around simple Subscription.criteria.
36    QueryFilter {
37        resource_type: ResourceType,
38        parameters: Vec<SubscriptionParameter>,
39    },
40    // This could come from a subscriptiontopic which
41    // allows arbitrary FHIRPath expressions, or from more complex criteria in the future.
42    FHIRPathFilter {
43        expression: String,
44    },
45}
46
47/// In memory representation of a subscription filter.
48/// This is what we will use to evaluate whether a given subscription matches an incoming event.
49#[allow(dead_code)]
50pub struct MemorySubscriptionFilter {
51    fp_engine: haste_fhirpath::FPEngine,
52    triggers: Vec<SubscriptionTrigger>,
53}
54
55impl MemorySubscriptionFilter {
56    /// Creates a [`MemorySubscriptionFilter`] from a subscription.
57    ///
58    /// Resolves and validates the search parameters specified in the subscription
59    /// criteria and constructs the corresponding FHIRPath-based filter.
60    ///
61    /// # Errors
62    ///
63    /// Returns an [`OperationOutcomeError`] if:
64    /// - the subscription criteria has an invalid format;
65    /// - the criteria contains an invalid resource type;
66    /// - a search parameter cannot be resolved for the resource type;
67    /// - a chained search parameter is used;
68    /// - a resolved search parameter does not have a `FHIRPath` expression;
69    /// - an unsupported result parameter is present in the criteria; or
70    /// - the subscription does not contain criteria.
71    ///
72    /// # Arguments
73    ///
74    /// * `tenant_id` - The tenant owning the subscription.
75    /// * `project_id` - The project owning the subscription.
76    /// * `resolver` - Resolver used to look up search parameters.
77    /// * `value` - The subscription from which to construct the filter.
78    pub async fn new<Resolver: SearchParameterResolve>(
79        tenant_id: &TenantId,
80        project_id: &ProjectId,
81        resolver: Arc<Resolver>,
82        value: Subscription,
83    ) -> Result<Self, OperationOutcomeError> {
84        if let Some(criteria) = value.criteria.value {
85            let criteria_pieces = criteria.split('?').collect::<Vec<_>>();
86            let [path, parameters] = criteria_pieces.as_slice() else {
87                return Err(OperationOutcomeError::error(
88                    IssueType::exception(),
89                    "Invalid subscription criteria format".to_string(),
90                ));
91            };
92
93            let resource_type = ResourceType::try_from(*path).map_err(|_| {
94                OperationOutcomeError::error(
95                    IssueType::exception(),
96                    "Invalid resource type".to_string(),
97                )
98            })?;
99
100            let parsed_parameters = ParsedParameters::try_from(*parameters)?;
101            let mut subscription_parsed_parameters = vec![];
102
103            for parameter in parsed_parameters.owned_parameters() {
104                match parameter {
105                    ParsedParameter::Resource(resource_param) => {
106                        let Some(parameter) = resolver
107                            .by_name(
108                                tenant_id,
109                                project_id,
110                                Some(&resource_type),
111                                &resource_param.name,
112                            )
113                            .await?
114                        else {
115                            return Err(OperationOutcomeError::error(
116                                IssueType::exception(),
117                                format!(
118                                    "Invalid search parameter in subscription criteria: {}",
119                                    resource_param.name
120                                ),
121                            ));
122                        };
123
124                        if resource_param.chains.is_some() {
125                            return Err(OperationOutcomeError::error(
126                                IssueType::exception(),
127                                format!(
128                                    "Chained parameters are not supported in subscription criteria: {}",
129                                    resource_param.name
130                                ),
131                            ));
132                        }
133
134                        let Some(fp_expression) = parameter
135                            .search_parameter
136                            .expression
137                            .as_ref()
138                            .and_then(|expr| expr.value.as_ref())
139                        else {
140                            return Err(OperationOutcomeError::error(
141                                IssueType::exception(),
142                                format!(
143                                    "Search parameter does not have an expression: {}",
144                                    resource_param.name
145                                ),
146                            ));
147                        };
148
149                        subscription_parsed_parameters.push(SubscriptionParameter {
150                            parameter: parameter.clone(),
151                            fp_extract_expression: fp_expression.clone(),
152                            value: resource_param.value,
153                            modifier: resource_param.modifier,
154                        });
155                    }
156                    ParsedParameter::Result(result_param) => {
157                        return Err(OperationOutcomeError::error(
158                            IssueType::exception(),
159                            format!(
160                                "Unsupported parameter in subscription criteria: {}",
161                                result_param.name
162                            ),
163                        ));
164                    }
165                }
166            }
167
168            Ok(MemorySubscriptionFilter {
169                fp_engine: haste_fhirpath::FPEngine::new(),
170                triggers: vec![SubscriptionTrigger::QueryFilter {
171                    resource_type,
172                    parameters: subscription_parsed_parameters,
173                }],
174            })
175        } else {
176            Err(OperationOutcomeError::error(
177                IssueType::exception(),
178                "SubscriptionFilter conversion not implemented".to_string(),
179            ))
180        }
181    }
182}
183
184async fn fits_subscription_parameter(
185    fp_engine: &haste_fhirpath::FPEngine,
186    subscription_parameter: &SubscriptionParameter,
187    resource: &Resource,
188) -> Result<bool, OperationOutcomeError> {
189    let result = fp_engine
190        .evaluate(
191            &subscription_parameter.fp_extract_expression,
192            vec![resource],
193        )
194        .await
195        .map_err(SubscriptionFilterError::from)?;
196
197    let conversions = indexing_conversion::to_insertable_index(
198        &subscription_parameter.parameter,
199        &result.iter().collect::<Vec<_>>(),
200    )?;
201
202    match conversions {
203        InsertableIndex::String(resource_values) => {
204            Ok(resource_values.iter().any(|resource_value| {
205                subscription_parameter
206                    .value
207                    .iter()
208                    .any(|v| resource_value.to_lowercase().starts_with(&v.to_lowercase()))
209            }))
210        }
211        InsertableIndex::Number(_) => Err(OperationOutcomeError::error(
212            IssueType::exception(),
213            "Number search parameters are not supported in subscription criteria".to_string(),
214        ))?,
215        InsertableIndex::URI(_) => Err(OperationOutcomeError::error(
216            IssueType::exception(),
217            "URI search parameters are not supported in subscription criteria".to_string(),
218        ))?,
219        InsertableIndex::Token(_) => Err(OperationOutcomeError::error(
220            IssueType::exception(),
221            "Token search parameters are not supported in subscription criteria".to_string(),
222        ))?,
223        InsertableIndex::Date(_) => Err(OperationOutcomeError::error(
224            IssueType::exception(),
225            "Date search parameters are not supported in subscription criteria".to_string(),
226        ))?,
227
228        InsertableIndex::Reference(_) => Err(OperationOutcomeError::error(
229            IssueType::exception(),
230            "Reference search parameters are not supported in subscription criteria".to_string(),
231        ))?,
232        InsertableIndex::Quantity(_) => Err(OperationOutcomeError::error(
233            IssueType::exception(),
234            "Quantity search parameters are not supported in subscription criteria".to_string(),
235        ))?,
236        InsertableIndex::DynamicParameters(_) => Err(OperationOutcomeError::error(
237            IssueType::exception(),
238            "Dynamic search parameters are not supported in subscription criteria".to_string(),
239        ))?,
240
241        InsertableIndex::Composite(_) => Err(OperationOutcomeError::error(
242            IssueType::exception(),
243            "Composite search parameters are not supported in subscription criteria".to_string(),
244        ))?,
245        InsertableIndex::Special(_) => Err(OperationOutcomeError::error(
246            IssueType::exception(),
247            "Special search parameters are not supported in subscription criteria".to_string(),
248        ))?,
249        InsertableIndex::Meta(_) => Err(OperationOutcomeError::error(
250            IssueType::exception(),
251            "Meta search parameters are not supported in subscription criteria".to_string(),
252        ))?,
253    }
254}
255
256impl traits::SubscriptionFilter for MemorySubscriptionFilter {
257    async fn matches(&self, resource: &Resource) -> Result<bool, OperationOutcomeError> {
258        let resource_resource_type = resource.resource_type();
259
260        if let Some(trigger) = self.triggers.first() {
261            match trigger {
262                SubscriptionTrigger::QueryFilter {
263                    resource_type,
264                    parameters,
265                } => {
266                    if *resource_type != resource_resource_type {
267                        return Ok(false);
268                    }
269
270                    for sub_parameter in parameters {
271                        let fits_criteria =
272                            fits_subscription_parameter(&self.fp_engine, sub_parameter, resource)
273                                .await?;
274                        if !fits_criteria {
275                            return Ok(false);
276                        }
277                    }
278
279                    return Ok(true);
280                }
281                SubscriptionTrigger::FHIRPathFilter { .. } => {
282                    Err(OperationOutcomeError::error(
283                        IssueType::exception(),
284                        "FHIRPathFilter triggers are not yet supported".to_string(),
285                    ))?;
286                }
287            }
288        }
289
290        Ok(false)
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use haste_fhir_model::r4::generated::{
297        resources::Patient,
298        types::{FHIRString, HumanName},
299    };
300    use haste_fhir_search::memory::R4_SEARCH_PARAMETERS_INDEX;
301
302    use crate::traits::SubscriptionFilter;
303
304    use super::*;
305
306    #[tokio::test]
307    async fn quick_test_derive() {
308        let subscription = Subscription {
309            criteria: Box::new(FHIRString {
310                value: Some("Patient?name=Smith".to_string()),
311                ..Default::default()
312            }),
313            ..Default::default()
314        };
315
316        let resolver = R4_SEARCH_PARAMETERS_INDEX.clone();
317
318        let sub_filter = MemorySubscriptionFilter::new(
319            &TenantId::System,
320            &ProjectId::System,
321            resolver,
322            subscription,
323        )
324        .await
325        .unwrap();
326
327        assert_eq!(sub_filter.triggers.len(), 1);
328
329        match &sub_filter.triggers[0] {
330            SubscriptionTrigger::QueryFilter {
331                resource_type,
332                parameters,
333            } => {
334                assert_eq!(resource_type, &ResourceType::Patient);
335                assert_eq!(parameters[0].fp_extract_expression, "Patient.name");
336                assert_eq!(parameters[0].value, vec!["Smith".to_string()]);
337            }
338            SubscriptionTrigger::FHIRPathFilter { .. } => panic!("Expected QueryFilter trigger"),
339        }
340    }
341
342    #[tokio::test]
343    async fn modifier_check() {
344        let subscription = Subscription {
345            criteria: Box::new(FHIRString {
346                value: Some("Observation?category:missing=true".to_string()),
347                ..Default::default()
348            }),
349            ..Default::default()
350        };
351
352        let resolver = R4_SEARCH_PARAMETERS_INDEX.clone();
353        let sub_filter = MemorySubscriptionFilter::new(
354            &TenantId::System,
355            &ProjectId::System,
356            resolver,
357            subscription,
358        )
359        .await
360        .unwrap();
361
362        assert_eq!(sub_filter.triggers.len(), 1);
363
364        match &sub_filter.triggers[0] {
365            SubscriptionTrigger::QueryFilter {
366                resource_type,
367                parameters,
368            } => {
369                assert_eq!(resource_type, &ResourceType::Observation);
370                assert_eq!(parameters[0].fp_extract_expression, "Observation.category");
371                assert_eq!(parameters[0].value, vec!["true".to_string()]);
372                assert_eq!(parameters[0].modifier, Some("missing".to_string()));
373            }
374            SubscriptionTrigger::FHIRPathFilter { .. } => panic!("Expected QueryFilter trigger"),
375        }
376    }
377
378    #[tokio::test]
379    async fn test_run_fhirpath() {
380        let resolver = R4_SEARCH_PARAMETERS_INDEX.clone();
381        let sub_filter = MemorySubscriptionFilter::new(
382            &TenantId::System,
383            &ProjectId::System,
384            resolver.clone(),
385            Subscription {
386                criteria: Box::new(FHIRString {
387                    value: Some("Patient?name=Smith".to_string()),
388                    ..Default::default()
389                }),
390                ..Default::default()
391            },
392        )
393        .await
394        .unwrap();
395        let patient = Resource::Patient(Patient {
396            name: Some(vec![HumanName {
397                family: Some(Box::new(FHIRString {
398                    value: Some("Smith".to_string()),
399                    ..Default::default()
400                })),
401                ..Default::default()
402            }]),
403            ..Default::default()
404        });
405
406        assert!(sub_filter.matches(&patient).await.unwrap());
407
408        let sub_filter_partial = MemorySubscriptionFilter::new(
409            &TenantId::System,
410            &ProjectId::System,
411            resolver.clone(),
412            Subscription {
413                criteria: Box::new(FHIRString {
414                    value: Some("Patient?name=Sm".to_string()),
415                    ..Default::default()
416                }),
417                ..Default::default()
418            },
419        )
420        .await
421        .unwrap();
422
423        assert!(sub_filter_partial.matches(&patient).await.unwrap());
424
425        let sub_filter_casing = MemorySubscriptionFilter::new(
426            &TenantId::System,
427            &ProjectId::System,
428            resolver.clone(),
429            Subscription {
430                criteria: Box::new(FHIRString {
431                    value: Some("Patient?name=sm".to_string()),
432                    ..Default::default()
433                }),
434                ..Default::default()
435            },
436        )
437        .await
438        .unwrap();
439
440        assert!(sub_filter_casing.matches(&patient).await.unwrap());
441
442        let patient = Resource::Patient(Patient {
443            name: Some(vec![HumanName {
444                family: Some(Box::new(FHIRString {
445                    value: Some("NotSmith".to_string()),
446                    ..Default::default()
447                })),
448                ..Default::default()
449            }]),
450            ..Default::default()
451        });
452
453        assert!(!sub_filter.matches(&patient).await.unwrap());
454    }
455}