Skip to main content

helios_sof/
reference_collector.rs

1//! Reference collection for remote `resolve()` prefetch (Phase 3).
2//!
3//! Gathers the FHIR `Reference.reference` strings present in an input bundle (and,
4//! during chained prefetch, in fetched resources) so the prefetch stage can decide
5//! which ones are eligible for remote fetching. This module is pure (no I/O).
6//!
7//! References are found by structurally walking the JSON for `"reference"` string
8//! fields, which captures every `Reference` element regardless of the path it
9//! appears at. Candidates are later filtered against the allowlist
10//! ([`crate::remote_resolver::RemoteResolveConfig::fetch_decision`]), so occasional
11//! non-`Reference` `"reference"` keys are harmless — they simply won't match.
12
13use std::collections::BTreeSet;
14
15use serde_json::Value;
16
17use crate::SofBundle;
18
19/// Collects all unique `reference` strings found anywhere in the bundle.
20pub fn collect_reference_strings(bundle: &SofBundle) -> Vec<String> {
21    let mut out = BTreeSet::new();
22    if let Some(json) = bundle_to_json(bundle) {
23        collect_into(&json, &mut out);
24    }
25    out.into_iter().collect()
26}
27
28/// Collects all unique `reference` strings from an arbitrary resource JSON value
29/// (used to discover chained references inside fetched resources).
30pub fn collect_references_from_json(value: &Value) -> Vec<String> {
31    let mut out = BTreeSet::new();
32    collect_into(value, &mut out);
33    out.into_iter().collect()
34}
35
36/// Collects all unique `reference` strings across a slice of raw resource JSON
37/// values (the streaming/NDJSON chunk shape).
38pub fn collect_reference_strings_from_resources(resources: &[Value]) -> Vec<String> {
39    let mut out = BTreeSet::new();
40    for resource in resources {
41        collect_into(resource, &mut out);
42    }
43    out.into_iter().collect()
44}
45
46/// Collects the `"ResourceType/id"` keys of a slice of raw resource JSON values.
47pub fn collect_resource_keys_from_resources(resources: &[Value]) -> BTreeSet<String> {
48    let mut keys = BTreeSet::new();
49    for resource in resources {
50        if let (Some(rt), Some(id)) = (
51            resource.get("resourceType").and_then(Value::as_str),
52            resource.get("id").and_then(Value::as_str),
53        ) {
54            keys.insert(format!("{rt}/{id}"));
55        }
56    }
57    keys
58}
59
60/// Collects the `"ResourceType/id"` keys of every resource already in the bundle,
61/// so the prefetch can skip references that resolve locally.
62pub fn collect_resource_keys(bundle: &SofBundle) -> BTreeSet<String> {
63    let mut keys = BTreeSet::new();
64    let Some(json) = bundle_to_json(bundle) else {
65        return keys;
66    };
67    let Some(entries) = json.get("entry").and_then(Value::as_array) else {
68        return keys;
69    };
70    for entry in entries {
71        let resource = entry.get("resource").unwrap_or(entry);
72        if let (Some(rt), Some(id)) = (
73            resource.get("resourceType").and_then(Value::as_str),
74            resource.get("id").and_then(Value::as_str),
75        ) {
76            keys.insert(format!("{rt}/{id}"));
77        }
78    }
79    keys
80}
81
82/// Returns the `"Type/id"` key implied by a reference string (relative or the
83/// trailing `Type/id` of an absolute URL), used to test local resolvability.
84///
85/// Mirrors the (capitalised-type) heuristic of the in-scope resolver. Returns
86/// `None` for fragment/bare references and anything without a `Type/id` tail.
87pub fn reference_type_id(reference: &str) -> Option<String> {
88    // Drop query/fragment before inspecting path segments.
89    let without_query = reference.split(['?', '#']).next().unwrap_or(reference);
90    let trimmed = without_query.trim_end_matches('/');
91    let mut segments = trimmed.rsplitn(3, '/');
92    let id = segments.next()?;
93    let resource_type = segments.next()?;
94    if id.is_empty() || resource_type.is_empty() {
95        return None;
96    }
97    // A FHIR resource type is a capitalised token.
98    if !resource_type
99        .chars()
100        .next()
101        .map(|c| c.is_ascii_uppercase())
102        .unwrap_or(false)
103    {
104        return None;
105    }
106    Some(format!("{resource_type}/{id}"))
107}
108
109/// Whether `reference` already resolves against a resource present in the bundle.
110pub fn resolves_in_bundle(reference: &str, bundle_keys: &BTreeSet<String>) -> bool {
111    match reference_type_id(reference) {
112        Some(key) => bundle_keys.contains(&key),
113        None => false,
114    }
115}
116
117fn collect_into(value: &Value, out: &mut BTreeSet<String>) {
118    match value {
119        Value::Object(map) => {
120            if let Some(Value::String(reference)) = map.get("reference") {
121                out.insert(reference.clone());
122            }
123            for child in map.values() {
124                collect_into(child, out);
125            }
126        }
127        Value::Array(items) => {
128            for item in items {
129                collect_into(item, out);
130            }
131        }
132        _ => {}
133    }
134}
135
136fn bundle_to_json(bundle: &SofBundle) -> Option<Value> {
137    match bundle {
138        #[cfg(feature = "R4")]
139        SofBundle::R4(b) => serde_json::to_value(b).ok(),
140        #[cfg(feature = "R4B")]
141        SofBundle::R4B(b) => serde_json::to_value(b).ok(),
142        #[cfg(feature = "R5")]
143        SofBundle::R5(b) => serde_json::to_value(b).ok(),
144        #[cfg(feature = "R6")]
145        SofBundle::R6(b) => serde_json::to_value(b).ok(),
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn extracts_type_id_from_relative_and_absolute() {
155        assert_eq!(
156            reference_type_id("Patient/123").as_deref(),
157            Some("Patient/123")
158        );
159        assert_eq!(
160            reference_type_id("https://example.org/fhir/Patient/123").as_deref(),
161            Some("Patient/123")
162        );
163        assert_eq!(
164            reference_type_id("https://example.org/fhir/Patient/123?_format=json").as_deref(),
165            Some("Patient/123")
166        );
167        assert_eq!(reference_type_id("#contained"), None);
168        assert_eq!(reference_type_id("urn:uuid:abc"), None);
169    }
170
171    #[test]
172    fn collects_nested_references() {
173        let resource = serde_json::json!({
174            "resourceType": "Encounter",
175            "subject": { "reference": "Patient/1" },
176            "participant": [
177                { "individual": { "reference": "Practitioner/2" } },
178                { "individual": { "reference": "Practitioner/3" } }
179            ]
180        });
181        let mut refs = collect_references_from_json(&resource);
182        refs.sort();
183        assert_eq!(refs, vec!["Patient/1", "Practitioner/2", "Practitioner/3"]);
184    }
185}