Skip to content

Commit e24b69a

Browse files
Add MLLP output option and adapter
Add support for HL7 passthrough over MLLP: new mllp adapter sends raw HL7v2 framed with VT/FS/CR, reads ACKs and treats MSA AE/AR as NACKs (includes unit tests). Persist RawHL7 on model.RoutedPayload and include it in the gateway pipeline. Introduce Channel.OutputType ("fhir" | "hl7_passthrough") and update the router to construct either an HTTP or MLLP sender (both wrapped with retry). Update channel model, API types, and the web UI to let users pick output_type, adjust placeholders and form fields (hide auth for MLLP), and show output type in the channels list.
1 parent 77afbf0 commit e24b69a

8 files changed

Lines changed: 368 additions & 17 deletions

File tree

cmd/gateway/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ func makePipeline(rtr *router.Router, st store.Store) func(model.HL7Message) err
149149
Findings: len(report.Findings),
150150
})
151151

152-
payload := model.RoutedPayload{Resource: resource, Quality: report}
152+
payload := model.RoutedPayload{Resource: resource, Quality: report, RawHL7: msg.Payload}
153153

154154
// Persist as pending before delivery so the message is visible in
155155
// the UI even if routing crashes or the gateway restarts mid-flight.

internal/channel/channel.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,31 @@ type RetryConfig struct {
3333
Multiplier float64 `yaml:"multiplier" json:"multiplier"`
3434
}
3535

