#![expect(
clippy::disallowed_types,
reason = "the wire-boundary validation reads the canonical JSON node before the typed decode \
(#1694 boundary class)"
)]
use serde_json::Value;
use crate::common::change_control::version::Version;
use openehr_base::prelude::{HierObjectId, ObjectVersionId};
impl<T> Version<T> {
#[must_use]
pub fn uid(&self) -> &ObjectVersionId {
match self {
Version::OriginalVersion(original) => &original.uid,
Version::ImportedVersion(imported) => imported.uid(),
}
}
#[must_use]
pub fn owner_id(&self) -> HierObjectId {
HierObjectId::from(self.uid().object_id())
}
#[must_use]
pub fn is_branch(&self) -> bool {
self.uid().is_branch()
}
}
#[derive(Debug, thiserror::Error)]
pub enum CanonicalFormError {
#[error("RFC 8785 (JCS) canonicalisation: {0}")]
Canonicalize(#[source] serde_json::Error),
}
pub fn canonical_form_of_json(value: &Value) -> Result<String, CanonicalFormError> {
let mut value = value.clone();
if let Value::Object(map) = &mut value {
map.remove("signature");
}
serde_jcs::to_string(&value).map_err(CanonicalFormError::Canonicalize)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::change_control::imported_version::ImportedVersion;
use crate::common::change_control::original_version::OriginalVersion;
use crate::common::generic::audit_details::{AuditDetails, AuditDetailsData};
use crate::common::generic::party_proxy::PartyProxy;
use crate::common::generic::party_self::PartySelf;
use crate::data_types::quantity::date_time::dv_date_time::DvDateTime;
use crate::data_types::text::code_phrase::CodePhrase;
use crate::data_types::text::dv_coded_text::DvCodedText;
use openehr_base::prelude::{ObjectId, ObjectRef, ObjectRefData, TerminologyId};
const COMPOSITION: &str = include_str!(
"../../../../openehr-its/tests/vendor/openehr_sdk/composition/canonical_json/minimal_persistent.json"
);
fn original_version(signature: Option<&str>) -> Value {
let data: Value = serde_json::from_str(COMPOSITION).unwrap();
let mut ov = serde_json::json!({
"_type": "ORIGINAL_VERSION",
"contribution": {
"_type": "OBJECT_REF",
"namespace": "local",
"type": "CONTRIBUTION",
"id": {
"_type": "HIER_OBJECT_ID",
"value": "0198f4a5-9df1-7d1e-8b6f-2b8c00000abc"
}
},
"commit_audit": {
"_type": "AUDIT_DETAILS",
"system_id": "ferroehr.local",
"time_committed": {
"_type": "DV_DATE_TIME",
"value": "2026-07-07T10:11:12.5Z"
},
"change_type": {
"_type": "DV_CODED_TEXT",
"value": "creation",
"defining_code": {
"_type": "CODE_PHRASE",
"terminology_id": { "_type": "TERMINOLOGY_ID", "value": "openehr" },
"code_string": "249"
}
},
"committer": { "_type": "PARTY_IDENTIFIED", "name": "FerroEHR" }
},
"uid": {
"_type": "OBJECT_VERSION_ID",
"value": "0198f4a5-9df1-7d1e-8b6f-2b8c00000001::ferroehr.local::1"
},
"lifecycle_state": {
"_type": "DV_CODED_TEXT",
"value": "complete",
"defining_code": {
"_type": "CODE_PHRASE",
"terminology_id": { "_type": "TERMINOLOGY_ID", "value": "openehr" },
"code_string": "532"
}
},
"data": data
});
if let Some(sig) = signature {
ov.as_object_mut()
.unwrap()
.insert("signature".to_owned(), Value::String(sig.to_owned()));
}
ov
}
#[test]
fn canonical_form_is_byte_stable_across_repeated_calls() {
let ov = original_version(None);
let a = canonical_form_of_json(&ov).unwrap();
let b = canonical_form_of_json(&ov).unwrap();
assert_eq!(a, b, "canonical_form must be deterministic");
}
#[test]
fn canonical_form_independent_of_signature_value() {
let none = canonical_form_of_json(&original_version(None)).unwrap();
let some = canonical_form_of_json(&original_version(Some("sha256:tampered"))).unwrap();
let other = canonical_form_of_json(&original_version(Some(
"-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----",
)))
.unwrap();
assert_eq!(none, some);
assert_eq!(none, other);
}
#[test]
fn canonical_form_golden() {
let canonical = canonical_form_of_json(&original_version(None)).unwrap();
insta::assert_snapshot!("original_version_canonical_form", canonical);
}
fn typed_original(value: &str) -> OriginalVersion<String> {
OriginalVersion {
contribution: ObjectRef::ObjectRef(ObjectRefData {
namespace: "local".to_owned(),
r#type: "CONTRIBUTION".to_owned(),
id: ObjectId::HierObjectId(
HierObjectId::new("11111111-1111-4111-8111-111111111111".to_owned())
.expect("a well-formed identifier"),
),
}),
signature: None,
commit_audit: AuditDetails::AuditDetails(AuditDetailsData {
system_id: "ferroehr.local".to_owned(),
time_committed: DvDateTime {
normal_status: None,
normal_range: None,
other_reference_ranges: openehr_base::containers::present_nonempty(Vec::new()),
magnitude_status: None,
accuracy: None,
value: "2026-07-07T10:11:12Z".to_owned(),
},
change_type: coded("249", "creation"),
description: None,
committer: PartyProxy::PartySelf(PartySelf { external_ref: None }),
}),
uid: ObjectVersionId::new(value.to_owned()).expect("a well-formed identifier"),
preceding_version_uid: None,
other_input_version_uids: openehr_base::containers::present_nonempty(Vec::new()),
lifecycle_state: coded("532", "complete"),
attestations: openehr_base::containers::present_nonempty(Vec::new()),
data: Some("content".to_owned()),
}
}
fn typed_version(value: &str) -> Version<String> {
Version::OriginalVersion(typed_original(value))
}
fn coded(code: &str, rubric: &str) -> DvCodedText {
DvCodedText {
value: rubric.to_owned(),
hyperlink: None,
formatting: None,
mappings: openehr_base::containers::present_nonempty(Vec::new()),
language: None,
encoding: None,
defining_code: CodePhrase {
terminology_id: TerminologyId {
value: "openehr".to_owned(),
},
code_string: code.to_owned(),
preferred_term: None,
},
}
}
#[test]
fn owner_id_is_the_uids_object_id() {
let version = typed_version("8849182c-82ad-4088-a07f-48ead4180515::ferroehr.local::2.1.1");
assert_eq!(
version.owner_id().value(),
"8849182c-82ad-4088-a07f-48ead4180515"
);
assert_eq!(
version.owner_id(),
HierObjectId::from(version.uid().object_id()),
"Owner_id_valid"
);
}
#[test]
fn is_branch_follows_the_version_tree_id() {
assert!(
!typed_version("8849182c-82ad-4088-a07f-48ead4180515::ferroehr.local::2").is_branch()
);
assert!(
typed_version("8849182c-82ad-4088-a07f-48ead4180515::ferroehr.local::2.1.1")
.is_branch()
);
}
#[test]
fn an_imported_version_derives_its_identity_from_the_wrapped_original() {
let item = typed_original("8849182c-82ad-4088-a07f-48ead4180515::remote.example::3.2.1");
let wrapper = Version::ImportedVersion(ImportedVersion {
contribution: ObjectRef::ObjectRef(ObjectRefData {
namespace: "local".to_owned(),
r#type: "CONTRIBUTION".to_owned(),
id: ObjectId::HierObjectId(
HierObjectId::new("22222222-2222-4222-8222-222222222222".to_owned())
.expect("a well-formed identifier"),
),
}),
signature: Some("local-wrapper-signature".to_owned()),
commit_audit: AuditDetails::AuditDetails(AuditDetailsData {
system_id: "local.example".to_owned(),
time_committed: DvDateTime {
normal_status: None,
normal_range: None,
other_reference_ranges: openehr_base::containers::present_nonempty(Vec::new()),
magnitude_status: None,
accuracy: None,
value: "2026-07-09T08:00:00Z".to_owned(),
},
change_type: coded("249", "creation"),
description: None,
committer: PartyProxy::PartySelf(PartySelf { external_ref: None }),
}),
item,
});
assert_eq!(
wrapper.uid().value(),
"8849182c-82ad-4088-a07f-48ead4180515::remote.example::3.2.1"
);
assert_eq!(
wrapper.owner_id().value(),
"8849182c-82ad-4088-a07f-48ead4180515"
);
assert!(wrapper.is_branch());
}
}