Etlworks ships embedded copies of two HAPI libraries and exposes them to JavaScript and Python flows:
- HAPI HL7v2 (ca.uhn.hl7v2) — for HL7 2.x messages (pipe-delimited, MSH / PID / OBR / OBX, …).
- HAPI FHIR (ca.uhn.fhir and org.hl7.fhir.*) — for HL7 FHIR resources (JSON / XML, Patient / Observation / DiagnosticReport / Bundle, …).
This article is the scripting reference for both. The two halves are parallel: same hook into the dataset, same import-then-manipulate pattern, different libraries underneath. For the general HL7 reference in Etlworks (format settings, transports, the visual mapping path), see Working with HL7.
When should I use scripting instead of mapping?
Most HL7 work in Etlworks fits the visual nested mapping editor — segments / resources and fields appear in the tree, you drag source onto destination, and the engine handles the message construction. Reach for scripting when the transformation logic doesn't fit that model:
- Deep, per-message conditional logic — e.g., the OBX value depends on which NTE comment kind appeared earlier in the source message; or, in FHIR, the Observation's code depends on which LOINC subset the source maps into.
- Restructuring repeating segments or Bundle entries — e.g., drop NTE segments after extracting their data into OBX; or pivot Observations within a Bundle into a different shape.
- Cross-segment / cross-resource lookups within the same message or Bundle.
- Cloning + selective edits — start with the source, change a few fields, keep everything else.
- Generating multiple output messages from one input (or one from many).
If your transformation is "rename these fields, drop these fields, fill these fields from elsewhere", visual mapping is faster to build and easier to maintain. Pick scripting when the logic itself is the hard part.
Pick the right flavor: HL7 2.x vs FHIR
| HL7 2.x | HL7 FHIR | |
|---|---|---|
| On-the-wire format | Pipe-delimited segments (MSH, PID, OBR, OBX, …). | JSON or XML resources (Patient, Observation, DiagnosticReport, …). |
| Information model | Message-oriented — each message type (ORM_O01, ORU_R01, ADT_A01, …) has a fixed segment tree. | Resource-oriented — each resource is a self-contained object; collections are wrapped in a Bundle. |
| Versions | 2.1, 2.2, 2.3, 2.3.1, 2.4, 2.5, 2.5.1, 2.6, 2.7. | DSTU2, DSTU2.1, DSTU2 HL7-Org, DSTU3, R4, R5. |
| Library | HAPI HL7v2 (ca.uhn.hl7v2). | HAPI FHIR (ca.uhn.fhir for parsers / context, org.hl7.fhir.* for the resource model). |
| Hook into the flow dataset | dataSet.getActualData() / dataSet.setActualData() — same as FHIR. | dataSet.getActualData() / dataSet.setActualData() — same as 2.x. |
| Typical use cases | Clinical interfaces, legacy hospital systems, lab orders / results, ADT feeds, MLLP traffic. | Modern healthcare APIs, US Core / international FHIR profiles, REST integrations with EHRs, SMART on FHIR apps. |
The two libraries are independent — an HL7 2.x flow doesn't touch FHIR classes and vice versa. The hook into the Etlworks dataset is the same on both sides: dataSet.getActualData() returns whichever object the source format produced (a HAPI HL7v2 message type for HL7 2.x flows, a HAPI FHIR IBaseResource for FHIR flows).
HL7 2.x scripting (HAPI HL7v2)
This half of the article covers scripting against the HL7 2.x object model. If you're working with FHIR, skip to FHIR scripting below.
The HAPI HL7v2 library
HL7 2.x parsing and construction in Etlworks is implemented on top of HAPI HL7v2, the de-facto Java library for HL7 2.x. The library is bundled with Etlworks — you don't install it separately — and the package ca.uhn.hl7v2 is available to any JavaScript or Python flow.
Supported HL7 2.x versions
HAPI ships separate model classes for each HL7 2.x version. Etlworks bundles all of them. Use the package that matches the version of the message you're working with:
| HL7 version | Package prefix |
|---|---|
| 2.1 | ca.uhn.hl7v2.model.v21 |
| 2.2 | ca.uhn.hl7v2.model.v22 |
| 2.3 | ca.uhn.hl7v2.model.v23 |
| 2.3.1 | ca.uhn.hl7v2.model.v231 |
| 2.4 | ca.uhn.hl7v2.model.v24 |
| 2.5 | ca.uhn.hl7v2.model.v25 |
| 2.5.1 | ca.uhn.hl7v2.model.v251 |
| 2.6 | ca.uhn.hl7v2.model.v26 |
| 2.7 | ca.uhn.hl7v2.model.v27 |
Package layout
Inside each version's package the structure is the same:
| Subpackage | What it contains |
|---|---|
| ca.uhn.hl7v2.model.vNN.message | Top-level message types — ORM_O01, ORU_R01, ADT_A01, ACK, etc. |
| ca.uhn.hl7v2.model.vNN.segment | Segment classes — MSH, PID, PV1, OBR, OBX, NTE, ORC, IN1, etc. |
| ca.uhn.hl7v2.model.vNN.datatype | HL7 data types — ST, FT, NM, TS, XPN, XAD, etc. |
| ca.uhn.hl7v2.model.vNN.group | Repeating group structures within messages, e.g., ORM_O01_ORDER. |
Shared utilities used regardless of version:
| ca.uhn.hl7v2.util.DeepCopy | Copies a segment from one message into another, field by field. |
| ca.uhn.hl7v2.parser.PipeParser | Parser / serializer for the standard pipe-delimited HL7 wire format. |
| ca.uhn.hl7v2.parser.XMLParser | XML parser / serializer for HL7. |
| ca.uhn.hl7v2.HL7Exception | Base exception type thrown by HAPI APIs. |
For the full Javadoc, see the HAPI HL7v2 API documentation.
Accessing the HL7 2.x object model from a flow
The bridge between Etlworks and HAPI HL7v2 is two methods on com.toolsverse.etl.common.DataSet:
| Method | What it does |
|---|---|
| dataSet.getActualData() | Returns the parsed HL7 2.x message as a HAPI object (e.g., an ORM_O01 instance for a 2.3 lab order). Walk it from JavaScript or Python using the same getter / setter API HAPI provides in Java. |
| dataSet.setActualData(message) | Replaces the underlying HAPI object with one you've built or modified. The serializer emits it back to the wire format on output. |
The typical scripting flow:
- Read the source HL7 2.x message into the flow. Etlworks parses it through HAPI; the result is reachable as dataSet.getActualData().
- In a JavaScript / Python step, get the HAPI object, manipulate it, optionally build a new one.
- Call dataSet.setActualData(newMessage) if you've built a new top-level message; or just modify the existing object in place.
- The downstream flow writes the result through the HL7 destination format — serialization is automatic.
Importing HAPI HL7v2 packages from JavaScript
Use JavaImporter to make HAPI's Java packages available without the full path on every reference:
var javaImports = new JavaImporter(
Packages.ca.uhn.hl7v2.model.v23.message,
Packages.ca.uhn.hl7v2.model.v23.segment,
Packages.ca.uhn.hl7v2.model.v23.datatype,
Packages.ca.uhn.hl7v2.util,
java.io);
with (javaImports) {
var message = dataSet.getActualData(); // HAPI ORM_O01 (or whatever the source is)
// … your transformation code …
// For a brand-new top-level message:
// var destMessage = new ORU_R01();
// dataSet.setActualData(destMessage);
}
For other HL7 versions, change v23 to v25, v251, v26, etc.
From a Python flow the equivalent is direct import of the Java packages:
from ca.uhn.hl7v2.model.v23.message import ORM_O01, ORU_R01 from ca.uhn.hl7v2.util import DeepCopy message = dataSet.getActualData() # … transform …
Common HL7 2.x scripting patterns
Get a field value
HAPI exposes getters at every level — message → segment → field → component → subcomponent. Long names with the segment-field number are the most readable choice:
var sendingApp = message.getMSH().getSendingApplication().getNamespaceID().getValue();
var patientId = message.getPID().getPatientIDInternalID(0).getID().getValue();
var orderingMd = message.getORDER(0).getORDER_DETAIL().getOBR()
.getOrderingProvider(0).getFamilyName().getValue();
Set a field value
message.getMSH().getSendingApplication().getNamespaceID().setValue("Integrator");
message.getMSH().getMessageControlID().setValue(newControlId);
Iterate repeating segments
HL7 2.x segments and groups can repeat. HAPI exposes both an indexed accessor and a count:
var numOrders = message.getORDERReps();
for (var i = 0; i < numOrders; i++) {
var order = message.getORDER(i);
var obr = order.getORDER_DETAIL().getOBR();
// … do something with each ORDER group …
}
For "all of them as a list" use the getXxxAll() variants:
var allNte = order.getORDER_DETAIL().getNTEAll();
for (var i = 0; i < allNte.length; i++) {
var nte = allNte.get(i);
var commentSource = nte.getNte2_SourceOfComment().getValue();
// …
}
Insert / remove segments
// Insert a new OBSERVATION group at index 0
var obx = order.getORDER_DETAIL().insertOBSERVATION(0).getOBX();
obx.getObx1_SetIDOBX().setValue(1);
obx.getObx2_ValueType().setValue("FT");
// Remove NTE segments after extracting what you need from them
var detail = order.getORDER_DETAIL();
while (detail.getNTEReps() > 0) {
detail.removeNTE(0);
}
Clone a message
Start from the source and edit a few fields:
var sourceMessage = dataSet.getActualData();
var destMessage = ClassUtils.clone(sourceMessage);
destMessage.getMSH().getSendingApplication().getNamespaceID().setValue("Integrator");
dataSet.setActualData(destMessage);
Copy a segment between messages
Use DeepCopy to move a segment from one message to another field-for-field:
var sourceMessage = dataSet.getActualData();
var destMessage = new ORU_R01();
DeepCopy.copy(sourceMessage.getMSH(), destMessage.getMSH());
destMessage.getMSH().getSendingApplication().getNamespaceID().setValue("Integrator");
Build a destination message from scratch
var destMessage = new ORU_R01();
destMessage.getMSH().getFieldSeparator().setValue("|");
destMessage.getMSH().getEncodingCharacters().setValue("^~\\&");
destMessage.getMSH().getSendingApplication().getNamespaceID().setValue("Integrator");
destMessage.getMSH().getMessageType().getMessageType().setValue("ORU");
destMessage.getMSH().getMessageType().getTriggerEvent().setValue("R01");
// …
dataSet.setActualData(destMessage);
Example: HL7 2.x lab order processing
A hospital sends an HL7 2.3 ORM_O01 order via SFTP to a vendor folder. Etlworks reformats the message to the AP Easy specification and writes the result to the lab's destination folder.
What the transformation does
- Change MSH-3 (Sending Application) from the source application to Integrator.
- For each ORDER group in the message, derive OBX observations from the NTE comments and insert them into the ORDER_DETAIL.
- Use the NTE comment kind to decide whether each OBX represents "Site" or "Procedure".
- Map NTE-3 comment text to a controlled vocabulary (SHAVE, PUNCH, EXCISION, …).
- Remove the NTE and DG1 segments after the data has been extracted.
Flow shape
| Stage | What happens |
|---|---|
| Hospital → source-orders folder | SFTP drop. Etlworks polls. |
| source-orders → processing | Move-files flow. |
| processing → JavaScript reformat → destination-orders | HL7 to HL7 flow with a JavaScript transformation step using HAPI. |
| On error | Move to the failed folder for inspection. |
The transformation script
The key piece is the JavaScript step in the HL7-to-HL7 flow. It clones the source, edits MSH, and rewrites each ORDER's observations:
var javaImports = new JavaImporter(
Packages.ca.uhn.hl7v2.model.v23.message,
Packages.ca.uhn.hl7v2.model.v23.segment,
Packages.ca.uhn.hl7v2.model.v23.datatype);
with (javaImports) {
var message = dataSet.getActualData();
var destMessage = ClassUtils.clone(message);
// change sender
destMessage.getMSH().getSendingApplication().getNamespaceID().setValue("Integrator");
var numOrders = destMessage.getORDERReps();
for (var num = 0; num < numOrders; num++) {
var order = destMessage.getORDER(num);
var detail = order.getORDER_DETAIL();
var allNte = detail.getNTEAll();
// build first OBX from OBR-13
var obx = detail.insertOBSERVATION(0).getOBX();
obx.getObx1_SetIDOBX().setValue(1);
obx.getObx2_ValueType().setValue("FT");
obx.getObx3_ObservationIdentifier().getIdentifier().setValue("03");
obx.getObx3_ObservationIdentifier().getText().setValue("Clinical");
var ft = new FT(destMessage);
ft.setValue(detail.getOBR().getObr13_RelevantClinicalInformation().getValue());
obx.insertObservationValue(0).setData(ft);
// turn selected NTEs into Site / Procedure observations
var index = 1;
for (var i = 0; i < allNte.length; i++) {
if (i == 0 || i == 2 || i == 4 || i == 5) continue; // skip NTEs that don't map to OBX
var nte = allNte.get(i);
var obx = detail.insertOBSERVATION(index).getOBX();
obx.getObx1_SetIDOBX().setValue(index);
obx.getObx2_ValueType().setValue("FT");
obx.getObx3_ObservationIdentifier().getIdentifier().setValue("0" + index);
var qualifier = nte.getNte2_SourceOfComment().getValue().toLowerCase();
obx.getObx3_ObservationIdentifier().getText().setValue(
qualifier.contains("specimen") ? "Site" : "Procedure");
// map NTE-3 free text to a controlled vocabulary
var raw = nte.getNte3_Comment(0).getValue().toLowerCase();
var coded = raw.contains("punch") ? "PUNCH"
: raw.contains("shave") ? "SHAVE"
: raw.contains("excision") ? "EXCISION"
: nte.getNte3_Comment(0).getValue();
var ft = new FT(destMessage);
ft.setValue(coded);
obx.insertObservationValue(0).setData(ft);
index++;
}
// remove the NTE segments (and DG1 segments, omitted here for brevity)
while (detail.getNTEReps() > 0) {
detail.removeNTE(0);
}
}
dataSet.setActualData(destMessage);
}
Flow setup
- Create SFTP connections for source-orders, processing, failed, destination-orders. The processing folder is typically server storage on the Etlworks host.
- Create an HL7 2.3 format.
- Build the pipeline as a nested flow:
- Step 1: Move from source-orders to processing.
- Step 2: HL7-to-HL7 flow that reads from processing, runs the script above as the transformation step, writes to destination-orders.
- Step 3: Move source files out of processing on success.
- On error step: Move to failed.
- Schedule the nested flow continuously or on a short interval (every 30 s is typical for clinical drops).
Example: HL7 2.x lab results processing
The mirror of the order flow. The lab sends an HL7 2.3 ORU_R01 with optional PDF attachment via SFTP to the client folder. Etlworks builds a reformatted ORU_R01 and writes it back toward the hospital's destination folder.
What the transformation does
- Start from a blank ORU_R01 in the destination.
- Copy MSH from the source via DeepCopy.
- Change MSH-3 to Integrator.
- Iterate over each RESPONSE / ORDER_OBSERVATION in the source, transform the result content, and place it in the destination.
- Output is a different ORU_R01 structure tuned to the receiving system's expectations.
Flow shape
| Stage | What happens |
|---|---|
| Lab → source-results folder | SFTP drop. May include an HL7 ORU plus a PDF attachment. |
| source-results → processing | Files copied to a remote processing folder and to a local processing folder on the Etlworks host. |
| processing → JavaScript reformat → destination-results | HL7-to-HL7 flow with a JavaScript transformation step that builds a fresh ORU_R01 using DeepCopy. |
| PDF passthrough | Copy the associated PDF to the same destination folder. |
| On error | Move to failed. |
The transformation script
The key technique is build-from-scratch + DeepCopy instead of clone: when the output structure isn't the same as the input, building fresh and copying selectively is cleaner.
var javaImports = new JavaImporter(
Packages.ca.uhn.hl7v2.model.v23.message,
Packages.ca.uhn.hl7v2.model.v23.segment,
Packages.ca.uhn.hl7v2.model.v23.datatype,
Packages.ca.uhn.hl7v2.util,
java.io);
with (javaImports) {
var message = dataSet.getActualData();
var destMessage = new ORU_R01();
// Copy MSH from the source
DeepCopy.copy(message.getMSH(), destMessage.getMSH());
// Change sender
destMessage.getMSH().getSendingApplication().getNamespaceID().setValue("Integrator");
var responses = message.getRESPONSEAll();
for (var resNum = 0; resNum < responses.length; resNum++) {
var response = responses.get(resNum);
var destResponse = destMessage.getRESPONSE(resNum);
// Copy PID and ORDER_OBSERVATION sub-pieces, applying any
// per-field transformations the receiving system needs.
DeepCopy.copy(response.getPATIENT().getPID(),
destResponse.getPATIENT().getPID());
var orderObs = response.getORDER_OBSERVATIONAll();
for (var ooNum = 0; ooNum < orderObs.length; ooNum++) {
var srcOrderObs = orderObs.get(ooNum);
var destOrderObs = destResponse.getORDER_OBSERVATION(ooNum);
DeepCopy.copy(srcOrderObs.getORC(), destOrderObs.getORC());
DeepCopy.copy(srcOrderObs.getOBR(), destOrderObs.getOBR());
// Reformat OBX observations per the receiving system's spec
var obxAll = srcOrderObs.getOBSERVATIONAll();
for (var obxNum = 0; obxNum < obxAll.length; obxNum++) {
DeepCopy.copy(obxAll.get(obxNum).getOBX(),
destOrderObs.getOBSERVATION(obxNum).getOBX());
// … field-level edits here …
}
}
}
dataSet.setActualData(destMessage);
}
HL7 2.x notes and limitations
- Pick the right version package. HL7 2.3, 2.5, 2.5.1, and 2.6 messages each have their own classes — ORM_O01 in v23.message is a different class from ORM_O01 in v25.message. Importing the wrong package gives ClassCastException at runtime.
- Mixing versions across a flow is allowed (parse 2.3, emit 2.5) but the script has to translate — you can't simply DeepCopy a 2.3 segment into a 2.5 segment instance.
- Strict validation on the HL7 format may reject messages your script built if you forgot to set a required field. Disable strict validation temporarily while developing.
- Error handling — HAPI throws HL7Exception from many APIs. Wrap in a try/catch when accessing optional segments / fields that may not exist.
- Python flows can use the same HAPI APIs via direct package imports (from ca.uhn.hl7v2.model.v23.message import ORM_O01). JavaScript with JavaImporter is more common in the examples online.
- For simple field-level edits the visual mapping editor is faster to build and easier to maintain — reserve scripting for cases where the logic is more than a rename / reorder.
HL7 FHIR scripting (HAPI FHIR)
This half of the article covers scripting against the HL7 FHIR resource model. If you're working with HL7 2.x, see HL7 2.x scripting above.
The HAPI FHIR library
HL7 FHIR parsing and construction in Etlworks is implemented on top of HAPI FHIR, the Java library used across the FHIR ecosystem. The library is bundled with Etlworks (HAPI FHIR 5.6.x) — you don't install it separately — and the packages ca.uhn.fhir.* (context, parsers, validation) and org.hl7.fhir.* (the resource model) are available to any JavaScript or Python flow.
Supported FHIR versions
HAPI FHIR ships separate model classes for each FHIR release. Etlworks bundles all of them. The version is picked on the FHIR format (the Etlworks format's FHIR version setting); the model classes you script against come from the matching package:
| FHIR version | Format setting | Resource-model package |
|---|---|---|
| DSTU2 | DTSTU2 | ca.uhn.fhir.model.dstu2.resource |
| DSTU2.1 | DTSTU2_! | org.hl7.fhir.dstu2016may.model |
| DSTU2 (HL7.org variant) | DTSTU2HL7ORG | org.hl7.fhir.instance.model |
| DSTU3 (default) | DTSTU3 | org.hl7.fhir.dstu3.model |
| R4 | R4 | org.hl7.fhir.r4.model |
| R5 | R5 | org.hl7.fhir.r5.model |
R4 is the most common in production today and is the recommended starting point unless you have a specific reason to use another version. R5 is the current standard for new projects. DSTU3 is the Etlworks format default for backward compatibility with older deployments.
Package layout
Inside each version's resource-model package the structure is similar:
| Subpackage / class group | What it contains |
|---|---|
| org.hl7.fhir.r4.model (and the same for r5 / dstu3) | All FHIR resource classes — Patient, Observation, DiagnosticReport, ServiceRequest, Encounter, MedicationRequest, Practitioner, Organization, Bundle, and dozens more — plus the data types Reference, Identifier, CodeableConcept, Coding, Quantity, HumanName, Address, ContactPoint, Period, Range, DateType, StringType. |
Shared utilities under ca.uhn.fhir used regardless of version:
| ca.uhn.fhir.context.FhirContext | Entry point. Use FhirContext.forR4(), forR5(), forDstu3(), forDstu2(), forDstu2_1(), or forDstu2Hl7Org(). |
| ca.uhn.fhir.parser.IParser | JSON / XML parser. Create with ctx.newJsonParser() or ctx.newXmlParser(). |
| ca.uhn.fhir.validation.FhirValidator | Structural validation against the FHIR schema and (optionally) profiles. |
| org.hl7.fhir.instance.model.api.IBaseResource | Base interface for any FHIR resource — what dataSet.getActualData() returns. Cast to a concrete type to access typed getters. |
For the full Javadoc, see the HAPI FHIR API documentation.
Accessing the FHIR resource model from a flow
The bridge is the same two methods on com.toolsverse.etl.common.DataSet used for HL7 2.x. dataSet.getActualData() returns an IBaseResource instead of a HAPI HL7v2 message:
| Method | What it does |
|---|---|
| dataSet.getActualData() | Returns the parsed FHIR resource as an IBaseResource. Cast to a concrete type (Patient, Bundle, DiagnosticReport, …) to access typed getters. |
| dataSet.setActualData(resource) | Replaces the underlying resource with one you've built or modified. The destination FHIR format serializes it to JSON or XML on output. |
Importing HAPI FHIR packages from JavaScript
var javaImports = new JavaImporter(
Packages.org.hl7.fhir.r4.model,
Packages.ca.uhn.fhir.context);
with (javaImports) {
var resource = dataSet.getActualData(); // IBaseResource (cast to Patient / Bundle / etc.)
// … your transformation code …
// For a brand-new resource:
// var patient = new Patient();
// dataSet.setActualData(patient);
}
For other FHIR versions, change r4.model to r5.model, dstu3.model, etc.
From a Python flow:
from org.hl7.fhir.r4.model import Patient, Bundle, Observation, DiagnosticReport from ca.uhn.fhir.context import FhirContext resource = dataSet.getActualData() # … transform …
Common FHIR scripting patterns
Get a field value
FHIR resources are accessed through typed getters — the API is more uniform than HL7 2.x because everything is properties of the resource object, not positional segment fields:
var patient = dataSet.getActualData(); // Patient var familyName = patient.getName().get(0).getFamily(); var givenName = patient.getName().get(0).getGiven().get(0).getValue(); var birthDate = patient.getBirthDate(); // java.util.Date var gender = patient.getGender(); // AdministrativeGender enum var mrn = patient.getIdentifier().get(0).getValue();
Set a field value
patient.addName().setFamily("Smith").addGiven("John");
patient.setBirthDateElement(new DateType("1980-01-01"));
patient.setGender(Enumerations.AdministrativeGender.MALE);
patient.setActive(true);
patient.addIdentifier()
.setSystem("http://hospital.example.org/mrn")
.setValue("MRN-12345");
Build a CodeableConcept
Most FHIR fields that carry coded values use CodeableConcept — a wrapper around one or more codings plus optional free text:
var cc = new CodeableConcept();
cc.addCoding()
.setSystem("http://loinc.org")
.setCode("8867-4")
.setDisplay("Heart rate");
cc.setText("Heart rate");
observation.setCode(cc);
Iterate Bundle entries
var bundle = dataSet.getActualData(); // Bundle
var entries = bundle.getEntry(); // List<Bundle.BundleEntryComponent>
for (var i = 0; i < entries.size(); i++) {
var resource = entries.get(i).getResource();
if (resource.fhirType() == "Observation") {
var obs = resource; // Observation
var code = obs.getCode().getCodingFirstRep().getCode();
// …
}
}
Clone a resource
HAPI FHIR resources implement copy() directly — no separate DeepCopy class like HL7 2.x:
var sourcePatient = dataSet.getActualData();
var destPatient = sourcePatient.copy();
destPatient.addIdentifier()
.setSystem("http://lab.example.org/patient-id")
.setValue("LAB-" + sourcePatient.getIdentifierFirstRep().getValue());
dataSet.setActualData(destPatient);
Build a Bundle from scratch
var bundle = new Bundle();
bundle.setType(Bundle.BundleType.COLLECTION);
// Patient entry
var patient = new Patient();
patient.addName().setFamily("Smith").addGiven("John");
patient.setBirthDateElement(new DateType("1980-01-01"));
bundle.addEntry()
.setResource(patient)
.setFullUrl("urn:uuid:patient-1");
// Observation entry (referring to the Patient)
var obs = new Observation();
obs.setStatus(Observation.ObservationStatus.FINAL);
obs.setCode(new CodeableConcept().addCoding(
new Coding().setSystem("http://loinc.org")
.setCode("8867-4")
.setDisplay("Heart rate")));
obs.setSubject(new Reference("urn:uuid:patient-1"));
obs.setValue(new Quantity()
.setValue(72)
.setUnit("beats/minute")
.setSystem("http://unitsofmeasure.org")
.setCode("/min"));
bundle.addEntry()
.setResource(obs)
.setFullUrl("urn:uuid:observation-1");
dataSet.setActualData(bundle);
Parse / encode a resource outside the flow hook
If you need to parse a FHIR string that didn't come through the source format (e.g., a JSON pulled from an external API mid-flow):
var ctx = FhirContext.forR4(); var parser = ctx.newJsonParser(); // Parse var resource = parser.parseResource(jsonString); // Or with the typed variant: parser.parseResource(Patient.class, jsonString) // Encode parser.setPrettyPrint(true); var json = parser.encodeResourceToString(resource);
Example: reformat a FHIR ServiceRequest for a lab partner
The FHIR analog of the HL7 2.x lab-order example. A clinic posts a FHIR R4 Bundle to Etlworks containing a ServiceRequest (the lab order) and a Patient. Etlworks reformats the Bundle for the lab's intake API: rewrites the requester reference, recodes the requested test using the lab's coding system, and replaces the patient MRN with the lab's identifier system.
What the transformation does
- Find the ServiceRequest and Patient in the source Bundle.
- Add a Patient identifier with the lab's system URI.
- On the ServiceRequest, set requester to the lab-facing identifier of the ordering clinic.
- Re-code the ServiceRequest.code from the clinic's internal code to the lab's LOINC code via a controlled mapping.
- Emit a new Bundle with just the updated Patient and ServiceRequest in the order the lab expects.
Flow shape
| Stage | What happens |
|---|---|
| Clinic → HTTP listener | Clinic POSTs a Bundle (FHIR R4 JSON) to a FHIR listener endpoint on Etlworks. |
| Listener → JavaScript reformat → HTTP POST | FHIR-to-FHIR flow with a JavaScript transformation step that rebuilds the Bundle for the lab. |
| HTTP POST to lab | Etlworks POSTs the rebuilt Bundle to the lab's intake API. |
| On error | Log the failed Bundle to a file-storage error folder. |
The transformation script
var javaImports = new JavaImporter(
Packages.org.hl7.fhir.r4.model,
Packages.ca.uhn.fhir.context);
with (javaImports) {
var sourceBundle = dataSet.getActualData(); // Bundle (R4)
// Pull the resources we care about out of the source Bundle
var srcPatient = null;
var srcServiceRequest = null;
var srcEntries = sourceBundle.getEntry();
for (var i = 0; i < srcEntries.size(); i++) {
var r = srcEntries.get(i).getResource();
if (r.fhirType() == "Patient") srcPatient = r;
if (r.fhirType() == "ServiceRequest") srcServiceRequest = r;
}
// ----- Reformat Patient -----
var patient = srcPatient.copy();
patient.addIdentifier()
.setSystem("http://lab.example.org/patient-id")
.setValue("LAB-" + srcPatient.getIdentifierFirstRep().getValue());
// ----- Reformat ServiceRequest -----
var sr = srcServiceRequest.copy();
// requester -> the lab-facing identifier of the ordering clinic
sr.setRequester(new Reference()
.setIdentifier(new Identifier()
.setSystem("http://lab.example.org/clinic-id")
.setValue("CLINIC-001")));
// Re-code ServiceRequest.code from clinic codes -> lab LOINC codes
var clinicCode = sr.getCode().getCodingFirstRep().getCode();
var loincByCode = { "CBC":"58410-2", "BMP":"24323-8", "TSH":"3016-3" };
var loinc = loincByCode[clinicCode] || clinicCode;
sr.setCode(new CodeableConcept()
.addCoding(new Coding()
.setSystem("http://loinc.org")
.setCode(loinc)
.setDisplay(sr.getCode().getCodingFirstRep().getDisplay())));
// Patient reference inside the ServiceRequest gets the new internal id
sr.setSubject(new Reference("urn:uuid:patient-1"));
// ----- Build destination Bundle -----
var destBundle = new Bundle();
destBundle.setType(Bundle.BundleType.COLLECTION);
destBundle.addEntry().setResource(patient).setFullUrl("urn:uuid:patient-1");
destBundle.addEntry().setResource(sr).setFullUrl("urn:uuid:service-request-1");
dataSet.setActualData(destBundle);
}
Example: reformat a FHIR DiagnosticReport Bundle
The FHIR analog of the HL7 2.x lab-results example. The lab posts a FHIR R4 Bundle containing a DiagnosticReport, a Patient, and one Observation per result item. Etlworks rebuilds the Bundle for the ordering clinic's downstream system: converts lab-internal observation codes to a standard LOINC subset, copies the Patient + DiagnosticReport across, and emits Observations with units normalized to UCUM.
What the transformation does
- Start from a blank destination Bundle.
- Copy Patient and DiagnosticReport across (resource copy(), then targeted field edits).
- For each Observation, re-code Observation.code to the receiving system's LOINC subset and normalize valueQuantity.code to UCUM units.
- Rebuild DiagnosticReport.result references to point at the new Observation URIs.
Flow shape
| Stage | What happens |
|---|---|
| Lab → HTTP listener | Lab POSTs a Bundle (FHIR R4 JSON) to a FHIR listener endpoint. |
| Listener → JavaScript reformat → HTTP POST | FHIR-to-FHIR flow with a JavaScript transformation step. |
| HTTP POST to clinic | Etlworks POSTs the rebuilt Bundle to the clinic's downstream system. |
| On error | Log the failed Bundle to a file-storage error folder. |
The transformation script
var javaImports = new JavaImporter(
Packages.org.hl7.fhir.r4.model,
Packages.ca.uhn.fhir.context);
with (javaImports) {
var sourceBundle = dataSet.getActualData(); // Bundle (R4)
// Index source resources by type
var srcPatient = null, srcReport = null;
var srcObservations = [];
var srcEntries = sourceBundle.getEntry();
for (var i = 0; i < srcEntries.size(); i++) {
var r = srcEntries.get(i).getResource();
if (r.fhirType() == "Patient") srcPatient = r;
if (r.fhirType() == "DiagnosticReport") srcReport = r;
if (r.fhirType() == "Observation") srcObservations.push(r);
}
// Lab-internal observation codes -> standard LOINC
var loincByLab = {
"HR_BPM": "8867-4", // Heart rate
"BP_SYS": "8480-6", // Systolic blood pressure
"BP_DIA": "8462-4", // Diastolic blood pressure
"WBC": "6690-2", // White blood cell count
"GLUCOSE_FAST":"1558-6" // Fasting glucose
};
// Lab-internal unit strings -> UCUM codes
var ucumByUnit = {
"bpm": "/min",
"mmHg": "mm[Hg]",
"mg/dL": "mg/dL",
"10^3/uL":"10*3/uL"
};
// ----- Build destination Bundle -----
var destBundle = new Bundle();
destBundle.setType(Bundle.BundleType.COLLECTION);
// Patient
var patient = srcPatient.copy();
destBundle.addEntry().setResource(patient).setFullUrl("urn:uuid:patient-1");
// Observations
var obsRefs = [];
for (var n = 0; n < srcObservations.length; n++) {
var srcObs = srcObservations[n];
var obs = srcObs.copy();
// Re-code observation.code
var labCode = obs.getCode().getCodingFirstRep().getCode();
var loinc = loincByLab[labCode] || labCode;
obs.setCode(new CodeableConcept().addCoding(new Coding()
.setSystem("http://loinc.org")
.setCode(loinc)
.setDisplay(obs.getCode().getCodingFirstRep().getDisplay())));
// Normalize valueQuantity units to UCUM
if (obs.hasValueQuantity()) {
var q = obs.getValueQuantity();
var ucum = ucumByUnit[q.getUnit()] || q.getCode();
q.setSystem("http://unitsofmeasure.org");
q.setCode(ucum);
}
// Reference back to the new Patient URN
obs.setSubject(new Reference("urn:uuid:patient-1"));
var fullUrl = "urn:uuid:observation-" + (n + 1);
destBundle.addEntry().setResource(obs).setFullUrl(fullUrl);
obsRefs.push(new Reference(fullUrl));
}
// DiagnosticReport with result references rebuilt
var report = srcReport.copy();
report.setSubject(new Reference("urn:uuid:patient-1"));
report.setResult(obsRefs);
destBundle.addEntry().setResource(report).setFullUrl("urn:uuid:report-1");
dataSet.setActualData(destBundle);
}
FHIR notes and limitations
- Pick the right version package. R4 (org.hl7.fhir.r4.model) and R5 (org.hl7.fhir.r5.model) ship different classes for the same resource — importing the wrong one gives ClassCastException at runtime. Set the FHIR version on the format and import the matching package.
- Mixing versions across a flow works if you translate explicitly — you cannot pass an R4 Patient where an R5 Patient is expected. Use the resource's copy() only within the same FHIR version.
- Validation against profiles (US Core, IPS, …) is not enabled by default. Construct a FhirValidator with the profiles you care about and call validator.validateWithResult(resource) from your script when needed.
- JSON or XML — pick on the FHIR format. The same scripting code works regardless; serialization happens at the destination format.
- Bundles are the FHIR analog of a "compound HL7 message". Use Bundle.BundleType.COLLECTION for general groupings and TRANSACTION / BATCH only when you actually mean to invoke a transaction against a FHIR server.
- References in a Bundle are stitched together by fullUrl. When building a Bundle, set fullUrl on every entry that's referenced elsewhere and use the same URN in the Reference — otherwise downstream FHIR servers will fail to resolve the links.
- Python flows can use the same HAPI FHIR APIs (from org.hl7.fhir.r4.model import Patient, Bundle). JavaScript with JavaImporter is the typical choice in the examples online.
- For simple field-level edits the visual mapping editor is faster to build and easier to maintain — reserve scripting for cross-resource logic, code-system translations, and Bundle restructuring.
Related articles
- Working with HL7 — the general HL7 reference: format settings, transports, the visual mapping path, ACK / NACK handling.
- Nested mapping — the visual alternative to scripting for most HL7 transformations.
- Execute any JavaScript — the JavaScript flow type the examples above use.
- HAPI HL7v2 project page — HL7 2.x library upstream documentation, source code, and Javadoc.
- HAPI FHIR project page — FHIR library upstream documentation, source code, and Javadoc.