Skip to main content

haste_fhirpath/
fhir_functions.rs

1//! Functions FHIR adds to `FHIRPath`: <https://hl7.org/fhir/R4/fhirpath.html#functions>.
2
3use crate::{
4    Cardinality, Config, Context, FHIRPathError, ResolvedValue, boolean_result,
5    evaluate_expression, parser::FunctionInvocation, singleton, validate_arguments,
6};
7use haste_fhir_model::r4::{
8    conversion::{PRIMITIVE_TYPES, STRING_TYPES, downcast_string},
9    generated::types::Extension,
10};
11use haste_reflect::MetaValue;
12use std::{future::Future, pin::Pin, sync::Arc};
13
14pub type ServiceFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
15
16/// A system and code pair. A bare code has no system.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Code {
19    pub system: Option<String>,
20    pub code: String,
21}
22
23/// Server-side lookups behind `memberOf`, `subsumes`, `subsumedBy`,
24/// `conformsTo` and `resolve`. A `None` answer means the lookup is unknown.
25pub trait FHIRServices: Send + Sync {
26    fn member_of<'a>(
27        &'a self,
28        value_set: &'a str,
29        code: &'a Code,
30    ) -> ServiceFuture<'a, Option<bool>>;
31
32    /// Whether `left` subsumes `right`.
33    fn subsumes<'a>(&'a self, left: &'a Code, right: &'a Code) -> ServiceFuture<'a, Option<bool>>;
34
35    fn conforms_to<'a>(
36        &'a self,
37        resource: &'a dyn MetaValue,
38        profile: &'a str,
39    ) -> ServiceFuture<'a, Option<bool>>;
40
41    fn resolve<'a>(&'a self, reference: &'a str) -> ServiceFuture<'a, Option<ResolvedValue>>;
42}
43
44fn services(
45    name: &str,
46    config: Option<&Arc<Config<'_>>>,
47) -> Result<Arc<dyn FHIRServices>, FHIRPathError> {
48    config
49        .and_then(|c| c.services.clone())
50        .ok_or_else(|| FHIRPathError::InternalError(format!("{name} requires services in config")))
51}
52
53/// Evaluates a single string argument, `None` when it is empty.
54async fn string_argument<'a>(
55    name: &str,
56    function: &FunctionInvocation,
57    context: &Context<'a>,
58    config: Option<Arc<Config<'a>>>,
59) -> Result<Option<String>, FHIRPathError> {
60    validate_arguments(&function.arguments, &Cardinality::One)?;
61    let argument = evaluate_expression(&function.arguments[0], context.clone(), config).await?;
62    singleton(name, &argument)?
63        .map(|v| downcast_string(v).map_err(Into::into))
64        .transpose()
65}
66
67pub(crate) async fn evaluate_extension<'a>(
68    function: &FunctionInvocation,
69    context: Context<'a>,
70    config: Option<Arc<Config<'a>>>,
71) -> Result<Context<'a>, FHIRPathError> {
72    let Some(url) = string_argument("extension", function, &context, config).await? else {
73        return Ok(context.new_context_from(vec![]));
74    };
75
76    let matches = context
77        .values
78        .iter()
79        .filter_map(|value| value.get_field("extension"))
80        .flat_map(MetaValue::flatten)
81        .filter(|ext| {
82            ext.as_any()
83                .downcast_ref::<Extension>()
84                .is_some_and(|ext| ext.url == url)
85        })
86        .collect();
87
88    Ok(context.new_context_from(matches))
89}
90
91/// The underlying system value of a FHIR primitive, `None` when it has none.
92fn primitive_value(value: &dyn MetaValue) -> Option<&dyn MetaValue> {
93    let fhir_type = value.fhir_type();
94    if !PRIMITIVE_TYPES.contains(fhir_type) {
95        return None;
96    }
97    if fhir_type.starts_with("http://hl7.org/fhirpath/") {
98        return Some(value);
99    }
100    value.get_field("value")
101}
102
103pub(crate) fn evaluate_has_value<'a>(
104    function: &FunctionInvocation,
105    context: &Context<'a>,
106) -> Result<Context<'a>, FHIRPathError> {
107    validate_arguments(&function.arguments, &Cardinality::Zero)?;
108
109    let has_value = match context.values.as_slice() {
110        [value] => primitive_value(*value).is_some(),
111        _ => false,
112    };
113    Ok(boolean_result(context, has_value))
114}
115
116pub(crate) fn evaluate_get_value<'a>(
117    function: &FunctionInvocation,
118    context: &Context<'a>,
119) -> Result<Context<'a>, FHIRPathError> {
120    validate_arguments(&function.arguments, &Cardinality::Zero)?;
121
122    let value = singleton("getValue", context)?.and_then(primitive_value);
123    Ok(context.new_context_from(value.into_iter().collect()))
124}
125
126/// Elements allowed in narrative xhtml: <https://hl7.org/fhir/R4/narrative.html#xhtml>.
127const ALLOWED_ELEMENTS: &[&str] = &[
128    "a",
129    "abbr",
130    "acronym",
131    "b",
132    "big",
133    "blockquote",
134    "br",
135    "caption",
136    "cite",
137    "code",
138    "col",
139    "colgroup",
140    "dd",
141    "dfn",
142    "div",
143    "dl",
144    "dt",
145    "em",
146    "h1",
147    "h2",
148    "h3",
149    "h4",
150    "h5",
151    "h6",
152    "hr",
153    "i",
154    "img",
155    "kbd",
156    "li",
157    "ol",
158    "p",
159    "pre",
160    "q",
161    "samp",
162    "small",
163    "span",
164    "strong",
165    "sub",
166    "sup",
167    "table",
168    "tbody",
169    "td",
170    "tfoot",
171    "th",
172    "thead",
173    "tr",
174    "tt",
175    "ul",
176    "var",
177];
178
179/// Whether every tag is an allowed element and no attribute is an event
180/// handler or a `javascript:` url.
181fn html_is_safe(html: &str) -> bool {
182    let mut rest = html;
183    while let Some(start) = rest.find('<') {
184        rest = &rest[start + 1..];
185        let Some(end) = rest.find('>') else {
186            return false;
187        };
188        let tag = &rest[..end];
189        rest = &rest[end + 1..];
190
191        // Comments and processing instructions carry no markup of their own.
192        if tag.starts_with("!--") || tag.starts_with('?') {
193            continue;
194        }
195
196        let tag = tag.trim_start_matches('/').trim_end_matches('/');
197        let mut pieces = tag.split_ascii_whitespace();
198        let name = pieces.next().unwrap_or_default();
199        if !ALLOWED_ELEMENTS
200            .iter()
201            .any(|e| e.eq_ignore_ascii_case(name))
202        {
203            return false;
204        }
205
206        let attributes = tag[name.len()..].to_ascii_lowercase();
207        if attributes.contains("javascript:")
208            || attributes
209                .split_ascii_whitespace()
210                .any(|attribute| attribute.starts_with("on"))
211        {
212            return false;
213        }
214    }
215    true
216}
217
218pub(crate) fn evaluate_html_checks<'a>(
219    function: &FunctionInvocation,
220    context: &Context<'a>,
221) -> Result<Context<'a>, FHIRPathError> {
222    validate_arguments(&function.arguments, &Cardinality::Zero)?;
223
224    let Some(value) = singleton("htmlChecks", context)? else {
225        return Ok(context.new_context_from(vec![]));
226    };
227    let safe = value.fhir_type() == "xhtml" && html_is_safe(&downcast_string(value)?);
228    Ok(boolean_result(context, safe))
229}
230
231/// Every system and code held by a code, Coding or `CodeableConcept`.
232fn codes_of(value: &dyn MetaValue) -> Vec<Code> {
233    let text = |field: &str| value.get_field(field).and_then(|v| downcast_string(v).ok());
234
235    if STRING_TYPES.contains(value.fhir_type()) {
236        return downcast_string(value)
237            .map(|code| vec![Code { system: None, code }])
238            .unwrap_or_default();
239    }
240    if let Some(codings) = value.get_field("coding") {
241        return codings.flatten().into_iter().flat_map(codes_of).collect();
242    }
243    text("code")
244        .map(|code| Code {
245            system: text("system"),
246            code,
247        })
248        .into_iter()
249        .collect()
250}
251
252pub(crate) async fn evaluate_member_of<'a>(
253    function: &FunctionInvocation,
254    context: Context<'a>,
255    config: Option<Arc<Config<'a>>>,
256) -> Result<Context<'a>, FHIRPathError> {
257    let services = services("memberOf", config.as_ref())?;
258    let Some(value_set) = string_argument("memberOf", function, &context, config).await? else {
259        return Ok(context.new_context_from(vec![]));
260    };
261    let Some(value) = singleton("memberOf", &context)? else {
262        return Ok(context.new_context_from(vec![]));
263    };
264
265    let mut known = false;
266    for code in codes_of(value) {
267        match services.member_of(&value_set, &code).await {
268            Some(true) => return Ok(boolean_result(&context, true)),
269            Some(false) => known = true,
270            None => {}
271        }
272    }
273    Ok(if known {
274        boolean_result(&context, false)
275    } else {
276        context.new_context_from(vec![])
277    })
278}
279
280pub(crate) async fn evaluate_subsumes<'a>(
281    function: &FunctionInvocation,
282    context: Context<'a>,
283    config: Option<Arc<Config<'a>>>,
284) -> Result<Context<'a>, FHIRPathError> {
285    let name = function.name.0.as_str();
286    let services = services(name, config.as_ref())?;
287    validate_arguments(&function.arguments, &Cardinality::One)?;
288
289    let argument = evaluate_expression(&function.arguments[0], context.clone(), config).await?;
290    let (Some(input), Some(other)) = (singleton(name, &context)?, singleton(name, &argument)?)
291    else {
292        return Ok(context.new_context_from(vec![]));
293    };
294    let (Some(input), Some(other)) = (
295        codes_of(input).into_iter().next(),
296        codes_of(other).into_iter().next(),
297    ) else {
298        return Ok(context.new_context_from(vec![]));
299    };
300
301    let result = if name == "subsumes" {
302        services.subsumes(&input, &other).await
303    } else {
304        services.subsumes(&other, &input).await
305    };
306    Ok(result.map_or_else(
307        || context.new_context_from(vec![]),
308        |r| boolean_result(&context, r),
309    ))
310}
311
312pub(crate) async fn evaluate_conforms_to<'a>(
313    function: &FunctionInvocation,
314    context: Context<'a>,
315    config: Option<Arc<Config<'a>>>,
316) -> Result<Context<'a>, FHIRPathError> {
317    let services = services("conformsTo", config.as_ref())?;
318    let Some(profile) = string_argument("conformsTo", function, &context, config).await? else {
319        return Ok(context.new_context_from(vec![]));
320    };
321    let Some(resource) = singleton("conformsTo", &context)? else {
322        return Ok(context.new_context_from(vec![]));
323    };
324
325    let result = services.conforms_to(resource, &profile).await;
326    Ok(result.map_or_else(
327        || context.new_context_from(vec![]),
328        |r| boolean_result(&context, r),
329    ))
330}
331
332/// Resolves references through the services when configured, otherwise the
333/// input passes through unchanged for search indexing.
334pub(crate) async fn evaluate_resolve<'a>(
335    context: Context<'a>,
336    config: Option<Arc<Config<'a>>>,
337) -> Result<Context<'a>, FHIRPathError> {
338    let Some(services) = config.as_ref().and_then(|c| c.services.clone()) else {
339        return Ok(context);
340    };
341
342    let mut resolved = Vec::with_capacity(context.values.len());
343    for value in &context.values {
344        let reference = if STRING_TYPES.contains(value.fhir_type()) {
345            downcast_string(*value).ok()
346        } else {
347            value
348                .get_field("reference")
349                .and_then(|r| downcast_string(r).ok())
350        };
351        if let Some(reference) = reference
352            && let Some(found) = services.resolve(&reference).await
353        {
354            resolved.push(context.allocate(found));
355        }
356    }
357    Ok(context.new_context_from(resolved))
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::FPEngine;
364    use haste_fhir_model::r4::conversion::downcast_bool;
365    use haste_fhir_model::r4::generated::{
366        resources::Patient,
367        types::{Coding, FHIRBoolean, FHIRCode, FHIRString},
368    };
369
370    struct Mock;
371
372    impl FHIRServices for Mock {
373        fn member_of<'a>(&'a self, _: &'a str, code: &'a Code) -> ServiceFuture<'a, Option<bool>> {
374            Box::pin(async move { Some(code.code == "in") })
375        }
376
377        fn subsumes<'a>(&'a self, l: &'a Code, r: &'a Code) -> ServiceFuture<'a, Option<bool>> {
378            Box::pin(async move { Some(l.code == "parent" && r.code == "child") })
379        }
380
381        fn conforms_to<'a>(
382            &'a self,
383            _: &'a dyn MetaValue,
384            profile: &'a str,
385        ) -> ServiceFuture<'a, Option<bool>> {
386            Box::pin(async move { Some(profile == "http://example.org/ok") })
387        }
388
389        fn resolve<'a>(&'a self, reference: &'a str) -> ServiceFuture<'a, Option<ResolvedValue>> {
390            Box::pin(async move {
391                (reference == "Patient/1").then(|| ResolvedValue::Box(Box::new(Patient::default())))
392            })
393        }
394    }
395
396    fn patient() -> Patient {
397        Patient {
398            active: Some(Box::new(FHIRBoolean {
399                value: Some(true),
400                extension: Some(vec![Extension {
401                    url: "http://example.org/a".to_string(),
402                    ..Default::default()
403                }]),
404                ..Default::default()
405            })),
406            ..Default::default()
407        }
408    }
409
410    async fn run(expression: &str, services: bool) -> Vec<String> {
411        let patient = patient();
412        let engine = FPEngine::new();
413        let result = if services {
414            let config = Arc::new(Config::builder().with_services(Arc::new(Mock)));
415            engine
416                .evaluate_with_config(expression, vec![&patient], config)
417                .await
418        } else {
419            engine.evaluate(expression, vec![&patient]).await
420        }
421        .unwrap_or_else(|e| panic!("{expression} failed: {e}"));
422        result
423            .iter()
424            .map(|v| match downcast_bool(v) {
425                Ok(b) => b.to_string(),
426                Err(_) => v.fhir_type().to_string(),
427            })
428            .collect()
429    }
430
431    #[tokio::test]
432    async fn extension_filters_by_url() {
433        let found = run("Patient.active.extension('http://example.org/a')", false).await;
434        assert_eq!(found, ["Extension"]);
435        assert!(
436            run("Patient.active.extension('http://example.org/b')", false)
437                .await
438                .is_empty()
439        );
440    }
441
442    #[tokio::test]
443    async fn has_value_and_get_value() {
444        assert_eq!(run("Patient.active.hasValue()", false).await, ["true"]);
445        assert_eq!(run("Patient.birthDate.hasValue()", false).await, ["false"]);
446        assert_eq!(run("Patient.active.getValue()", false).await, ["true"]);
447        assert!(run("Patient.birthDate.getValue()", false).await.is_empty());
448    }
449
450    #[test]
451    fn html_checks() {
452        assert!(html_is_safe(
453            "<div xmlns=\"x\"><p class=\"a\">hi<br/></p></div>"
454        ));
455        assert!(!html_is_safe("<div><script>x</script></div>"));
456        assert!(!html_is_safe("<div><p onclick=\"x()\">hi</p></div>"));
457        assert!(!html_is_safe(
458            "<div><a href=\"javascript:x()\">hi</a></div>"
459        ));
460    }
461
462    #[test]
463    fn codes() {
464        let coding = Coding {
465            system: Some(Box::new("http://s".to_string().into())),
466            code: Some(Box::new(FHIRCode {
467                value: Some("c".to_string()),
468                ..Default::default()
469            })),
470            ..Default::default()
471        };
472        assert_eq!(
473            codes_of(&coding),
474            [Code {
475                system: Some("http://s".to_string()),
476                code: "c".to_string()
477            }]
478        );
479        let bare = FHIRString {
480            value: Some("x".to_string()),
481            ..Default::default()
482        };
483        assert_eq!(codes_of(&bare)[0].system, None);
484    }
485
486    #[tokio::test]
487    async fn services_backed_functions() {
488        assert_eq!(run("'in'.memberOf('http://vs')", true).await, ["true"]);
489        assert_eq!(run("'out'.memberOf('http://vs')", true).await, ["false"]);
490        assert_eq!(run("'parent'.subsumes('child')", true).await, ["true"]);
491        assert_eq!(run("'child'.subsumedBy('parent')", true).await, ["true"]);
492        assert_eq!(
493            run("Patient.conformsTo('http://example.org/ok')", true).await,
494            ["true"]
495        );
496        assert_eq!(run("'Patient/1'.resolve()", true).await, ["Patient"]);
497        assert!(run("'Patient/2'.resolve()", true).await.is_empty());
498    }
499
500    #[tokio::test]
501    async fn services_required() {
502        let patient = patient();
503        let result = FPEngine::new()
504            .evaluate("'a'.memberOf('http://vs')", vec![&patient])
505            .await;
506        assert!(result.is_err());
507    }
508}