36-
// Channel is a named FHIR delivery destination with its own delivery policy.
36+
// OutputType controls what format/protocol is used when delivering messages.
37+
type OutputType string
38+
39+
const (
40+
// OutputFHIR delivers the mapped FHIR Bundle as JSON via HTTP POST (default).
41+
OutputFHIR OutputType = "fhir"
42+
// OutputHL7Passthrough delivers the original raw HL7v2 message via MLLP TCP.
43+
OutputHL7Passthrough OutputType = "hl7_passthrough"
44+
)
45+
46+
// Channel is a named delivery destination with its own delivery policy.
3747
type Channel struct {
3848
// ID is the unique identifier for this channel (URL-safe string).
3949
ID string `yaml:"id" json:"id"`
4050
// Name is a human-readable label.
4151
Name string `yaml:"name" json:"name"`
42-
// URL is the target FHIR server endpoint.
52+
// OutputType controls the output format and protocol. Defaults to "fhir".
53+
OutputType OutputType `yaml:"output_type" json:"output_type"`
54+
// URL is the target endpoint.
55+
// For fhir: HTTP/HTTPS URL of the FHIR server.
56+
// For hl7_passthrough: host:port of the MLLP destination (e.g. "hl7.example.com:2575").
4357
URL string `yaml:"url" json:"url"`
44-
// AuthHeader is the value sent in the Authorization header (optional).
58+
// AuthHeader is the value sent in the Authorization header (optional, FHIR only).
4559
AuthHeader string `yaml:"auth_header" json:"auth_header,omitempty"`
46-
// TimeoutMS is the HTTP client timeout in milliseconds (default 10 000).
60+
// TimeoutMS is the client timeout in milliseconds (default 10 000).
4761
TimeoutMS int `yaml:"timeout_ms" json:"timeout_ms"`
4862
// MinQualityScore is the minimum quality score [0,1] required to deliver.
4963
// Messages scoring below this threshold are dead-lettered without sending.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Package mllp provides an MLLP destination adapter that delivers the original
2+
// raw HL7v2 message to a remote system over a TCP connection using MLLP framing.
3+
//
4+
// The adapter opens a new TCP connection per Send call so it is safe for
5+
// concurrent use and requires no persistent connection management. For
6+
// high-throughput scenarios a connection pool can be added later without
7+
// changing the Sender interface.
8+
//
9+
// MLLP framing:
10+
//
11+
// VT (0x0B) <HL7 payload bytes> FS (0x1C) CR (0x0D)
12+
package mllp
13+
14+
import (
15+
"context"
16+
"fmt"
17+
"io"
18+
"net"
19+
"time"
20+
21+
"github.com/vagnercazarotto/verifhir-gateway/internal/model"
22+
)
23+
24+
const (
25+
vt = byte(0x0B)
26+
fs = byte(0x1C)
27+
cr = byte(0x0D)
28+
defaultTimeout = 10 * time.Second
29+
ackBufSize = 4096
30+
)
31+
32+
// Config holds the runtime settings for the MLLP destination adapter.
33+
type Config struct {
34+
// Addr is the host:port of the remote MLLP listener, e.g. "hl7.example.com:2575".
35+
Addr string
36+
37+
// Timeout overrides the default 10-second dial+write+ack deadline.
38+
// Zero means use the default.
39+
Timeout time.Duration
40+
}
41+
42+
// Adapter sends raw HL7v2 messages to a remote MLLP listener.
43+
type Adapter struct {
44+
cfg Config
45+
timeout time.Duration
46+
// dial is injectable for testing; defaults to net.DialTimeout.
47+
dial func(network, addr string, timeout time.Duration) (net.Conn, error)
48+
}
49+
50+
// New creates an Adapter from cfg.
51+
func New(cfg Config) *Adapter {
52+
t := cfg.Timeout
53+
if t <= 0 {
54+
t = defaultTimeout
55+
}
56+
return &Adapter{
57+
cfg: cfg,
58+
timeout: t,
59+
dial: net.DialTimeout,
60+
}
61+
}
62+
63+
// Send wraps payload.RawHL7 in MLLP framing and delivers it to the configured
64+
// address, then reads and validates the ACK response.
65+
// It returns an error if RawHL7 is empty, the connection fails, or the remote
66+
// side responds with a NACK (MSA-1 = "AE" or "AR").
67+
func (a *Adapter) Send(ctx context.Context, payload model.RoutedPayload) error {
68+
raw := payload.RawHL7
69+
if raw == "" {
70+
return fmt.Errorf("mllp adapter: RawHL7 is empty for msg %s", payload.Resource.ID)
71+
}
72+
73+
// Respect context deadline.
74+
deadline := time.Now().Add(a.timeout)
75+
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
76+
deadline = d
77+
}
78+
79+
conn, err := a.dial("tcp", a.cfg.Addr, time.Until(deadline))
80+
if err != nil {
81+
return fmt.Errorf("mllp adapter: dial %s: %w", a.cfg.Addr, err)
82+
}
83+
defer conn.Close()
84+
85+
if err := conn.SetDeadline(deadline); err != nil {
86+
return fmt.Errorf("mllp adapter: set deadline: %w", err)
87+
}
88+
89+
// Write MLLP-framed message.
90+
frame := make([]byte, 0, 3+len(raw)+2)
91+
frame = append(frame, vt)
92+
frame = append(frame, []byte(raw)...)
93+
frame = append(frame, fs, cr)
94+
95+
if _, err := conn.Write(frame); err != nil {
96+
return fmt.Errorf("mllp adapter: write: %w", err)
97+
}
98+
99+
// Read ACK response.
100+
ack, err := readACK(conn)
101+
if err != nil {
102+
return fmt.Errorf("mllp adapter: read ack: %w", err)
103+
}
104+
if isNACK(ack) {
105+
return fmt.Errorf("mllp adapter: received NACK from %s: %s", a.cfg.Addr, ack)
106+
}
107+
return nil
108+
}
109+
110+
// readACK reads an MLLP-framed response from conn and returns the payload.
111+
func readACK(conn net.Conn) (string, error) {
112+
buf := make([]byte, 1)
113+
// Advance to VT.
114+
for {
115+
if _, err := io.ReadFull(conn, buf); err != nil {
116+
return "", err
117+
}
118+
if buf[0] == vt {
119+
break
120+
}
121+
}
122+
var payload []byte
123+
for len(payload) < ackBufSize {
124+
if _, err := io.ReadFull(conn, buf); err != nil {
125+
return "", err
126+
}
127+
if buf[0] == fs {
128+
break
129+
}
130+
payload = append(payload, buf[0])
131+
}
132+
// Consume trailing CR (best-effort; ignore error).
133+
_, _ = io.ReadFull(conn, buf)
134+
return string(payload), nil
135+
}
136+
137+
// isNACK returns true when the ACK message contains MSA-1 = "AE" or "AR".
138+
// This is a minimal check on the MSA segment field 1.
139+
func isNACK(ack string) bool {
140+
// Look for MSA segment and read field 1 (after first |).
141+
const prefix = "MSA|"
142+
idx := 0
143+
for i := 0; i+len(prefix) <= len(ack); i++ {
144+
if ack[i:i+len(prefix)] == prefix {
145+
idx = i + len(prefix)
146+
// Field 1 is the next 2 characters before the next |.
147+
end := idx
148+
for end < len(ack) && ack[end] != '|' && ack[end] != '\r' && ack[end] != '\n' {
149+
end++
150+
}
151+
code := ack[idx:end]
152+
return code == "AE" || code == "AR"
153+
}
154+
}
155+
return false
156+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package mllp
2+
3+
import (
4+
"context"
5+
"net"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/vagnercazarotto/verifhir-gateway/internal/model"
11+
)
12+
13+
// ---- helpers ---------------------------------------------------------------
14+
15+
func makeACK(code string) []byte {
16+
ack := "MSH|^~\\&|TEST|TEST|SENDER|SENDER|20260101120000||ACK^A01|1|P|2.5\rMSA|" + code + "|1\r"
17+
out := make([]byte, 0, len(ack)+3)
18+
out = append(out, vt)
19+
out = append(out, []byte(ack)...)
20+
out = append(out, fs, cr)
21+
return out
22+
}
23+
24+
func serveOnce(t *testing.T, response []byte) (addr string, done <-chan struct{}) {
25+
t.Helper()
26+
ln, err := net.Listen("tcp", "127.0.0.1:0")
27+
if err != nil {
28+
t.Fatalf("listen: %v", err)
29+
}
30+
ch := make(chan struct{})
31+
go func() {
32+
defer close(ch)
33+
defer ln.Close()
34+
conn, err := ln.Accept()
35+
if err != nil {
36+
return
37+
}
38+
defer conn.Close()
39+
buf := make([]byte, 4096)
40+
conn.Read(buf) // consume inbound frame
41+
conn.Write(response)
42+
}()
43+
return ln.Addr().String(), ch
44+
}
45+
46+
func samplePayload(raw string) model.RoutedPayload {
47+
return model.RoutedPayload{
48+
Resource: model.FHIRResource{ID: "msg-001"},
49+
RawHL7: raw,
50+
}
51+
}
52+
53+
const sampleHL7 = "MSH|^~\\&|SEND|SEND|RECV|RECV|20260101120000||ADT^A01|1|P|2.5\r" +
54+
"PID|1||P001^^^MRN||Doe^John\r"
55+
56+
// ---- tests -----------------------------------------------------------------
57+
58+
func TestSendACK(t *testing.T) {
59+
addr, done := serveOnce(t, makeACK("AA"))
60+
a := New(Config{Addr: addr, Timeout: 3 * time.Second})
61+
if err := a.Send(context.Background(), samplePayload(sampleHL7)); err != nil {
62+
t.Fatalf("unexpected error: %v", err)
63+
}
64+
<-done
65+
}
66+
67+
func TestSendNACK_AE(t *testing.T) {
68+
addr, done := serveOnce(t, makeACK("AE"))
69+
a := New(Config{Addr: addr, Timeout: 3 * time.Second})
70+
err := a.Send(context.Background(), samplePayload(sampleHL7))
71+
if err == nil {
72+
t.Fatal("expected error for AE NACK")
73+
}
74+
if !strings.Contains(err.Error(), "NACK") {
75+
t.Errorf("error should mention NACK, got: %v", err)
76+
}
77+
<-done
78+
}
79+
80+
func TestSendNACK_AR(t *testing.T) {
81+
addr, done := serveOnce(t, makeACK("AR"))
82+
a := New(Config{Addr: addr, Timeout: 3 * time.Second})
83+
err := a.Send(context.Background(), samplePayload(sampleHL7))
84+
if err == nil {
85+
t.Fatal("expected error for AR NACK")
86+
}
87+
<-done
88+
}
89+
90+
func TestSendEmptyRawHL7(t *testing.T) {
91+
a := New(Config{Addr: "127.0.0.1:9999", Timeout: time.Second})
92+
err := a.Send(context.Background(), samplePayload(""))
93+
if err == nil {
94+
t.Fatal("expected error when RawHL7 is empty")
95+
}
96+
if !strings.Contains(err.Error(), "RawHL7 is empty") {
97+
t.Errorf("unexpected error: %v", err)
98+
}
99+
}
100+
101+
func TestSendDialFailure(t *testing.T) {
102+
// Port 1 is reserved and will always fail to connect.
103+
a := New(Config{Addr: "127.0.0.1:1", Timeout: 500 * time.Millisecond})
104+
err := a.Send(context.Background(), samplePayload(sampleHL7))
105+
if err == nil {
106+
t.Fatal("expected dial error")
107+
}
108+
}
109+
110+
func TestSendContextCancelled(t *testing.T) {
111+
ctx, cancel := context.WithCancel(context.Background())
112+
cancel() // already cancelled
113+
// Point at a non-routable address so the dial races the deadline.
114+
a := New(Config{Addr: "192.0.2.1:2575", Timeout: 100 * time.Millisecond})
115+
err := a.Send(ctx, samplePayload(sampleHL7))
116+
if err == nil {
117+
t.Fatal("expected error with cancelled context")
118+
}
119+
}
120+
121+
func TestIsNACK(t *testing.T) {
122+
cases := []struct {
123+
ack string
124+
want bool
125+
}{
126+
{"MSH|...\rMSA|AA|1\r", false},
127+
{"MSH|...\rMSA|AE|1\r", true},
128+
{"MSH|...\rMSA|AR|1\r", true},
129+
{"MSH|...\rMSA|CA|1\r", false},
130+
{"", false},
131+
}
132+
for _, c := range cases {
133+
if got := isNACK(c.ack); got != c.want {
134+
t.Errorf("isNACK(%q) = %v, want %v", c.ack, got, c.want)
135+
}
136+
}
137+
}

internal/model/types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,7 @@ type MDMResult struct {
199199
type RoutedPayload struct {
200200
Resource FHIRResource
201201
Quality QualityReport
202+
// RawHL7 holds the original HL7v2 message text. It is populated by the
203+
// ingest stage and used by hl7_passthrough channels.
204+
RawHL7 string
202205
}

internal/router/router.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/vagnercazarotto/verifhir-gateway/internal/channel"
1616
"github.com/vagnercazarotto/verifhir-gateway/internal/connector/destination/dlq"
1717
httpdest "github.com/vagnercazarotto/verifhir-gateway/internal/connector/destination/http"
18+
mllpdest "github.com/vagnercazarotto/verifhir-gateway/internal/connector/destination/mllp"
1819
"github.com/vagnercazarotto/verifhir-gateway/internal/connector/destination/retry"
1920
"github.com/vagnercazarotto/verifhir-gateway/internal/model"
2021
)
@@ -178,14 +179,25 @@ func (r *Router) senderFor(ch channel.Channel) Sender {
178179
return sender
179180
}
180181

181-
// defaultBuilder constructs a retry-wrapped HTTP adapter from a Channel.
182+
// defaultBuilder constructs a retry-wrapped sender from a Channel.
183+
// For fhir channels it wraps an HTTP adapter; for hl7_passthrough channels
184+
// it wraps an MLLP client adapter.
182185
func defaultBuilder(ch channel.Channel) Sender {
183-
adapter := httpdest.New(httpdest.Config{
184-
URL: ch.URL,
185-
Timeout: ch.Timeout(),
186-
AuthHeader: ch.AuthHeader,
187-
})
188-
return retry.New(adapter, retry.Config{
186+
var inner retry.Sender
187+
switch ch.OutputType {
188+
case channel.OutputHL7Passthrough:
189+
inner = mllpdest.New(mllpdest.Config{
190+
Addr: ch.URL,
191+
Timeout: ch.Timeout(),
192+
})
193+
default: // OutputFHIR and any unrecognised value
194+
inner = httpdest.New(httpdest.Config{
195+
URL: ch.URL,
196+
Timeout: ch.Timeout(),
197+
AuthHeader: ch.AuthHeader,
198+
})
199+
}
200+
return retry.New(inner, retry.Config{
189201
MaxAttempts: ch.Retry.MaxAttempts,
190202
InitialBackoff: time.Duration(ch.Retry.InitialBackoffMS) * time.Millisecond,
191203
Multiplier: ch.Retry.Multiplier,

0 commit comments

Comments
 (0)