use std::fmt;
use std::path::Path;
use fhir_types::codec::{Json, Object, Value};
use fhir_types::r4::bundle::Bundle;
use fhir_types::r4::resource::Resource;
use fhir_types::r4::schema::SCHEMAS;
use fhirconnect::engine::context::CallContext;
use fhirconnect::engine::outcome::Warning;
use fhirconnect::engine::seam::Seams;
use fhirconnect::model::load::load_set;
use fhirconnect::model::semantic::StaticMappingCodes;
use fhirconnect::operations::contract::CompositionPayload;
use fhirconnect::operations::contract::ToFhirRequest;
use fhirconnect::operations::contract::ToFhirResponse;
use fhirconnect::operations::error::OperationError;
use fhirconnect::operations::programs::ProgramSet;
use fhirconnect::operations::programs::ProgramSetError;
use fhirconnect::operations::run;
use fhirconnect::operations::run::Settings;
use fhirconnect::resolve::compile::compile;
use fhirconnect::resolve::select::select_by_template;
use openehr_mapping_core::diagnostic::Diagnostic;
use openehr_mapping_core::header::metadata::MappingName;
use openehr_mapping_core::header::metadata::MappingNameError;
use openehr_mapping_core::index::WebTemplateIndex;
use openehr_mapping_core::template::PathError;
use openehr_mapping_core::template::TemplateSource;
use crate::transformation::Pin;
#[derive(Debug)]
pub struct Mapping {
programs: ProgramSet,
files: Vec<String>,
}
impl Mapping {
pub fn compile<P: AsRef<Path>>(
opt: &str,
files: &[P],
contexts: &[&str],
) -> Result<Self, MappingError> {
let source = TemplateSource::opt14(opt).map_err(|source| MappingError::Template {
source: Box::new(source),
})?;
let index = WebTemplateIndex::build(&source).map_err(|source| MappingError::Template {
source: Box::new(source),
})?;
let codes = StaticMappingCodes::default();
let set = load_set(files.iter().map(AsRef::as_ref), &codes)
.map_err(|diagnostics| MappingError::Refused { diagnostics })?;
let mut compiled = Vec::with_capacity(contexts.len());
for context in contexts {
let name = MappingName::new(*context)
.map_err(|source| MappingError::ContextName { source })?;
compiled.push(
compile(&set, &name, &index, &SCHEMAS, &codes)
.map_err(|diagnostics| MappingError::Refused { diagnostics })?,
);
}
let mut programs = ProgramSet::new();
programs.insert_template(index);
for program in compiled {
programs
.insert_program(program)
.map_err(|source| MappingError::Program { source })?;
}
let files = files
.iter()
.filter_map(|file| file.as_ref().file_name())
.map(|name| name.to_string_lossy().into_owned())
.collect();
Ok(Self { programs, files })
}
#[must_use]
pub fn len(&self) -> usize {
self.programs.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.programs.is_empty()
}
#[must_use]
pub fn files(&self) -> &[String] {
&self.files
}
#[must_use]
pub fn pin(&self, template: &str) -> Option<Pin> {
self.contexts()
.find(|context| context.template == template)
.map(|context| Pin {
context: Some(context.name.to_owned()),
template: context.template.to_owned(),
profile: Some(context.profile.to_owned()),
files: self.files.clone(),
package: None,
})
}
#[must_use]
pub fn start_archetype(&self, template: &str) -> Option<String> {
let program = self
.programs
.programs()
.iter()
.find(|program| program.template().id().as_str() == template)?;
program
.models()
.iter()
.find(|model| model.name() == program.start())?
.archetype()
.map(ToString::to_string)
}
pub(crate) const fn programs(&self) -> &ProgramSet {
&self.programs
}
pub fn contexts(&self) -> impl Iterator<Item = Context<'_>> {
self.programs.programs().iter().map(|program| Context {
name: program.context().as_str(),
template: program.template().id().as_str(),
profile: program.profile().url().as_str(),
})
}
pub fn to_fhir(
&self,
composition: &str,
settings: &Settings,
) -> Result<ToFhirResponse, MappingError> {
self.run(composition, settings, None)
.map(|(response, _)| response)
}
pub fn to_fhir_with(
&self,
composition: &str,
settings: &Settings,
context: CallContext,
) -> Result<ToFhirResponse, MappingError> {
self.run(composition, settings, Some(context))
.map(|(response, _)| response)
}
pub fn to_fhir_with_warnings(
&self,
composition: &str,
settings: &Settings,
context: CallContext,
) -> Result<(ToFhirResponse, Vec<Warning>), MappingError> {
self.run(composition, settings, Some(context))
}
fn run(
&self,
composition: &str,
settings: &Settings,
context: Option<CallContext>,
) -> Result<(ToFhirResponse, Vec<Warning>), MappingError> {
let payload =
CompositionPayload::parse(composition).map_err(|source| MappingError::Run {
source: Box::new(source),
})?;
if !matches!(payload, CompositionPayload::Canonical(_)) {
return Err(MappingError::NotCanonical);
}
let template = payload.template_id();
let request = ToFhirRequest::new(payload);
let request = match context {
Some(context) => request.with_context(context),
None => request,
};
let refused = |source: OperationError| MappingError::Run {
source: Box::new(source),
};
let (response, warnings) = run::to_fhir_with_warnings(
&self.programs,
&SCHEMAS,
&Seams::default(),
settings,
&request,
)
.map_err(refused)?
.into_parts();
let template = template.ok_or_else(|| refused(OperationError::CanonicalTemplate))?;
let program = select_by_template(self.programs.programs(), &template)
.map_err(|source| refused(OperationError::Select { source }))?;
let mut bundle = response.into_bundle();
claim(
&mut bundle,
program.resource().as_str(),
program.profile().url().as_str(),
)?;
Ok((ToFhirResponse::new(bundle), warnings))
}
}
fn claim(bundle: &mut Bundle, resource_type: &str, profile: &str) -> Result<(), MappingError> {
let mut claimed = false;
for resource in bundle
.entry
.iter_mut()
.filter_map(|entry| entry.resource.as_mut())
{
let mut object = Json::to_json(&*resource).map_err(claim_error)?;
if !matches!(object.get("resourceType"), Some(Value::String(name)) if name == resource_type)
{
continue;
}
let meta = object
.entry(String::from("meta"))
.or_insert_with(|| Value::Object(Object::new()));
let Value::Object(meta) = meta else {
return Err(claim_error(Unclaimable("its meta is no object")));
};
let profiles = meta
.entry(String::from("profile"))
.or_insert_with(|| Value::Array(Vec::new()));
let Value::Array(profiles) = profiles else {
return Err(claim_error(Unclaimable("its meta.profile is no array")));
};
if !profiles
.iter()
.any(|held| matches!(held, Value::String(url) if url == profile))
{
profiles.push(Value::String(profile.to_owned()));
}
*resource = Resource::from_json(&object, &mut fhir_types::codec::Path::root(resource_type))
.map_err(claim_error)?;
claimed = true;
}
if claimed {
Ok(())
} else {
Err(claim_error(Unclaimable(
"the run wrote no resource of the mapped type",
)))
}
}
fn claim_error(source: impl std::error::Error + Send + Sync + 'static) -> MappingError {
MappingError::Claim {
source: Box::new(source),
}
}
#[derive(Debug, thiserror::Error)]
#[error("the mapped resource cannot claim its profile: {0}")]
struct Unclaimable(&'static str);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Context<'a> {
pub name: &'a str,
pub template: &'a str,
pub profile: &'a str,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MappingError {
#[error("the operational template cannot be read")]
Template {
#[source]
source: Box<PathError>,
},
#[error("a context name is not a mapping name")]
ContextName {
#[source]
source: MappingNameError,
},
#[error("the mapping files were refused: {}", Refusals(diagnostics))]
Refused {
diagnostics: Vec<Diagnostic>,
},
#[error("a context maps another template")]
Program {
#[source]
source: ProgramSetError,
},
#[error("the composition is not canonical JSON: it carries no _type")]
NotCanonical,
#[error("the mapping run was refused")]
Run {
#[source]
source: Box<OperationError>,
},
#[error("the mapped resource cannot claim its profile")]
Claim {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
struct Refusals<'a>(&'a [Diagnostic]);
impl fmt::Display for Refusals<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (position, diagnostic) in self.0.iter().enumerate() {
if position > 0 {
f.write_str("; ")?;
}
write!(f, "{diagnostic}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use fhir_types::codec::{Json, Path, Value};
use fhir_types::r4::bundle::Bundle;
use super::claim;
const PROFILE: &str = "http://example.org/fhir/StructureDefinition/ferrofed-allergy";
const PROVENANCE: &str = r#"{ "resource": { "resourceType": "Provenance", "id": "v", "target": [{ "reference": "AllergyIntolerance/a1" }], "recorded": "2026-10-14T10:00:00Z", "agent": [{ "who": { "display": "Synthetic" } }] } }"#;
fn bundle(entries: &[String]) -> Bundle {
let text = format!(
r#"{{ "resourceType": "Bundle", "type": "collection", "entry": [{}] }}"#,
entries.join(",")
);
let Ok(Value::Object(read)) = serde_json::from_str::<Value>(&text) else {
panic!("the fixture should be a JSON object");
};
Bundle::from_json(&read, &mut Path::root("Bundle")).expect("the fixture should decode")
}
fn allergy(id: &str, meta: &str) -> String {
format!(
r#"{{ "resource": {{ "resourceType": "AllergyIntolerance", "id": "{id}"{meta}, "patient": {{ "reference": "Patient/p" }} }} }}"#
)
}
fn claims(bundle: &Bundle) -> Vec<Vec<String>> {
bundle
.entry
.iter()
.filter_map(|entry| entry.resource.as_ref())
.map(|resource| Json::to_json(resource).expect("an entry should encode"))
.filter(|object| {
object.get("resourceType").and_then(Value::as_str) == Some("AllergyIntolerance")
})
.map(|object| {
object
.get("meta")
.and_then(|meta| meta.get("profile"))
.and_then(Value::as_array)
.map(|profiles| {
profiles
.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect()
})
.unwrap_or_default()
})
.collect()
}
#[test]
fn every_entry_of_the_mapped_type_claims_the_profile_once() {
let mut mapped = bundle(&[
allergy("a1", ""),
String::from(PROVENANCE),
allergy(
"a2",
&format!(r#", "meta": {{ "profile": ["{PROFILE}"] }}"#),
),
]);
claim(&mut mapped, "AllergyIntolerance", PROFILE).expect("the run should claim");
assert_eq!(
claims(&mapped),
vec![vec![String::from(PROFILE)], vec![String::from(PROFILE)]],
"both allergies claim the profile, and the one that held it holds it once"
);
}
#[test]
fn a_run_with_no_entry_of_the_mapped_type_is_refused() {
let mut mapped = bundle(&[String::from(PROVENANCE)]);
assert!(claim(&mut mapped, "AllergyIntolerance", PROFILE).is_err());
}
}