use std::cell::RefCell;
use std::collections::BTreeMap;
use std::fmt;
use std::rc::Rc;
use fhir_types::codec::Value;
use fhir_types::schema::Schemas;
#[derive(Debug, Clone, Copy)]
pub(crate) enum Resolution<'i> {
Found {
target: &'i Value,
via: Via<'i>,
},
Missing,
OutOfScope,
Ambiguous,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum Via<'i> {
Contained,
Entry {
from: &'i Value,
},
}
#[derive(Debug, Default)]
pub(crate) struct Index<'i> {
by_url: BTreeMap<&'i str, Vec<&'i Value>>,
by_resource: BTreeMap<usize, &'i Value>,
}
impl<'i> Index<'i> {
fn new(bundle: &'i Value) -> Self {
let mut index = Self::default();
for entry in entries(bundle) {
if let Some(url) = entry.get("fullUrl").and_then(Value::as_str) {
index.by_url.entry(url).or_default().push(entry);
}
if let Some(resource) = entry.get("resource") {
index
.by_resource
.entry(std::ptr::from_ref(resource).addr())
.or_insert(entry);
}
}
index
}
}
#[derive(Debug, Default)]
pub(crate) struct Indexes<'i> {
built: RefCell<BTreeMap<usize, Rc<Index<'i>>>>,
}
impl<'i> Indexes<'i> {
fn of(&self, bundle: &'i Value) -> Rc<Index<'i>> {
let key = std::ptr::from_ref(bundle).addr();
Rc::clone(
self.built
.borrow_mut()
.entry(key)
.or_insert_with(|| Rc::new(Index::new(bundle))),
)
}
}
pub(crate) trait Resolver: fmt::Debug + Send + Sync {
fn resolve<'i>(
&self,
reference: &str,
scope: &[&'i Value],
indexes: &Indexes<'i>,
) -> Resolution<'i>;
fn bundle<'i>(&self, scope: &[&'i Value], indexes: &Indexes<'i>) -> Option<&'i Value>;
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct Contained;
impl Resolver for Contained {
fn resolve<'i>(
&self,
reference: &str,
scope: &[&'i Value],
_indexes: &Indexes<'i>,
) -> Resolution<'i> {
let Some(id) = reference.strip_prefix('#') else {
return Resolution::OutOfScope;
};
let Some(container) = container(scope) else {
return Resolution::Missing;
};
let found = if id.is_empty() {
Some(container)
} else {
contained(container)
.iter()
.find(|item| item.get("id").and_then(Value::as_str) == Some(id))
};
found.map_or(Resolution::Missing, |target| Resolution::Found {
target,
via: Via::Contained,
})
}
fn bundle<'i>(&self, _scope: &[&'i Value], _indexes: &Indexes<'i>) -> Option<&'i Value> {
None
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Bundled {
schemas: &'static Schemas,
}
impl Bundled {
pub(crate) const fn new(schemas: &'static Schemas) -> Self {
Self { schemas }
}
}
impl Resolver for Bundled {
fn resolve<'i>(
&self,
reference: &str,
scope: &[&'i Value],
indexes: &Indexes<'i>,
) -> Resolution<'i> {
if reference.starts_with('#') {
return Contained.resolve(reference, scope, indexes);
}
let Some((bundle, entry)) = context(scope, indexes) else {
return Resolution::OutOfScope;
};
let Some(from) = entry.get("resource") else {
return Resolution::OutOfScope;
};
let full_url = entry.get("fullUrl").and_then(Value::as_str);
let Some((url, version)) = absolute(self.schemas, reference, full_url) else {
return Resolution::OutOfScope;
};
lookup((bundle, indexes), &url, version, from)
}
fn bundle<'i>(&self, scope: &[&'i Value], indexes: &Indexes<'i>) -> Option<&'i Value> {
context(scope, indexes).map(|(bundle, _)| bundle)
}
}
pub(crate) fn from_bundle<'i>(
schemas: &Schemas,
bundle: &'i Value,
reference: &str,
indexes: &Indexes<'i>,
) -> Resolution<'i> {
if !has_scheme(reference) {
return Resolution::OutOfScope;
}
match absolute(schemas, reference, None) {
Some((url, version)) => lookup((bundle, indexes), &url, version, bundle),
None => Resolution::OutOfScope,
}
}
fn lookup<'i>(
(bundle, indexes): (&'i Value, &Indexes<'i>),
url: &str,
version: Option<&str>,
from: &'i Value,
) -> Resolution<'i> {
let index = indexes.of(bundle);
let found: Vec<&'i Value> = index
.by_url
.get(url)
.into_iter()
.flatten()
.filter_map(|entry| entry.get("resource"))
.filter(|resource| version.is_none_or(|version| version_id(resource) == Some(version)))
.collect();
match found.as_slice() {
[target] => Resolution::Found {
target,
via: Via::Entry { from },
},
[] if url.starts_with("http://") || url.starts_with("https://") => Resolution::OutOfScope,
[] => Resolution::Missing,
_ => Resolution::Ambiguous,
}
}
fn context<'i>(scope: &[&'i Value], indexes: &Indexes<'i>) -> Option<(&'i Value, &'i Value)> {
for (index, &resource) in scope.iter().enumerate().rev() {
let outer = scope.get(..index).unwrap_or_default();
for &candidate in outer.iter().rev() {
if candidate.get("resourceType").and_then(Value::as_str) != Some("Bundle") {
continue;
}
let index = indexes.of(candidate);
if let Some(&entry) = index.by_resource.get(&std::ptr::from_ref(resource).addr()) {
return Some((candidate, entry));
}
}
}
None
}
pub(crate) fn container<'i>(scope: &[&'i Value]) -> Option<&'i Value> {
let (&innermost, outer) = scope.split_last()?;
Some(
outer
.iter()
.rev()
.copied()
.find(|candidate| {
contained(candidate)
.iter()
.any(|item| std::ptr::eq(item, innermost))
})
.unwrap_or(innermost),
)
}
fn entries(bundle: &Value) -> &[Value] {
bundle
.get("entry")
.and_then(Value::as_array)
.unwrap_or_default()
}
fn version_id(resource: &Value) -> Option<&str> {
resource
.get("meta")
.and_then(|meta| meta.get("versionId"))
.and_then(Value::as_str)
}
fn absolute<'r>(
schemas: &Schemas,
reference: &'r str,
full_url: Option<&str>,
) -> Option<(String, Option<&'r str>)> {
if has_scheme(reference) {
return Some(match restful(schemas, reference) {
Some(parts) if parts.version.is_some() => (parts.unversioned(), parts.version),
_ => (reference.to_owned(), None),
});
}
let relative = restful(schemas, reference).filter(|parts| parts.root.is_empty())?;
let root = restful(schemas, full_url?)?.root;
Some((
format!("{root}{}/{}", relative.kind, relative.id),
relative.version,
))
}
fn has_scheme(text: &str) -> bool {
text.split_once(':').is_some_and(|(scheme, _)| {
let mut chars = scheme.chars();
chars
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
})
}
#[derive(Debug, Clone, Copy)]
struct Restful<'a> {
root: &'a str,
kind: &'a str,
id: &'a str,
version: Option<&'a str>,
}
impl Restful<'_> {
fn unversioned(&self) -> String {
format!("{}{}/{}", self.root, self.kind, self.id)
}
}
fn restful<'t>(schemas: &Schemas, text: &'t str) -> Option<Restful<'t>> {
let (path, version) = match text.rsplit_once("/_history/") {
Some((path, version)) => (path, Some(version)),
None => (text, None),
};
if version.is_some_and(|version| !is_id(version)) {
return None;
}
let (head, id) = path.rsplit_once('/')?;
let kind = head.rsplit('/').next()?;
let root = head.strip_suffix(kind)?;
let base_ok = root.is_empty()
|| root
.strip_prefix("http://")
.or_else(|| root.strip_prefix("https://"))
.is_some_and(|rest| {
rest.ends_with('/')
&& rest.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '-' | '\\' | '.' | ':' | '%' | '$' | '/')
})
});
(base_ok && is_id(id) && schemas.is_resource(kind)).then_some(Restful {
root,
kind,
id,
version,
})
}
fn is_id(text: &str) -> bool {
(1..=64).contains(&text.len())
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'))
}
fn contained(resource: &Value) -> &[Value] {
resource
.get("contained")
.and_then(Value::as_array)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use fhir_types::codec::Value;
use fhir_types::r4::schema::SCHEMAS;
use super::Bundled;
use super::Contained;
use super::Indexes;
use super::Resolution;
use super::Resolver;
use super::Via;
use super::restful;
fn json(text: &str) -> Value {
serde_json::from_str(text).expect("test JSON")
}
fn id_of(found: Resolution<'_>) -> Option<&str> {
match found {
Resolution::Found { target, .. } => target.get("id").and_then(Value::as_str),
Resolution::Missing | Resolution::OutOfScope | Resolution::Ambiguous => None,
}
}
#[test]
fn a_local_reference_resolves_to_the_contained_resource() {
let resource =
json(r#"{"resourceType":"List","contained":[{"resourceType":"Patient","id":"p1"}]}"#);
let found = Contained.resolve("#p1", &[&resource], &Indexes::default());
assert!(matches!(
found,
Resolution::Found {
via: Via::Contained,
..
}
));
assert_eq!(id_of(found), Some("p1"));
assert!(matches!(
Contained.resolve("#p2", &[&resource], &Indexes::default()),
Resolution::Missing
));
assert!(matches!(
Contained.resolve("Patient/p1", &[&resource], &Indexes::default()),
Resolution::OutOfScope
));
}
#[test]
fn a_bare_hash_inside_a_contained_resource_resolves_to_its_container() {
let resource = json(
r#"{"resourceType":"List","id":"l","contained":[{"resourceType":"Patient","id":"p1"}]}"#,
);
let inner = resource
.get("contained")
.and_then(Value::as_array)
.and_then(|items| items.first())
.expect("the contained patient");
assert_eq!(
id_of(Contained.resolve("#", &[&resource, inner], &Indexes::default())),
Some("l")
);
}
#[test]
fn a_restful_url_splits_into_its_root_type_id_and_version() {
let parts =
restful(&SCHEMAS, "http://example.org/fhir/Patient/p1/_history/2").expect("RESTful");
assert_eq!(
(parts.root, parts.kind, parts.id, parts.version),
("http://example.org/fhir/", "Patient", "p1", Some("2"))
);
let relative = restful(&SCHEMAS, "Patient/p1").expect("RESTful");
assert_eq!((relative.root, relative.version), ("", None));
assert!(restful(&SCHEMAS, "urn:uuid:4a1e8f9e-0000-4000-8000-000000000001").is_none());
assert!(restful(&SCHEMAS, "http://example.org/fhir/NotAType/p1").is_none());
assert!(restful(&SCHEMAS, "ftp://example.org/Patient/p1").is_none());
}
#[test]
fn a_contained_resource_in_an_entry_resolves_through_its_containers_entry() {
let bundle = json(
r#"{"resourceType":"Bundle","type":"collection","entry":[
{"fullUrl":"http://example.org/fhir/Observation/o1","resource":{"resourceType":"Observation","id":"o1",
"contained":[{"resourceType":"Device","id":"d1"}]}},
{"fullUrl":"http://example.org/fhir/Patient/p1","resource":{"resourceType":"Patient","id":"p1"}}]}"#,
);
let entries = bundle
.get("entry")
.and_then(Value::as_array)
.expect("entries");
let observation = entries
.first()
.and_then(|entry| entry.get("resource"))
.expect("the observation");
let device = observation
.get("contained")
.and_then(Value::as_array)
.and_then(|items| items.first())
.expect("the device");
let found = Bundled::new(&SCHEMAS).resolve(
"Patient/p1",
&[&bundle, observation, device],
&Indexes::default(),
);
assert_eq!(id_of(found), Some("p1"));
assert!(matches!(
found,
Resolution::Found { via: Via::Entry { from }, .. } if std::ptr::eq(from, observation)
));
}
}