Tutorial
Your first message
From an empty directory to a program that reads a lab result, pulls out the patient and every observation, validates it, and produces the acknowledgement the sender is waiting for.
What you will build
A small program that behaves like the receiving end of a real lab interface. It answers four questions in order — who is this about, what does it say, do I trust it, and what do I say back — because that is the order a production receiver answers them in too.
Save this as oru_r01.hl7 in the project directory you are about to create:
MSH|^~\&|LAB|ACME|EHR|CLINIC|20260814080000||ORU^R01|MSG00042|P|2.5
PID|1||444333222^^^ACME&1.2.3.4&ISO^MR~987654321^^^NHS^NH||EVERYWOMAN^EVE^E||19620320|F
OBR|1|ORD776655|LAB2233|24331-1^Lipid Panel^LN|||20260813071500
OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL|<200|N|||F
OBX|2|NM|2085-9^HDL^LN||55|mg/dL|>40|N|||F
OBX|3|CE|10331-7^Rh Type^LN||D^Rh positive^LN|||N|||F
NTE|1||Fasting sample.Note PID-3: it carries two identifiers separated by ~, an ACME medical
record number and an NHS number. That repetition is not decoration — it is the most common thing
people get wrong on their first integration, and step 3 deals with it.
1. Set up
cargo new hl7-first
cd hl7-first
cargo add hl7hl7 is the umbrella crate; hl7::v2 is where everything in this
tutorial lives. See Install if you would rather depend on hl7-2 directly.
2. Parse it
src/main.rs
use hl7::v2;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let text = std::fs::read_to_string("oru_r01.hl7")?;
let message = v2::parse(&text)?;
println!("structure: {}", message.structure_id());
// structure: ORU_R01
Ok(())
}structure_id is the message's own claim about its shape, read from MSH-9. Because ORU_R01 is one of the structures the dictionary has a
grammar for, the segments are nested into groups rather than read flat — which you can see for
yourself with hl7-v2 --paths oru_r01.hl7.
3. Find the patient
let family = message.get("PID-5.1")?.unwrap_or_default();
let given = message.get("PID-5.2")?.unwrap_or_default();
let sex = message.get("PID-8")?;
println!("{family}, {given} ({sex:?})");
// EVERYWOMAN, EVE (Some("F"))
// PID-3 repeats: an ACME medical record number AND an NHS number.
for identifier in message.repetitions("PID-3")? {
println!("identifier: {identifier}");
}Read the identifier and its assigning authority together, and select the one you meant:
// Better: read the id and its assigning authority together, and pick.
let mut nhs_number = None;
for index in 1..=message.repetitions("PID-3")?.len() {
let authority = message.get(&format!("PID-3[{index}].4"))?;
if authority.as_deref() == Some("NHS") {
nhs_number = message.get(&format!("PID-3[{index}].1"))?;
}
}
assert_eq!(nhs_number.as_deref(), Some("987654321"));PID-3.4 is the assigning authority component of the CX data type. That
is the dictionary at work: it knows PID-3 holds a CX, so it knows what
the fourth component means.
4. Find every result
There are three OBX segments. Rather than hardcoding OBX[1] through OBX[3], walk the tree and let each node tell you its own path.
for observation in message.tree().find_all("OBX") {
let path = observation.path();
let code = message.get(&format!("{path}-3.2"))?.unwrap_or_default();
let value = message.get(&format!("{path}-5"))?.unwrap_or_default();
let units = message.get(&format!("{path}-6"))?.unwrap_or_default();
let flag = message.get(&format!("{path}-8"))?.unwrap_or_default();
println!("{code}: {value} {units} [{flag}]");
}
// Cholesterol: 187 mg/dL [N]
// HDL: 55 mg/dL [N]
// Rh Type: D [N]That observation.path() call is the bridge between the two vocabularies: find_all searches by name, and path() hands back the address that get takes. See Navigating.
5. Check it before trusting it
let diagnostics = message.validate();
for diagnostic in &diagnostics {
println!("{diagnostic}");
}
let has_errors = diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == v2::Severity::Error);Split on severity rather than on emptiness. An error means the message contradicts the dictionary it claims, and is the sender's problem. A warning means the dictionary does not cover the message, which is a coverage gap rather than a fault — rejecting on warnings would reject valid traffic. See Validating.
6. Answer it
let code = if has_errors { "AE" } else { "AA" };
let mut ack = v2::builder::acknowledge(&message, code, "ACK00001", "20260814080100")
.build_valid()?;
if has_errors {
ack.set("MSA-3", "message did not validate")?;
}
println!("{}", ack.to_er7());
assert_eq!(ack.get("MSA-2")?.as_deref(), Some("MSG00042"));MSA-2 echoing MSG00042 is the whole mechanism by which the sender
knows which message you answered. The control ID and timestamp are arguments rather than
generated, so this code is testable and traceable — the same reasoning behind MLLP's acknowledgement API.
The whole program
src/main.rs, complete
use hl7::v2;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let text = std::fs::read_to_string("oru_r01.hl7")?;
let message = v2::parse(&text)?;
// Who
let family = message.get("PID-5.1")?.unwrap_or_default();
let given = message.get("PID-5.2")?.unwrap_or_default();
println!("{family}, {given}");
// What
for observation in message.tree().find_all("OBX") {
let path = observation.path().to_string();
let code = message.get(&format!("{path}-3.2"))?.unwrap_or_default();
let value = message.get(&format!("{path}-5"))?.unwrap_or_default();
let units = message.get(&format!("{path}-6"))?.unwrap_or_default();
println!(" {code}: {value} {units}");
}
// Whether we trust it
let diagnostics = message.validate();
let has_errors = diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == v2::Severity::Error);
for diagnostic in &diagnostics {
eprintln!("{diagnostic}");
}
// What we say back
let code = if has_errors { "AE" } else { "AA" };
let mut ack = v2::builder::acknowledge(&message, code, "ACK00001", "20260814080100")
.build_valid()?;
if has_errors {
ack.set("MSA-3", "message did not validate")?;
}
println!("{}", ack.to_er7());
Ok(())
}Next
- Taming a vendor dialect — what to do when the message contains segments the standard has never heard of.
- An MLLP listener that answers — put this program behind a socket.
- Struct mode — once the feed has held still, replace those
getcalls with a struct the compiler checks.