Skip to content

Commit 3e64cc0

Browse files
Add multi-source ingest and source registry
Introduce multi-source ingest support: add SourceConfig and SourceRegistry (in-memory) with YAML loader functions; extend channel YAML to include "sources". Stamp SourceID on HL7Message and RoutedPayload, update MLLP server to accept a sourceID and launch one listener per enabled source in main (with backward-compatible single-listener fallback). Add REST endpoints for CRUD of sources and wire the source registry into the HTTP server. Add channel.SourceIDs filter and router logic to skip channels not matching the message SourceID. Update tests and docs/PROJECT-SCOPE to reflect Phase 4.5 multi-source work; use sync.WaitGroup for concurrent MLLP listeners and ensure graceful handling.
1 parent e24b69a commit 3e64cc0

10 files changed

Lines changed: 385 additions & 35 deletions

File tree

cmd/gateway/main.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"os"
99
"os/signal"
10+
"sync"
1011
"syscall"
1112
"time"
1213

@@ -45,10 +46,15 @@ func main() {
4546

4647
// Channel registry — load from YAML if configured.
4748
reg := channel.NewRegistry()
49+
// Source registry — load from YAML if configured.
50+
sourceReg := channel.NewSourceRegistry()
4851
if cfg.ChannelsFile != "" {
4952
if err := channel.LoadFile(cfg.ChannelsFile, reg); err != nil {
5053
log.Printf("[gateway] channels: %v (continuing with empty registry)", err)
5154
}
55+
if err := channel.LoadSourcesFile(cfg.ChannelsFile, sourceReg); err != nil {
56+
log.Printf("[gateway] sources: %v (continuing with empty source registry)", err)
57+
}
5258
}
5359

5460
// Dead-letter writer for delivery failures (one shared dir for the gateway).
@@ -64,7 +70,7 @@ func main() {
6470
// HTTP REST API
6571
httpSrv := &http.Server{
6672
Addr: ":" + cfg.HTTPPort,
67-
Handler: rest.New(st, reg).WithAuditDir(cfg.AuditDir),
73+
Handler: rest.New(st, reg, sourceReg).WithAuditDir(cfg.AuditDir),
6874
}
6975
go func() {
7076
log.Printf("[gateway] REST API listening on :%s", cfg.HTTPPort)
@@ -73,12 +79,44 @@ func main() {
7379
}
7480
}()
7581

76-
// MLLP listener (blocks until ctx cancelled)
77-
mllpSrv := mllp.New(cfg.MLLPAddr, makePipeline(rtr, st))
78-
if err := mllpSrv.ListenAndServe(ctx); err != nil {
79-
log.Printf("[gateway] mllp: %v", err)
82+
// Launch one MLLP listener goroutine per enabled source defined in YAML.
83+
// If no sources are defined, fall back to the single legacy MLLPAddr.
84+
sources := sourceReg.List()
85+
activeSources := make([]channel.SourceConfig, 0, len(sources))
86+
for _, src := range sources {
87+
if src.Enabled && src.Type == channel.SourceMLLP {
88+
activeSources = append(activeSources, src)
89+
}
90+
}
91+
92+
if len(activeSources) == 0 {
93+
// Backward-compat: no sources configured in YAML → use the env-var address.
94+
activeSources = []channel.SourceConfig{{
95+
ID: "default",
96+
Name: "Default MLLP Listener",
97+
Type: channel.SourceMLLP,
98+
Addr: cfg.MLLPAddr,
99+
Enabled: true,
100+
}}
80101
}
81102

103+
var wg sync.WaitGroup
104+
for _, src := range activeSources {
105+
src := src // capture loop variable
106+
wg.Add(1)
107+
go func() {
108+
defer wg.Done()
109+
srv := mllp.New(src.Addr, src.ID, makePipeline(rtr, st))
110+
log.Printf("[gateway] mllp source=%s (%s) listening on %s", src.ID, src.Name, src.Addr)
111+
if err := srv.ListenAndServe(ctx); err != nil {
112+
log.Printf("[gateway] mllp source=%s: %v", src.ID, err)
113+
}
114+
}()
115+
}
116+
117+
// Block until all MLLP listeners have stopped (ctx cancelled → listeners exit).
118+
wg.Wait()
119+
82120
// Graceful HTTP shutdown
83121
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
84122
defer cancel()
@@ -149,7 +187,7 @@ func makePipeline(rtr *router.Router, st store.Store) func(model.HL7Message) err
149187
Findings: len(report.Findings),
150188
})
151189

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

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

docs/PROJECT-SCOPE.md

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,59 @@ services:
124124

125125
---
126126

127-
### Phase 4 — Multi-Message Type Support
128-
129-
- ORM^O01 (order messages)
130-
- ORU^R01 (lab results / observations)
131-
- SIU^S12 (scheduling)
132-
- MDM^T02 (clinical documents)
133-
- Pluggable mapping rules per message type
134-
- Extended quality rules per message type
127+
### Phase 4 — Multi-Message Type Support ✅ Complete
128+
129+
- [x] ORM^O01 (order messages) → FHIR R4 ServiceRequest
130+
- [x] ORU^R01 (lab results / observations) → FHIR R4 DiagnosticReport + Observation
131+
- [x] SIU^S12 (scheduling) → FHIR R4 Appointment
132+
- [x] MDM^T02 (clinical documents) → FHIR R4 DocumentReference
133+
- [x] Pluggable mapping rules per message type (dispatcher by MSH-9.1)
134+
- [x] Extended quality rules per message type
135+
136+
**Also completed (outside original scope):**
137+
- [x] HL7 passthrough output — channels can deliver raw HL7v2 via MLLP TCP instead of FHIR JSON
138+
- New `output_type: fhir | hl7_passthrough` field on Channel (YAML + REST API + UI)
139+
- MLLP client destination adapter with VT/FS/CR framing, ACK/NACK detection, timeout
140+
- Fanout already works: one message → FHIR channel + HL7 passthrough channel simultaneously
141+
- [x] UI improvements: SVG icons, skeleton loading, auto-refresh Dashboard, Toast notifications,
142+
Channel confirm-delete modal, Message search, Output type badge in Channels table
143+
144+
---
145+
146+
### Phase 4.5 — Multi-Source / Multi-Pipeline (In Progress)
147+
148+
> **Goal:** a single gateway instance can listen on multiple MLLP ports and/or HTTP endpoints,
149+
> each bound to its own set of destination channels. Messages from source A only go to channels
150+
> configured for that source.
151+
152+
```
153+
[MLLP :2575 (EHR A)] ──→ Pipeline 1 ──→ [FHIR channel A1] [HL7 channel A2]
154+
[MLLP :2576 (EHR B)] ──→ Pipeline 2 ──→ [FHIR channel B1]
155+
[HTTP /ingest (API )] ──→ Pipeline 3 ──→ [FHIR channel C1] [HL7 channel C2]
156+
```
157+
158+
**Implementation roadmap:**
159+
160+
#### Phase A — Multiple MLLP Sources (current)
161+
- [ ] `SourceConfig` struct: `{ id, type: mllp|http|file, addr, tls_cert, tls_key }`
162+
- [ ] `sources:` list in channels YAML / REST API
163+
- [ ] `main.go` launches one `mllp.Server` goroutine per enabled source
164+
- [ ] `HL7Message.SourceID` carries which source received the message
165+
- [ ] Router filters channels by `SourceID` when set (empty = accept all)
166+
167+
**Success criteria:** two simultaneous MLLP listeners on different ports, each delivering
168+
to independent channel sets, verified by integration test.
169+
170+
#### Phase B — Pipeline Model
171+
- [ ] `Pipeline` struct: `{ id, name, source_id, filters{event_types, min_score}, destination_ids[] }`
172+
- [ ] Pipeline Registry (CRUD via REST API + YAML)
173+
- [ ] Router dispatches per pipeline instead of global channel list
174+
- [ ] UI: Pipeline Manager page (source + filters + destinations)
175+
176+
#### Phase C — HTTP Ingest Source
177+
- [ ] `POST /api/ingest/hl7` — accepts raw HL7v2 text body, feeds into pipeline
178+
- [ ] `POST /api/ingest/fhir` — accepts FHIR JSON, validates, routes to channels
179+
- [ ] Source authentication via API key (Phase 5 prerequisite)
135180
136181
---
137182

internal/api/rest/server.go

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
// GET /api/v1/channels/{id} — get channel by ID
1010
// PUT /api/v1/channels/{id} — update channel
1111
// DELETE /api/v1/channels/{id} — delete channel
12+
// GET /api/v1/sources — list sources
13+
// POST /api/v1/sources — create source
14+
// GET /api/v1/sources/{id} — get source by ID
15+
// PUT /api/v1/sources/{id} — update source
16+
// DELETE /api/v1/sources/{id} — delete source
1217
// GET /healthz — liveness probe
1318
// GET /readyz — readiness probe (checks store)
1419
//
@@ -58,13 +63,14 @@ type MessageResponse struct {
5863
type Server struct {
5964
store store.Store
6065
channels *channel.Registry
66+
sources *channel.SourceRegistry
6167
auditDir string
6268
mux *http.ServeMux
6369
}
6470

65-
// New creates a Server backed by st and reg, and registers all routes.
66-
func New(st store.Store, reg *channel.Registry) *Server {
67-
s := &Server{store: st, channels: reg}
71+
// New creates a Server backed by st, reg and sourceReg, and registers all routes.
72+
func New(st store.Store, reg *channel.Registry, sourceReg *channel.SourceRegistry) *Server {
73+
s := &Server{store: st, channels: reg, sources: sourceReg}
6874
s.mux = http.NewServeMux()
6975
// message routes
7076
s.mux.HandleFunc("GET /api/v1/messages", s.listMessages)
@@ -75,6 +81,12 @@ func New(st store.Store, reg *channel.Registry) *Server {
7581
s.mux.HandleFunc("GET /api/v1/channels/{id}", s.getChannel)
7682
s.mux.HandleFunc("PUT /api/v1/channels/{id}", s.updateChannel)
7783
s.mux.HandleFunc("DELETE /api/v1/channels/{id}", s.deleteChannel)
84+
// source routes
85+
s.mux.HandleFunc("GET /api/v1/sources", s.listSources)
86+
s.mux.HandleFunc("POST /api/v1/sources", s.createSource)
87+
s.mux.HandleFunc("GET /api/v1/sources/{id}", s.getSource)
88+
s.mux.HandleFunc("PUT /api/v1/sources/{id}", s.updateSource)
89+
s.mux.HandleFunc("DELETE /api/v1/sources/{id}", s.deleteSource)
7890
// audit + reports routes
7991
s.mux.HandleFunc("GET /api/v1/audit", s.listAudit)
8092
s.mux.HandleFunc("GET /api/v1/reports", s.getReports)
@@ -258,6 +270,94 @@ func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) {
258270
w.WriteHeader(http.StatusNoContent)
259271
}
260272

273+
// ---- source handlers -------------------------------------------------------
274+
275+
// listSources handles GET /api/v1/sources
276+
func (s *Server) listSources(w http.ResponseWriter, r *http.Request) {
277+
writeJSON(w, http.StatusOK, s.sources.List())
278+
}
279+
280+
// createSource handles POST /api/v1/sources
281+
func (s *Server) createSource(w http.ResponseWriter, r *http.Request) {
282+
var src channel.SourceConfig
283+
if err := json.NewDecoder(r.Body).Decode(&src); err != nil {
284+
writeError(w, http.StatusBadRequest, "invalid JSON body")
285+
return
286+
}
287+
if src.ID == "" {
288+
writeError(w, http.StatusBadRequest, "id is required")
289+
return
290+
}
291+
if src.Addr == "" {
292+
writeError(w, http.StatusBadRequest, "addr is required")
293+
return
294+
}
295+
if err := s.sources.Add(src); err != nil {
296+
if errors.Is(err, channel.ErrDuplicateID) {
297+
writeError(w, http.StatusConflict, "source ID already exists")
298+
return
299+
}
300+
writeError(w, http.StatusInternalServerError, "failed to create source")
301+
return
302+
}
303+
got, _ := s.sources.Get(src.ID)
304+
writeJSON(w, http.StatusCreated, got)
305+
}
306+
307+
// getSource handles GET /api/v1/sources/{id}
308+
func (s *Server) getSource(w http.ResponseWriter, r *http.Request) {
309+
id := r.PathValue("id")
310+
src, err := s.sources.Get(id)
311+
if err != nil {
312+
if errors.Is(err, channel.ErrNotFound) {
313+
writeError(w, http.StatusNotFound, "source not found")
314+
return
315+
}
316+
writeError(w, http.StatusInternalServerError, "failed to retrieve source")
317+
return
318+
}
319+
writeJSON(w, http.StatusOK, src)
320+
}
321+
322+
// updateSource handles PUT /api/v1/sources/{id}
323+
func (s *Server) updateSource(w http.ResponseWriter, r *http.Request) {
324+
id := r.PathValue("id")
325+
var src channel.SourceConfig
326+
if err := json.NewDecoder(r.Body).Decode(&src); err != nil {
327+
writeError(w, http.StatusBadRequest, "invalid JSON body")
328+
return
329+
}
330+
src.ID = id // path wins over body
331+
if src.Addr == "" {
332+
writeError(w, http.StatusBadRequest, "addr is required")
333+
return
334+
}
335+
if err := s.sources.Update(src); err != nil {
336+
if errors.Is(err, channel.ErrNotFound) {
337+
writeError(w, http.StatusNotFound, "source not found")
338+
return
339+
}
340+
writeError(w, http.StatusInternalServerError, "failed to update source")
341+
return
342+
}
343+
got, _ := s.sources.Get(id)
344+
writeJSON(w, http.StatusOK, got)
345+
}
346+
347+
// deleteSource handles DELETE /api/v1/sources/{id}
348+
func (s *Server) deleteSource(w http.ResponseWriter, r *http.Request) {
349+
id := r.PathValue("id")
350+
if err := s.sources.Delete(id); err != nil {
351+
if errors.Is(err, channel.ErrNotFound) {
352+
writeError(w, http.StatusNotFound, "source not found")
353+
return
354+
}
355+
writeError(w, http.StatusInternalServerError, "failed to delete source")
356+
return
357+
}
358+
w.WriteHeader(http.StatusNoContent)
359+
}
360+
261361
// listAudit handles GET /api/v1/audit
262362
//
263363
// Query parameters:

internal/api/rest/server_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,12 @@ func (m *mockStore) Close() error { return nil }
8989

9090
// newSrv builds a Server with the given store and an empty channel registry.
9191
func newSrv(st store.Store) *rest.Server {
92-
return rest.New(st, channel.NewRegistry())
92+
return rest.New(st, channel.NewRegistry(), channel.NewSourceRegistry())
9393
}
9494

9595
// newSrvWithReg builds a Server with the given store and registry.
9696
func newSrvWithReg(st store.Store, reg *channel.Registry) *rest.Server {
97-
return rest.New(st, reg)
97+
return rest.New(st, reg, channel.NewSourceRegistry())
9898
}
9999

100100
func get(srv http.Handler, path string) *httptest.ResponseRecorder {

0 commit comments

Comments
 (0)