Skip to main content

fml_engine/
terminology.rs

1//! ConceptMaps for the `translate` transform.
2
3use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use serde_json::Value;
9
10/// A future a [`TranslateService`] returns.
11pub type TranslateFuture<'a> =
12    Pin<Box<dyn Future<Output = Result<Option<Translation>, String>> + 'a>>;
13
14/// A terminology service that runs `ConceptMap/$translate` (transforms.md, "translate"), for the
15/// ConceptMaps the engine does not hold itself: a FHIR server's own ConceptMaps, or an external
16/// terminology server.
17pub trait TranslateService {
18    /// Translates `code` from `system` (when known) with the ConceptMap at `url`. `Ok(None)`: no
19    /// translation; an error when the service fails or the translation is ambiguous.
20    fn translate<'a>(
21        &'a self,
22        url: &'a str,
23        system: Option<&'a str>,
24        code: &'a str,
25    ) -> TranslateFuture<'a>;
26}
27
28/// Finds ConceptMaps by canonical URL, for `translate(value, 'url', output)`.
29pub trait ConceptMapResolver {
30    fn resolve(&self, url: &str) -> Option<Arc<Value>>;
31}
32
33/// No ConceptMaps besides those contained in the map.
34#[derive(Debug, Default, Clone, Copy)]
35pub struct NoConceptMaps;
36
37impl ConceptMapResolver for NoConceptMaps {
38    fn resolve(&self, _url: &str) -> Option<Arc<Value>> {
39        None
40    }
41}
42
43/// A fixed set of ConceptMaps (FHIR R4 or R5 JSON), found by `url`, `url|version`, and by the
44/// canonical URLs they replace (`relatedArtifact` of type `predecessor`), so maps that still use
45/// an old canonical find the renamed ConceptMap.
46#[derive(Debug, Default, Clone)]
47pub struct ConceptMapSet {
48    by_url: HashMap<String, Arc<Value>>,
49}
50
51impl ConceptMapSet {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Adds a ConceptMap; other resources are ignored. Returns whether it was one.
57    pub fn add(&mut self, concept_map: Value) -> bool {
58        if concept_map["resourceType"] != "ConceptMap" {
59            return false;
60        }
61        let concept_map = Arc::new(concept_map);
62        let mut keys = Vec::new();
63        if let Some(url) = concept_map["url"].as_str() {
64            keys.push(url.to_owned());
65            if let Some(version) = concept_map["version"].as_str() {
66                keys.push(format!("{url}|{version}"));
67            }
68        }
69        for artifact in concept_map["relatedArtifact"]
70            .as_array()
71            .into_iter()
72            .flatten()
73        {
74            if artifact["type"] == "predecessor"
75                && let Some(resource) = artifact["resource"].as_str()
76            {
77                keys.push(resource.to_owned());
78            }
79        }
80        for key in keys {
81            self.by_url
82                .entry(key)
83                .or_insert_with(|| concept_map.clone());
84        }
85        true
86    }
87
88    pub fn len(&self) -> usize {
89        self.by_url.len()
90    }
91
92    pub fn is_empty(&self) -> bool {
93        self.by_url.is_empty()
94    }
95}
96
97impl ConceptMapResolver for ConceptMapSet {
98    fn resolve(&self, url: &str) -> Option<Arc<Value>> {
99        self.by_url
100            .get(url)
101            .or_else(|| self.by_url.get(url.split('|').next().unwrap_or(url)))
102            .cloned()
103    }
104}
105
106/// The result of translating a code: the target system and the mapped target entry.
107#[derive(Debug, Clone, PartialEq)]
108pub struct Translation {
109    pub system: Option<String>,
110    pub code: Option<String>,
111    pub display: Option<String>,
112}
113
114/// Translates `code` (from `system`, when known) with a ConceptMap in R5 or R4 form, as the
115/// reference engine does: the first element for the code, and among its targets the one whose
116/// relationship is `related-to`, `equivalent` or `source-is-narrower-than-target` (or none);
117/// several such targets are ambiguous. Without an element for the code, the group's unmapped
118/// rule applies (`use-source-code`, `fixed`, R4 `provided`). `Ok(None)`: the ConceptMap does not
119/// know the code, and the reference engine leaves the target unset. An element whose targets are
120/// all unrelated is an error.
121pub fn translate(
122    concept_map: &Value,
123    system: Option<&str>,
124    code: &str,
125) -> Result<Option<Translation>, String> {
126    let base = |uri: &str| uri.split('|').next().unwrap_or(uri).to_owned();
127    for group in concept_map["group"].as_array().into_iter().flatten() {
128        if let (Some(system), Some(source)) = (system, group["source"].as_str())
129            && base(source) != base(system)
130        {
131            continue;
132        }
133        let target_system = group["target"].as_str().map(base);
134        if let Some(element) = group["element"]
135            .as_array()
136            .into_iter()
137            .flatten()
138            .find(|element| element["code"].as_str() == Some(code))
139        {
140            let accepted: Vec<&Value> = element["target"]
141                .as_array()
142                .into_iter()
143                .flatten()
144                .filter(|target| usable(target))
145                .collect();
146            return match accepted.as_slice() {
147                [] => Err(format!("`{code}` has no usable translation")),
148                [target] => Ok(Some(Translation {
149                    system: target_system,
150                    code: target["code"].as_str().map(str::to_owned),
151                    display: target["display"].as_str().map(str::to_owned),
152                })),
153                _ => Err(format!("`{code}` has several translations")),
154            };
155        }
156        let unmapped = &group["unmapped"];
157        match unmapped["mode"].as_str() {
158            Some("use-source-code" | "provided") => {
159                return Ok(Some(Translation {
160                    system: target_system,
161                    code: Some(code.to_owned()),
162                    display: None,
163                }));
164            }
165            Some("fixed") => {
166                return Ok(Some(Translation {
167                    system: target_system,
168                    code: unmapped["code"].as_str().map(str::to_owned),
169                    display: unmapped["display"].as_str().map(str::to_owned),
170                }));
171            }
172            _ => {}
173        }
174    }
175    Ok(None)
176}
177
178/// A target the reference engine translates to: an R5 relationship of `related-to`,
179/// `equivalent` or `source-is-narrower-than-target`, the R4 equivalences that convert to those,
180/// or no relationship at all.
181fn usable(target: &Value) -> bool {
182    match (
183        target["relationship"].as_str(),
184        target["equivalence"].as_str(),
185    ) {
186        (Some(relationship), _) => matches!(
187            relationship,
188            "related-to" | "equivalent" | "source-is-narrower-than-target"
189        ),
190        (None, Some(equivalence)) => matches!(
191            equivalence,
192            "relatedto" | "inexact" | "equivalent" | "equal" | "wider" | "specializes"
193        ),
194        (None, None) => true,
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use serde_json::json;
201
202    use super::*;
203
204    fn concept_map() -> Value {
205        json!({
206            "resourceType": "ConceptMap",
207            "url": "http://example.org/ConceptMap/new",
208            "version": "1.0",
209            "relatedArtifact": [{ "type": "predecessor", "resource": "http://example.org/ConceptMap/old" }],
210            "group": [{
211                "source": "http://example.org/a|4.0.1",
212                "target": "http://example.org/b",
213                "element": [
214                    { "code": "x", "target": [{ "code": "X", "display": "Ex", "relationship": "equivalent" }] },
215                    { "code": "y", "target": [{ "code": "Y", "relationship": "not-related-to" }] },
216                    { "code": "z", "target": [{ "code": "Z", "equivalence": "wider" }] }
217                ],
218                "unmapped": { "mode": "fixed", "code": "UNK" }
219            }]
220        })
221    }
222
223    #[test]
224    fn finds_maps_by_url_version_and_predecessor() {
225        let mut set = ConceptMapSet::new();
226        assert!(set.add(concept_map()));
227        assert!(!set.add(json!({ "resourceType": "ValueSet", "url": "http://example.org/vs" })));
228        for url in [
229            "http://example.org/ConceptMap/new",
230            "http://example.org/ConceptMap/new|1.0",
231            "http://example.org/ConceptMap/new|2.0",
232            "http://example.org/ConceptMap/old",
233        ] {
234            assert!(set.resolve(url).is_some(), "{url}");
235        }
236    }
237
238    #[test]
239    fn translates_codes() {
240        let map = concept_map();
241        let x = translate(&map, Some("http://example.org/a"), "x")
242            .unwrap()
243            .unwrap();
244        assert_eq!(x.code.as_deref(), Some("X"));
245        assert_eq!(x.system.as_deref(), Some("http://example.org/b"));
246        assert_eq!(x.display.as_deref(), Some("Ex"));
247        assert_eq!(
248            translate(&map, None, "z").unwrap().unwrap().code.as_deref(),
249            Some("Z")
250        );
251        // The code is mapped, but only to an unrelated concept.
252        assert!(translate(&map, None, "y").is_err());
253        // An unmapped code takes the group's unmapped rule.
254        assert_eq!(
255            translate(&map, None, "q").unwrap().unwrap().code.as_deref(),
256            Some("UNK")
257        );
258        assert_eq!(translate(&map, Some("http://other.org"), "x"), Ok(None));
259    }
260}