1use 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#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Code {
19 pub system: Option<String>,
20 pub code: String,
21}
22
23pub 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 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
53async 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
91fn 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
126const 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
179fn 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 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
231fn 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
332pub(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}