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:

er7
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

sh
cargo new hl7-first
cd hl7-first
cargo add hl7

hl7 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

rust
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

rust
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:

rust
// 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.

rust
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

rust
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

rust
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

rust
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