Skip to main content
Version: v0.0.4

Example: HL7 v2 to FHIR

A project with one channel, adt-to-fhir, that reads HL7 v2 files from a folder, turns each admission, registration and update into a FHIR R4 Patient, and POSTs it as JSON to a FHIR server. A lab result in the same folder is filtered out. It is in examples/hl7-to-fhir, with its synthetic test messages, and CI runs its tests and the pass below on every change, and checks each Patient with @cosyte/fhir's validator.

What it shows​

  • A transform that takes one format in and sends another out: see turn one format into another.
  • Fields read from the parsed message (PID-3, PID-5, PID-7, PID-8) and mapped to FHIR, with a value table for administrative sex.
  • An HTTP destination whose Content-Type is set for what goes out, since the channel's format names what comes in.
  • Tests whose expected output is JSON: an input's expected file keeps its extension, so the expected output of a01.hl7 is a01.expected.hl7.

The receiver runs plain HTTP on the loopback (plaintext: true), for this example only. A FHIR server is an https:// URL, and one that needs a token reads it from the environment: see Send and receive messages over HTTP.

Run it​

You need Node.js 24 or later. Copy the folder, then in it:

npm install
npm run key
npm test

npm run key writes a development key to .pathways/dev.key, which pathways.yaml names. npm test runs the channel's tests, shown under Expected output.

Start the stand-in FHIR server in a second terminal. It listens on port 8080 and writes each request's body to data/receiver/:

npm run receiver

Back in the first, drop an admission and a lab result into the channel's folder, then run one pass and exit:

mkdir -p data/adt-to-fhir/in
cp -p channels/adt-to-fhir/tests/a01.hl7 channels/adt-to-fhir/tests/oru.hl7 data/adt-to-fhir/in/
npx pathways start --once

cp -p keeps each file's time, so it is already older than the 1 second a file source waits for a file to stop changing. The receiver gets one POST, the Patient, and prints its size, never its content:

POST /fhir/Patient application/fhir+json: 286 bytes to data/receiver/1.json

data/receiver/1.json matches channels/adt-to-fhir/tests/a01.expected.hl7. Set FHIR_URL to send to your own server, and RECEIVER_PORT when 8080 is taken.

Expected output​

What pathways test prints, which npm test runs, as the example's README gives it. This section and the next are generated from the example by scripts/example-pages.ts, and CI fails when they no longer match it.

PASS adt-to-fhir/tests/a01.hl7
PASS adt-to-fhir/tests/a04.hl7
PASS adt-to-fhir/tests/oru.hl7
3 passed, 0 failed

Files​

Every file of examples/hl7-to-fhir/ but its README and git settings: hl7-to-fhir on GitHub.

Its messages are synthetic, made by @cosyte/synth, and are on GitHub with the rest: in channels/adt-to-fhir/tests/, a01.hl7, a04.hl7, oru.hl7. Each input in a tests/ folder has its expected output beside it.

package.json​

package.json
{
"private": true,
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"key": "node scripts/dev-key.ts",
"receiver": "node scripts/receiver.ts",
"start": "pathways start",
"test": "pathways test"
},
"devDependencies": {
"@cosyte/pathways": "^0.0.4"
}
}

channels/adt-to-fhir/channel.yaml​

channels/adt-to-fhir/channel.yaml
# yaml-language-server: $schema=../../node_modules/@cosyte/pathways/schema/channel.json

# Read HL7 v2 files from a folder, turn each admission, registration and update into a FHIR Patient,
# and POST it to a FHIR server.
name: adt-to-fhir

# What comes in: the transform receives each message parsed by @cosyte/hl7.
format: hl7v2

source:
file:
dir: ../../data/adt-to-fhir/in

transform: ./transform.ts

destinations:
- http:
url: { env: FHIR_URL, default: "http://127.0.0.1:8080/fhir/Patient" }
headers:
# The channel's format names what comes in, so set the type of what goes out.
Content-Type: application/fhir+json
# The receiver in scripts/receiver.ts answers plain HTTP on the loopback. A FHIR server is
# an https:// URL, which needs no plaintext line.
plaintext: true
# The receiver is on this machine, an address the destination refuses unless it is named
# here. A real endpoint on the public internet needs no allowNetworks line; one on a private
# network, such as 10.20.0.0/16, needs that network named here.
allowNetworks: [127.0.0.1]

