HL7 v2
Parse, navigate, validate, modify, and write Health Level Seven (HL7) version 2 messages, as a Rust library and command-line tool — in three modes that share one set of internals.
HL7 v2 is the format most healthcare data still moves in, and the hard part
is not the syntax. That is pipes and carets, and the
er7 crate this one is built on already
handles it. The hard part is knowing what the pipes and carets mean in
the release the sender speaks. This crate owns that: the per-release
data-type tables, the message structures, and the three ways to apply them.
er7 the ER7 encoding: delimiters, escapes, paths,
byte-for-byte rendering, batch splitting
|
hl7-2 this crate: the HL7 v2 dictionary — releases
2.1-2.9, data types, message structures; three
modes; mutation; validation
|
+-- hl7-2-mllp transport (MLLP over TCP)
+-- hl7-2-from-er7-into-json format conversions
+-- hl7-2-from-er7-into-xml
+-- hl7-2-from-json-into-er7
+-- hl7-2-from-xml-into-er7
This crate is published standalone as hl7-2. Most users get it
through the hl7 umbrella crate instead,
which re-exports it as hl7::v2 — each HL7 standard gets a module of its
own there, leaving room for hl7::v3 and hl7::fhir, because a "message",
a "segment", and a "code" mean different things in each, and one flat
namespace would only invite mixing them up. Depend on hl7-2 directly only
if you specifically want v2 with no umbrella indirection. The command-line
tool is hl7-2 either way.
This README is a tour. spec/index.md is the normative,
section-by-section specification of every rule — the single source of truth
this crate implements against.
Three modes
Generic — for the vendor you have never seen
Parse anything into a navigable tree. Nothing is rejected, nothing is dropped, and what the dictionary recognises gets a name from HL7's own vocabulary.
let message = parse?;
let tree = message.tree;
assert_eq!;
assert_eq!;
// Every node knows the path that reads it back.
let second = tree.find_all.nth.unwrap;
assert_eq!;
assert_eq!;
Segments are grouped into the message structure when they fit it —
ORU_R01.PATIENT_RESULT.ORDER_OBSERVATION.OBSERVATION — and read flat when
they do not.
Schema-based — for the vendor whose format is not frozen
Write the shape as JSON, load it at runtime, and adding a field is a configuration change rather than a release.
let dictionary = from_json?;
let options = new.with_dictionary;
let message = parse_with_options?;
// The vendor's own segment now reads like any standard one.
assert_eq!;
The same format describes the bundled releases, so a schema can inherit one and state only its dialect.
Struct-based — for the feed that does not change
use ;
let admission: Admission = parse?.decode?;
assert_eq!;
// The one vendor field no struct models — same object, no second parse.
assert_eq!;
That last field is the point. Real feeds are stable until they are not, and
the usual choice at that moment is to re-parse the raw message or rewrite
the library. A Raw field keeps the whole parsed message beside the typed
data, so the fallback is a method call. Requires the derive feature:
= { = "0.2", = ["derive"] }
Walkthrough: from a message you have never seen to a typed struct
The three modes are not three libraries to choose between. They are three stages of the same job, and a real integration walks through them in order.
Stage 1 — look at it. A vendor sends a message and nobody knows what is in it. Start with the tool, not with code:
Everything standard is already named — PID.5 broke into XPN.1, XPN.2
— and the vendor's own ZAC is there positionally, nothing lost. The
bracketed paths are not decoration: each one is what reads that value back.
Stage 2 — write down what you learned. ZAC.2 is clearly a name. Say
so, in JSON, without touching the code:
|
ZAC.2 now reads as an XPN like any standard name field — and when the
vendor adds ZAC-4 next quarter it is one line in a file, not a release.
(Note the two vocabularies. XPN.1 is a node name, which is what the tree
and the JSON and XML sibling crates call that component. ZAC-2.1 is a
path, which is what --query, get, and set take. Names describe;
paths address.)
Stage 3 — freeze what is stable. Once the interface has held still long enough to trust, move it into the type system and let the compiler carry it:
And keep the raw field, because stage 3 is never final — the day a message
arrives with something the struct does not model, you are back at stage 1 on
the same object, with no re-parse and no rewrite.
Modify and build
A system that reads HL7 usually has to answer in it.
let mut message = parse?;
message.set?; // escapes delimiters in the value
message.append_segment;
message.set?;
let er7 = message.to_er7; // valid ER7, ready to send
let ack = acknowledge
.build_valid?;
assert_eq!;
An unmodified message writes back byte for byte — that guarantee is er7's,
and this crate does not weaken it.
Validate
Parsing stays lenient: unknown segments, unknown types, and structure mismatches are never errors. Checking is a separate question with a separate answer.
for diagnostic in message.validate
Diagnostics split by whose problem it is. Errors are the message contradicting the dictionary it claims: a required segment missing, a numeric field holding letters. Warnings are the dictionary not covering the message: an unknown segment, a structure with no grammar yet. Strict mode rejects the first and allows the second:
let options = new.strict;
match parse_with_options
A local Z-segment does not make a conformant message fail — most real interfaces carry one.
Versions
Releases 2.1 through 2.9, chosen from MSH-12 or forced with
Options::with_version. A release string this crate has no dictionary for
resolves to the nearest older one (2.5.2 reads as 2.5.1) rather than
failing.
v2.5 is the complete base dictionary; every other release is a delta of it
covering the differences this crate models today — MSH-9 without a
message-structure component before v2.3.1, the one-field ERR before v2.5,
TS withdrawn in favour of DTM from v2.7 — and inherits the rest. That
incompleteness is bounded by design: an unmodelled difference costs a typed
name, never a rejected message or a lost value. See
spec/index.md §3.4 for exactly what each release claims.
Command line
# Look at a message you have never seen
# Pull out every result value
# Check it, with an exit status a shell can act on
# Read a vendor dialect
# Change something and write it back out
hl7-v2 --help lists everything. Exit status is 0 on success, 1 on a usage
or parse error, and 2 when --check or --strict found something wrong.
Dependencies
One: er7, which has none of its own. The
JSON reader that loads dictionaries is hand-written here for the same
reason the sibling crates hand-write their writers — in a domain where
dependency trees get audited, a two-crate tree is worth a few hundred
lines. Enabling the derive feature adds hl7-2-derive, and with it
syn and quote, for callers who want the macros.
Install
Most users should instead depend on the hl7
umbrella crate (cargo add hl7), which re-exports this crate as hl7::v2.
See also
spec/index.md— the normative specificationer7— the ER7 encoding layerhl7-2-derive— the derive macroshl7-2-mllp— MLLP: sending and receiving these messages over TCPhl7-2-from-er7-into-json,-into-xml,hl7-2-from-json-into-er7,hl7-2-from-xml-into-er7— format conversions
License
MIT OR Apache-2.0 OR BSD-3-Clause OR GPL-2.0-only OR GPL-3.0-only