mllp

An Elixir library for transporting HL7 messages via MLLP (Minimal Lower Layer Protocol)

Status

This project is not at v1.0 yet. The API and internals will likely change quite a bit between now and v1.0. Also, be aware of the details of the license (Apache 2.0).

Usage

HL7 over MLLP

First, let's start an MLLP.Receiver on port 4090.

{:ok, r4090} = MLLP.Receiver.start(port: 4090, dispatcher: MLLP.EchoDispatcher)

Next, start an MLLP.Client process and store its PID.

{:ok, s1} = MLLP.Client.start_link({127,0,0,1}, 4090)

Alternatively, you could start a Client using a DNS name rather than an IP address.

{:ok, s1} = MLLP.Client.start_link("localhost", 4090)

Now send an HL7 message.

MLLP.Client.send(s1, HL7.Examples.wikipedia_sample_hl7() |> HL7.Message.new())

You will see log info like...


15:56:07.217 [debug] Receiver received data: [<<11, 77, 83, 72, 124, 94, 126, 92, 38, 124, 77, 101, 103, 97, 82, 101, 103, 124, 88, 89, 90, 72, 111, 115, 112, 67, 124, 83, 117, 112, 101, 114, 79, 69, 124, 88, 89, 90, 73, 109, 103, 67, 116, 114, 124, 50, 48, 48, 54, 48, ...>>].
 
15:56:07.217 [info] The EchoDispatcher simply logs and discards messages. Message type: mllp_hl7 Message: MSH|^~\&|MegaReg|XYZHospC|SuperOE|XYZImgCtr|20060529090131-0500||ADT^A01^ADT_A01|01052901|P|DG1|1||786.50^CHEST PAIN, UNSPECIFIED^I9|||A|||FFMD^0010^UAMC^L||67890^GRAINGER^LUCY^X^^^MD^0010^UAMC^L|MED|||||A0||13579^POTTER^SHERMAN^T^^^MD^0010^UAMC^L|||||||||||||||||||||||||||2006052909000^^O|||||||0105I30001^^^99DEF^AN
 
15:56:07.218 [debug] MLLP Envelope performed unnecessary unwrapping of unwrapped message
{:error, :application_reject,
 %MLLP.Ack{
   acknowledgement_code: "AR",
   hl7_ack_message: nil,
   text_message: "A real MLLP message dispatcher was not provided"
 }}

The Logger [debug] part tells us that the Receiver received the HL7 message. The Logger [info] part explains that the EchoDispatcher does not route the message anywhere (just "logs and discards" messages). Finally, the return value ({:error, :application_reject, ...}) is a NACK. The EchoDispatcher will not reply with an :application_accept.

Now, we will stop the Receiver.

 MLLP.Receiver.stop(4090)

Writing a message dispatcher

