Skip to main content

hl7_3/
message.rs

1//! The three-level structure every HL7 v3 message shares, whatever domain
2//! it carries: a transport wrapper, a control act wrapper, and a domain
3//! payload.
4//!
5//! ```
6//! let xml = r#"
7//! <QUQI_IN000001UV01 xmlns="urn:hl7-org:v3">
8//!   <id root="2.16.840.1.113883.19.5" extension="MSG00001"/>
9//!   <creationTime value="20260101120000"/>
10//!   <interactionId root="2.16.840.1.113883.1.6" extension="QUQI_IN000001UV01"/>
11//!   <processingCode code="P"/>
12//!   <sender typeCode="SND"><device classCode="DEV" determinerCode="INSTANCE"/></sender>
13//!   <receiver typeCode="RCV"><device classCode="DEV" determinerCode="INSTANCE"/></receiver>
14//!   <controlActProcess classCode="CACT" moodCode="EVN">
15//!     <code code="QUQI_TE000001UV01"/>
16//!     <subject>
17//!       <observation classCode="OBS" moodCode="EVN">
18//!         <id root="2.16.840.1.113883.19.5" extension="1"/>
19//!       </observation>
20//!     </subject>
21//!   </controlActProcess>
22//! </QUQI_IN000001UV01>
23//! "#;
24//! let message = hl7_3::message::parse(xml)?;
25//! assert_eq!(message.interaction_id.unwrap().extension.as_deref(), Some("QUQI_IN000001UV01"));
26//! assert_eq!(message.control_act.unwrap().code.unwrap().code, "QUQI_TE000001UV01");
27//! # Ok::<(), hl7_3::Error>(())
28//! ```
29
30use crate::Error;
31use crate::vocabulary::{Cd, Ii};
32use hl7_2_xml_lite_helper::Element;
33
34/// Level 1: the transport wrapper.
35///
36/// The root element's own tag is the interaction's wire name (analogous to
37/// [`hl7-2`](https://crates.io/crates/hl7-2)'s message structure ID) —
38/// this crate does not currently expose it as a field; read
39/// [`Element::name`] on the value [`parse`] was given if you need it.
40#[derive(Debug, Clone, PartialEq, Default)]
41pub struct Message {
42    /// This message's own identifier — not the identifier of anything it
43    /// carries.
44    pub id: Option<Ii>,
45    /// When the message was created, as raw text (see
46    /// [`crate::rim::Act::effective_time`] for why this crate doesn't
47    /// parse timestamps further yet).
48    pub creation_time: Option<String>,
49    /// Which interaction this is — the wire contract that names the
50    /// trigger event and the payload shape a receiver should expect. An
51    /// `II`, not a `CD`: `root` names the interaction catalog (almost
52    /// always `2.16.840.1.113883.1.6`) and `extension` names the specific
53    /// interaction (`"QUQI_IN000001UV01"`).
54    pub interaction_id: Option<Ii>,
55    /// The sender, read as a raw element rather than a modeled [`Device`]
56    /// — see `spec/index.md` §1 for why.
57    ///
58    /// [`Device`]: crate::rim::Entity
59    pub sender: Option<Element>,
60    /// The receiver, read the same way as `sender`.
61    pub receiver: Option<Element>,
62    /// Level 2 and 3: the control act wrapper and the domain payload it
63    /// carries, when the message has one.
64    pub control_act: Option<ControlAct>,
65}
66
67/// Level 2: the control act wrapper — identifies the real-world trigger
68/// event and carries level 3, the domain payload.
69#[derive(Debug, Clone, PartialEq, Default)]
70pub struct ControlAct {
71    /// Almost always `"CACT"` — kept as read, not assumed, since a
72    /// nonconforming sender is something a caller may want to notice
73    /// rather than have silently papered over.
74    pub class_code: String,
75    /// Almost always `"EVN"`, for the same reason.
76    pub mood_code: String,
77    /// The trigger event code — which real-world event caused this
78    /// message to be sent.
79    pub code: Option<Cd>,
80    /// Level 3: the domain-specific payload, read as a raw element.
81    ///
82    /// Its shape is defined by the interaction (`interaction_id`), not by
83    /// anything this crate knows in general — decode it with [`crate::rim`]
84    /// types yourself, matching what that interaction's schema says to
85    /// expect. This crate finds it (the first element under a `subject`
86    /// wrapper, the common case) and stops there; see `spec/index.md` §3
87    /// for the exact rule and its limits.
88    pub domain: Option<Element>,
89}
90
91/// Parse one HL7 v3 XML message into its three-level structure.
92///
93/// # Errors
94///
95/// [`Error::Xml`] when `xml_text` is not well-formed XML. This function
96/// does not fail when a wrapper element is missing — an absent `id`,
97/// `interactionId`, `sender`, or `controlActProcess` reads as `None`, the
98/// same way [`hl7-2`](https://crates.io/crates/hl7-2)'s generic mode
99/// degrades rather than rejecting; see `spec/index.md` §3.
100pub fn parse(xml_text: &str) -> Result<Message, Error> {
101    let root = hl7_2_xml_lite_helper::parse(xml_text).map_err(Error::Xml)?;
102    Ok(Message {
103        id: root.child("id").and_then(Ii::from_element),
104        creation_time: root
105            .child("creationTime")
106            .and_then(|time| time.attribute("value"))
107            .map(str::to_string),
108        interaction_id: root.child("interactionId").and_then(Ii::from_element),
109        sender: root
110            .child("sender")
111            .and_then(|wrapper| wrapper.children.first())
112            .cloned(),
113        receiver: root
114            .child("receiver")
115            .and_then(|wrapper| wrapper.children.first())
116            .cloned(),
117        control_act: root
118            .child("controlActProcess")
119            .map(control_act_from_element),
120    })
121}
122
123fn control_act_from_element(element: &Element) -> ControlAct {
124    ControlAct {
125        class_code: element
126            .attribute("classCode")
127            .unwrap_or_default()
128            .to_string(),
129        mood_code: element
130            .attribute("moodCode")
131            .unwrap_or_default()
132            .to_string(),
133        code: element.child("code").and_then(Cd::from_element),
134        domain: element
135            .child("subject")
136            .and_then(|subject| subject.children.first())
137            .cloned(),
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    const SAMPLE: &str = r#"
146        <QUQI_IN000001UV01 xmlns="urn:hl7-org:v3">
147          <id root="2.16.840.1.113883.19.5" extension="MSG00001"/>
148          <creationTime value="20260101120000"/>
149          <interactionId root="2.16.840.1.113883.1.6" extension="QUQI_IN000001UV01"/>
150          <sender typeCode="SND"><device classCode="DEV" determinerCode="INSTANCE"/></sender>
151          <receiver typeCode="RCV"><device classCode="DEV" determinerCode="INSTANCE"/></receiver>
152          <controlActProcess classCode="CACT" moodCode="EVN">
153            <code code="QUQI_TE000001UV01"/>
154            <subject>
155              <observation classCode="OBS" moodCode="EVN">
156                <id root="2.16.840.1.113883.19.5" extension="1"/>
157              </observation>
158            </subject>
159          </controlActProcess>
160        </QUQI_IN000001UV01>
161    "#;
162
163    #[test]
164    fn reads_the_transport_wrapper() {
165        let message = parse(SAMPLE).unwrap();
166        assert_eq!(message.id.unwrap().extension.as_deref(), Some("MSG00001"));
167        assert_eq!(message.creation_time.as_deref(), Some("20260101120000"));
168        assert_eq!(
169            message.interaction_id.unwrap().extension.as_deref(),
170            Some("QUQI_IN000001UV01")
171        );
172        assert_eq!(message.sender.unwrap().local_name(), "device");
173    }
174
175    #[test]
176    fn reads_the_control_act_wrapper_and_trigger_event() {
177        let message = parse(SAMPLE).unwrap();
178        let control_act = message.control_act.unwrap();
179        assert_eq!(control_act.class_code, "CACT");
180        assert_eq!(control_act.mood_code, "EVN");
181        assert_eq!(control_act.code.unwrap().code, "QUQI_TE000001UV01");
182    }
183
184    #[test]
185    fn reads_the_domain_payload_as_a_raw_element() {
186        let message = parse(SAMPLE).unwrap();
187        let domain = message.control_act.unwrap().domain.unwrap();
188        assert_eq!(domain.local_name(), "observation");
189        assert_eq!(domain.attribute("classCode"), Some("OBS"));
190        let act = crate::rim::Act::from_element(&domain);
191        assert_eq!(act.id[0].extension.as_deref(), Some("1"));
192    }
193
194    #[test]
195    fn missing_wrappers_read_as_none_not_an_error() {
196        let message = parse(r"<EMPTY_MESSAGE/>").unwrap();
197        assert_eq!(message.id, None);
198        assert_eq!(message.interaction_id, None);
199        assert_eq!(message.control_act, None);
200    }
201
202    #[test]
203    fn malformed_xml_is_an_error() {
204        assert!(matches!(parse("<unclosed>"), Err(Error::Xml(_))));
205    }
206}