haste_fhir_subscription_processor/
lib.rs1use 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 QueryFilter {
37 resource_type: ResourceType,
38 parameters: Vec<SubscriptionParameter>,
39 },
40 FHIRPathFilter {
43 expression: String,
44 },
45}
46
47#[allow(dead_code)]
50pub struct MemorySubscriptionFilter {
51 fp_engine: haste_fhirpath::FPEngine,
52 triggers: Vec<SubscriptionTrigger>,
53}
54
55impl MemorySubscriptionFilter {
56 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}