MLLP does not ship with a default message dispatcher as the cases can vary significantly from domain to domain. Instead, MLLP provides you with helper libraries (e.g., HL7 and helper functions so you can easily craft your own message dispatcher to suit your needs.

Now we will set up a receiver with a custom dispatcher.

defmodule DemoDispatcher do
  @behaviour MLLP.Dispatcher

  def dispatch(:mllp_hl7, message, state) when is_binary(message) do
    # Put your message handling logic here

    reply =
      MLLP.Ack.get_ack_for_message(
        message,
        :application_accept
      )
      |> to_string()
      |> MLLP.Envelope.wrap_message()

    {:ok, %{state | reply_buffer: reply}}
  end
end

Now we can configure a Receiver to use our newly defined DemoDispatcher.

{:ok, r4090} = MLLP.Receiver.start(port: 4090, dispatcher: DemoDispatcher)

Next, let's set up a Client to exercise our new Receiver.

{:ok, s2} = MLLP.Client.start_link({127,0,0,1}, 4090)

Now when you send a message to the Receiver's port, the custom DemoDispatcher will be used.

MLLP.Client.send(s2, HL7.Examples.wikipedia_sample_hl7() |> HL7.Message.new())

Notice the DemoDispatcher warning is no longer in the Logger output.

15:43:26.605 [debug] MLLP Envelope performed unnecessary unwrapping of unwrapped message
 
15:43:26.606 [debug] Receiver received data: [<<11, 77, 83, 72, 124, 94, 126, 92, 38, 124, 77, 101, 103, 97, 82, 101, 103, 124, 88, 89, 90, 72, 111, 115, 112, 67, 124, 83, 117, 112, 101, 114, 79, 69, 124, 88, 89, 90, 73, 109, 103, 67, 116, 114, 124, 50, 48, 48, 54, 48, ...>>].
{:ok, :application_accept,
 %MLLP.Ack{acknowledgement_code: "AA", hl7_ack_message: nil, text_message: ""}}

Non-HL7 over MLLP

While the MLLP framing protocol is mostly for HL7, some companies also send and receive non-HL7 data over MLLP. If you find yourself needing to integrate with a system that has made this choice the following will be helpful.

defmodule ExpandedDemoDispatcher do
  @behaviour MLLP.Dispatcher

  def dispatch(:mllp_hl7, message, state) when is_binary(message) do
    # Your message handling logic here

    reply =
      MLLP.Ack.get_ack_for_message(message, :application_accept)
      |> to_string()
      |> MLLP.Envelope.wrap_message()
    
    {:ok, %{state | reply_buffer: reply}}
  end

  def dispatch(:mllp_unknown, message, state) when is_binary(message) do
    # Your message handling logic here

    reply = "Got the BLOB"

    {:ok, %{state | reply_buffer: reply}}
  end
end

Now, to use this expanded custom dispatcher with a Client and Receiver.

Let's start by getting a new Receiver.

iex> {:ok, r4091} = MLLP.Receiver.start(port: 4091, dispatcher: ExpandedDemoDispatcher)
{:ok,
 %{
   pid: #PID<0.367.0>,
   port: 4091,
   receiver_id: #Reference<0.144001725.3813670918.119240>
 }}

Next, start a Client.

iex> {:ok, s3} = MLLP.Client.start_link("localhost", 4091)
{:ok, #PID<0.383.0>}

Now let's send and receive non-HL7 data over MLLP

iex> MLLP.Client.send(s3, "Hip hip hurray")
15:14:20.531 [debug] Receiver received data: [<<11, 72, 105, 112, 32, 104, 105, 112, 32, 104, 117, 114, 114, 97, 121, 28, 13>>].
{:ok, "Got the BLOB"}

Telemetry (Client only currently)

Can be namespaced or changed by passing a replacement for DefaultTelemetry.

The default emits [:mllp, :client, :status | :sending | :received] telemetry events. Emitted measurements contain status, errors, timestamps, etc. The emitted metadata contains the Client state.

Using TLS

Support for TLS can be added for MLLP protocol to secure the data transfer between a client and receiver. Follow steps below to start a receiver and client using TLS

Create certificates

The first step in TLS configuration is to create a TLS certificates, which can be used by the server to start the listener. To help you with creating self signed certificate, run following script:

sh tls/tls.sh

This script creates the following certs:

  • root ca
  • server certificate signed by root ca
  • client certificate signed by root ca
  • expired client certificate signed by root ca

Start Receiver

iex> MLLP.Receiver.start(port: 8154, dispatcher: MLLP.EchoDispatcher, transport_opts: %{tls: [cacertfile: "tls/root-ca/ca_certificate.pem", verify: :verify_none, certfile: "tls/server/server_certificate.pem", keyfile: "tls/server/private_key.pem"]})

Start Client

iex> {:ok, s3} = MLLP.Client.start_link("localhost", 8154, tls: [verify: :verify_peer, cacertfile: "tls/root-ca/ca_certificate.pem"])

Send a message

iex> MLLP.Client.send(s3, HL7.Examples.wikipedia_sample_hl7() |> HL7.Message.new())

Using Client Certificates

MLLP listener can enforce client to provide a valid certificate before establishing a successful connection. Follow steps below to use client cert with a listener

Start MLLP listener with :verify_peer option

iex> MLLP.Receiver.start(port: 8154, dispatcher: MLLP.EchoDispatcher, transport_opts: %{tls: [cacertfile: "tls/root-ca/ca_certificate.pem", verify: :verify_peer, certfile: "tls/server/server_certificate.pem", keyfile: "tls/server/private_key.pem"]})

Start MLLP Client with client cert

iex> {:ok, s3} = MLLP.Client.start_link("localhost", 8154, tls: [verify: :verify_peer, cacertfile: "tls/root-ca/ca_certificate.pem", certfile: "tls/client/client_certificate.pem", keyfile: "tls/client/private_key.pem"])

Send a message

iex> MLLP.Client.send(s3, HL7.Examples.wikipedia_sample_hl7() |> HL7.Message.new())

Using Client Restrictions

MLLP listener supports two options to restrict incoming client connections to make sure it accepts only trusted clients.

  1. IP/DNS restriction - In this mode, we can restrict incoming connections using Client IP/DNS.
  2. Client Cert Check - If verify: :verify_peer option is enabled on listener, it will enforce client to send a valid client cert and will only allow the connection if certificate returned from the client is valid and trusted.

Here are couple of exmaples of using client restrictions

Options 1 - Client IP/DNS restrictions

Start MLLP listener with allowed_clients options

iex> MLLP.Receiver.start(port: 8154, dispatcher: MLLP.EchoDispatcher, allowed_clients: ["localhost"])

Start MLLP Client

iex> {:ok, s3} = MLLP.Client.start_link("localhost", 8154)

Send a message

iex> MLLP.Client.send(s3, HL7.Examples.wikipedia_sample_hl7() |> HL7.Message.new())

In this example starting a client on another server other than localhost will fail and a warning will be logged on the server

[warn] Failed to verify client {ip, port}, error: :client_ip_not_allowed

This example provided is without TLS, We can modify it to use with TLS. Make sure to specify verify: :verify_none option in transport_opts on the listener. See Using TLS: for details on setting up TLS connections.

Options 2 - Client Cert Check

Start MLLP listener with TLS and allowed_clients options

iex> MLLP.Receiver.start(port: 8154, dispatcher: MLLP.EchoDispatcher, allowed_clients: ["client-1"], transport_opts: %{tls: [cacertfile: "tls/root-ca/ca_certificate.pem", verify: :verify_peer, certfile: "tls/server/server_certificate.pem", keyfile: "tls/server/private_key.pem"]})

Start MLLP Client

iex> {:ok, s3} = MLLP.Client.start_link("localhost", 8154, tls: [verify: :verify_peer, cacertfile: "tls/root-ca/ca_certificate.pem", certfile: "tls/client/client_certificate.pem", keyfile: "tls/client/private_key.pem"])

Send a message

iex> MLLP.Client.send(s3, HL7.Examples.wikipedia_sample_hl7() |> HL7.Message.new())

In the above scenarios we start a client with a valid certificate, but the cert issued is not one of the trusted client by the listener, thus the connection fails and a warning is logged by the listener

[warn] Failed to verify client {ip, port}, error: :fail_to_verify_client_cert

License

Elixir-MLLP source code is released under Apache 2 License. Check LICENSE file for more information.