channels/adt-to-fhir/transform.ts​

channels/adt-to-fhir/transform.ts
// Turns each admission, registration and update into a FHIR R4 Patient, as JSON. Every other
// message is filtered out.
import type { Transform } from "@cosyte/pathways";

// PID-8, administrative sex, as FHIR's administrative-gender codes.
const GENDER: Readonly<Record<string, string>> = {
F: "female",
M: "male",
O: "other",
U: "unknown",
};

/** An HL7 v2 date or time (YYYYMMDD...) as a FHIR date (YYYY-MM-DD), or undefined. */
function fhirDate(value: string | undefined): string | undefined {
const match = /^(\d{4})(\d{2})(\d{2})/.exec(value ?? "");
return match === null ? undefined : `${match[1]}-${match[2]}-${match[3]}`;
}

export default ((msg) => {
if (!msg.is("ADT^A01") && !msg.is("ADT^A04") && !msg.is("ADT^A08")) return null;
const patient = {
resourceType: "Patient",
// PID-3: the medical record number, and the authority that assigned it.
identifier: [
{
type: {
coding: [{ system: "http://terminology.hl7.org/CodeSystem/v2-0203", code: "MR" }],
},
value: msg.get("PID.3.1"),
assigner: { display: msg.get("PID.3.4") },
},
],
// PID-5: family name, then given name.
name: [{ family: msg.get("PID.5.1"), given: [msg.get("PID.5.2")] }],
gender: GENDER[msg.get("PID.8") ?? ""],
birthDate: fhirDate(msg.get("PID.7")),
};
// A key whose value is undefined is left out of the JSON.
return JSON.stringify(patient);
}) satisfies Transform;

pathways.yaml​

pathways.yaml
# yaml-language-server: $schema=./node_modules/@cosyte/pathways/schema/project.json

# Messages are stored in SQLite under .pathways/, each encrypted with the development key that
# `npm run key` writes there. .gitignore keeps the folder out of git.
storage:
sqlite:
file: .pathways/pathways.db
encryption:
keys:
- id: dev
file: .pathways/dev.key
current: dev

scripts/dev-key.ts​

scripts/dev-key.ts
// `npm run key`: writes a development master key to .pathways/dev.key, the file pathways.yaml
// names, unless one is there already. A key is 32 random bytes in base64, readable by this user
// only. .gitignore keeps .pathways/ out of git. A key for real messages comes from a secret store,
// never from a file made like this one.
import { randomBytes } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";

const KEY_FILE = ".pathways/dev.key";

mkdirSync(".pathways", { recursive: true, mode: 0o700 });
try {
// "wx" refuses a file that exists, so a key that has encrypted messages is never replaced.
writeFileSync(KEY_FILE, `${randomBytes(32).toString("base64")}\n`, { mode: 0o600, flag: "wx" });
process.stdout.write(`wrote ${KEY_FILE}\n`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
process.stdout.write(`${KEY_FILE} is there already\n`);
}

scripts/receiver.ts​

scripts/receiver.ts
// `npm run receiver`: a stand-in for the system the channel POSTs to. It listens on 127.0.0.1, port
// 8080 unless RECEIVER_PORT names another, writes each request's body to data/receiver/ as a file
// of its own, and answers 200. It prints the file's name and size, never what is in it.
import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";

const OUT = "data/receiver";
const EXTENSIONS: Readonly<Record<string, string>> = {
"application/hl7-v2": "hl7",
"application/fhir+json": "json",
};

let received = 0;
mkdirSync(OUT, { recursive: true });

const server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
received += 1;
const type = (request.headers["content-type"] ?? "").split(";")[0] ?? "";
const file = `${OUT}/${String(received)}.${EXTENSIONS[type] ?? "txt"}`;
const body = Buffer.concat(chunks);
writeFileSync(file, body);
process.stdout.write(
`${String(request.method)} ${String(request.url)} ${type}: ${String(body.length)} bytes to ${file}\n`,
);
response.writeHead(200).end();
});
});

const port = Number(process.env["RECEIVER_PORT"] ?? 8080);
server.listen(port, "127.0.0.1", () => {
process.stdout.write(`listening on http://127.0.0.1:${String(port)}\n`);
});