Skip to content

Commit 1d9dc9b

Browse files
Add pipelines and sources management
Introduce pipeline support and a sources UI: add Pipeline and PipelineRegistry types with thread-safe CRUD methods in the channel package, wire a pipeline registry into the gateway, and extend the REST server with full pipeline routes (list/create/get/update/delete). Update server constructor and tests to accept the new pipeline registry. On the frontend add Source and Pipeline types, API client methods for sources and pipelines, new pages for Pipelines and Sources (including modals/forms, listing, create/update/delete and toggles), route entries in App, and sidebar icons/links. Enables managing sources and routing pipelines via REST + web UI.
1 parent 3e64cc0 commit 1d9dc9b

10 files changed

Lines changed: 1010 additions & 26 deletions

File tree

cmd/gateway/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ func main() {
4848
reg := channel.NewRegistry()
4949
// Source registry — load from YAML if configured.
5050
sourceReg := channel.NewSourceRegistry()
51+
// Pipeline registry — starts empty; managed via REST API.
52+
pipelineReg := channel.NewPipelineRegistry()
5153
if cfg.ChannelsFile != "" {
5254
if err := channel.LoadFile(cfg.ChannelsFile, reg); err != nil {
5355
log.Printf("[gateway] channels: %v (continuing with empty registry)", err)
@@ -70,7 +72,7 @@ func main() {
7072
// HTTP REST API
7173
httpSrv := &http.Server{
7274
Addr: ":" + cfg.HTTPPort,
73-
Handler: rest.New(st, reg, sourceReg).WithAuditDir(cfg.AuditDir),
75+
Handler: rest.New(st, reg, sourceReg, pipelineReg).WithAuditDir(cfg.AuditDir),
7476
}
7577
go func() {
7678
log.Printf("[gateway] REST API listening on :%s", cfg.HTTPPort)

internal/api/rest/server.go

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,25 @@
22
//
33
// Endpoints:
44
//
5-
// GET /api/v1/messages — list messages (query: status, limit)
6-
// GET /api/v1/messages/{id} — get a single message by ID
7-
// GET /api/v1/channels — list channels
8-
// POST /api/v1/channels — create channel
9-
// GET /api/v1/channels/{id} — get channel by ID
10-
// PUT /api/v1/channels/{id} — update channel
11-
// 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
17-
// GET /healthz — liveness probe
18-
// GET /readyz — readiness probe (checks store)
5+
// GET /api/v1/messages — list messages (query: status, limit)
6+
// GET /api/v1/messages/{id} — get a single message by ID
7+
// GET /api/v1/channels — list channels
8+
// POST /api/v1/channels — create channel
9+
// GET /api/v1/channels/{id} — get channel by ID
10+
// PUT /api/v1/channels/{id} — update channel
11+
// 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
17+
// GET /api/v1/pipelines — list pipelines
18+
// POST /api/v1/pipelines — create pipeline
19+
// GET /api/v1/pipelines/{id} — get pipeline by ID
20+
// PUT /api/v1/pipelines/{id} — update pipeline
21+
// DELETE /api/v1/pipelines/{id} — delete pipeline
22+
// GET /healthz — liveness probe
23+
// GET /readyz — readiness probe (checks store)
1924
//
2025
// All responses are JSON. Error bodies have the shape {"error":"reason"}.
2126
// The server uses Go 1.22 enhanced ServeMux patterns, so unknown methods
@@ -61,16 +66,17 @@ type MessageResponse struct {
6166
// Server is an http.Handler that exposes the message store and channel
6267
// registry as a REST API.
6368
type Server struct {
64-
store store.Store
65-
channels *channel.Registry
66-
sources *channel.SourceRegistry
67-
auditDir string
68-
mux *http.ServeMux
69+
store store.Store
70+
channels *channel.Registry
71+
sources *channel.SourceRegistry
72+
pipelines *channel.PipelineRegistry
73+
auditDir string
74+
mux *http.ServeMux
6975
}
7076

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}
77+
// New creates a Server backed by st, reg, sourceReg and pipelineReg, and registers all routes.
78+
func New(st store.Store, reg *channel.Registry, sourceReg *channel.SourceRegistry, pipelineReg *channel.PipelineRegistry) *Server {
79+
s := &Server{store: st, channels: reg, sources: sourceReg, pipelines: pipelineReg}
7480
s.mux = http.NewServeMux()
7581
// message routes
7682
s.mux.HandleFunc("GET /api/v1/messages", s.listMessages)
@@ -87,6 +93,12 @@ func New(st store.Store, reg *channel.Registry, sourceReg *channel.SourceRegistr
8793
s.mux.HandleFunc("GET /api/v1/sources/{id}", s.getSource)
8894
s.mux.HandleFunc("PUT /api/v1/sources/{id}", s.updateSource)
8995
s.mux.HandleFunc("DELETE /api/v1/sources/{id}", s.deleteSource)
96+
// pipeline routes
97+
s.mux.HandleFunc("GET /api/v1/pipelines", s.listPipelines)
98+
s.mux.HandleFunc("POST /api/v1/pipelines", s.createPipeline)
99+
s.mux.HandleFunc("GET /api/v1/pipelines/{id}", s.getPipeline)
100+
s.mux.HandleFunc("PUT /api/v1/pipelines/{id}", s.updatePipeline)
101+
s.mux.HandleFunc("DELETE /api/v1/pipelines/{id}", s.deletePipeline)
90102
// audit + reports routes
91103
s.mux.HandleFunc("GET /api/v1/audit", s.listAudit)
92104
s.mux.HandleFunc("GET /api/v1/reports", s.getReports)
@@ -358,6 +370,94 @@ func (s *Server) deleteSource(w http.ResponseWriter, r *http.Request) {
358370
w.WriteHeader(http.StatusNoContent)
359371
}
360372

373+
// ---- pipeline handlers -----------------------------------------------------
374+
375+
// listPipelines handles GET /api/v1/pipelines
376+
func (s *Server) listPipelines(w http.ResponseWriter, r *http.Request) {
377+
writeJSON(w, http.StatusOK, s.pipelines.List())
378+
}
379+
380+
// createPipeline handles POST /api/v1/pipelines
381+
func (s *Server) createPipeline(w http.ResponseWriter, r *http.Request) {
382+
var p channel.Pipeline
383+
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
384+
writeError(w, http.StatusBadRequest, "invalid JSON body")
385+
return
386+
}
387+
if p.ID == "" {
388+
writeError(w, http.StatusBadRequest, "id is required")
389+
return
390+
}
391+
if p.Name == "" {
392+
writeError(w, http.StatusBadRequest, "name is required")
393+
return
394+
}
395+
if err := s.pipelines.Add(p); err != nil {
396+
if errors.Is(err, channel.ErrDuplicateID) {
397+
writeError(w, http.StatusConflict, "pipeline ID already exists")
398+
return
399+
}
400+
writeError(w, http.StatusInternalServerError, "failed to create pipeline")
401+
return
402+
}
403+
got, _ := s.pipelines.Get(p.ID)
404+
writeJSON(w, http.StatusCreated, got)
405+
}
406+
407+
// getPipeline handles GET /api/v1/pipelines/{id}
408+
func (s *Server) getPipeline(w http.ResponseWriter, r *http.Request) {
409+
id := r.PathValue("id")
410+
p, err := s.pipelines.Get(id)
411+
if err != nil {
412+
if errors.Is(err, channel.ErrNotFound) {
413+
writeError(w, http.StatusNotFound, "pipeline not found")
414+
return
415+
}
416+
writeError(w, http.StatusInternalServerError, "failed to retrieve pipeline")
417+
return
418+
}
419+
writeJSON(w, http.StatusOK, p)
420+
}
421+
422+
// updatePipeline handles PUT /api/v1/pipelines/{id}
423+
func (s *Server) updatePipeline(w http.ResponseWriter, r *http.Request) {
424+
id := r.PathValue("id")
425+
var p channel.Pipeline
426+
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
427+
writeError(w, http.StatusBadRequest, "invalid JSON body")
428+
return
429+
}
430+
p.ID = id // path wins over body
431+
if p.Name == "" {
432+
writeError(w, http.StatusBadRequest, "name is required")
433+
return
434+
}
435+
if err := s.pipelines.Update(p); err != nil {
436+
if errors.Is(err, channel.ErrNotFound) {
437+
writeError(w, http.StatusNotFound, "pipeline not found")
438+
return
439+
}
440+
writeError(w, http.StatusInternalServerError, "failed to update pipeline")
441+
return
442+
}
443+
got, _ := s.pipelines.Get(id)
444+
writeJSON(w, http.StatusOK, got)
445+
}
446+
447+
// deletePipeline handles DELETE /api/v1/pipelines/{id}
448+
func (s *Server) deletePipeline(w http.ResponseWriter, r *http.Request) {
449+
id := r.PathValue("id")
450+
if err := s.pipelines.Delete(id); err != nil {
451+
if errors.Is(err, channel.ErrNotFound) {
452+
writeError(w, http.StatusNotFound, "pipeline not found")
453+
return
454+
}
455+
writeError(w, http.StatusInternalServerError, "failed to delete pipeline")
456+
return
457+
}
458+
w.WriteHeader(http.StatusNoContent)
459+
}
460+
361461
// listAudit handles GET /api/v1/audit
362462
//
363463
// 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(), channel.NewSourceRegistry())
92+
return rest.New(st, channel.NewRegistry(), channel.NewSourceRegistry(), channel.NewPipelineRegistry())
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, channel.NewSourceRegistry())
97+
return rest.New(st, reg, channel.NewSourceRegistry(), channel.NewPipelineRegistry())
9898
}
9999

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

internal/channel/channel.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,121 @@ func (r *SourceRegistry) Len() int {
285285
defer r.mu.RUnlock()
286286
return len(r.sources)
287287
}
288+
289+
// ---- Pipelines -------------------------------------------------------------
290+
291+
// PipelineFilters defines message-level filter criteria applied before delivery.
292+
type PipelineFilters struct {
293+
// EventTypes limits processing to the listed HL7 event types (e.g. "ADT^A01").
294+
// An empty slice means accept all event types.
295+
EventTypes []string `yaml:"event_types" json:"event_types,omitempty"`
296+
// MinScore is the minimum quality score [0,1] required to proceed.
297+
// Messages scoring below this threshold are discarded without delivery.
298+
MinScore float64 `yaml:"min_score" json:"min_score"`
299+
}
300+
301+
// Pipeline routes messages from one source through filters to one or more
302+
// delivery channels.
303+
type Pipeline struct {
304+
// ID is the unique identifier for this pipeline (URL-safe string).
305+
ID string `yaml:"id" json:"id"`
306+
// Name is a human-readable label.
307+
Name string `yaml:"name" json:"name"`
308+
// SourceID is the ID of the SourceConfig that feeds this pipeline.
309+
// An empty string means accept messages from any source.
310+
SourceID string `yaml:"source_id" json:"source_id,omitempty"`
311+
// Filters defines optional message-level criteria.
312+
Filters PipelineFilters `yaml:"filters" json:"filters"`
313+
// DestinationIDs is the list of Channel IDs to deliver to.
314+
DestinationIDs []string `yaml:"destination_ids" json:"destination_ids,omitempty"`
315+
// Enabled controls whether this pipeline participates in routing.
316+
Enabled bool `yaml:"enabled" json:"enabled"`
317+
// CreatedAt is set when the pipeline is first registered.
318+
CreatedAt time.Time `json:"created_at"`
319+
// UpdatedAt is refreshed on every update.
320+
UpdatedAt time.Time `json:"updated_at"`
321+
}
322+
323+
// PipelineRegistry is a thread-safe, in-memory store of Pipelines.
324+
type PipelineRegistry struct {
325+
mu sync.RWMutex
326+
pipelines map[string]Pipeline
327+
now func() time.Time
328+
}
329+
330+
// NewPipelineRegistry creates an empty PipelineRegistry.
331+
func NewPipelineRegistry() *PipelineRegistry {
332+
return &PipelineRegistry{
333+
pipelines: make(map[string]Pipeline),
334+
now: time.Now,
335+
}
336+
}
337+
338+
// Add inserts a new pipeline. Returns ErrDuplicateID if the ID already exists.
339+
func (r *PipelineRegistry) Add(p Pipeline) error {
340+
r.mu.Lock()
341+
defer r.mu.Unlock()
342+
if _, exists := r.pipelines[p.ID]; exists {
343+
return ErrDuplicateID
344+
}
345+
now := r.now()
346+
p.CreatedAt = now
347+
p.UpdatedAt = now
348+
r.pipelines[p.ID] = p
349+
return nil
350+
}
351+
352+
// Update replaces an existing pipeline. Returns ErrNotFound if the ID does not
353+
// exist. CreatedAt is preserved; UpdatedAt is refreshed.
354+
func (r *PipelineRegistry) Update(p Pipeline) error {
355+
r.mu.Lock()
356+
defer r.mu.Unlock()
357+
existing, exists := r.pipelines[p.ID]
358+
if !exists {
359+
return ErrNotFound
360+
}
361+
p.CreatedAt = existing.CreatedAt
362+
p.UpdatedAt = r.now()
363+
r.pipelines[p.ID] = p
364+
return nil
365+
}
366+
367+
// Delete removes a pipeline by ID. Returns ErrNotFound if the ID does not exist.
368+
func (r *PipelineRegistry) Delete(id string) error {
369+
r.mu.Lock()
370+
defer r.mu.Unlock()
371+
if _, exists := r.pipelines[id]; !exists {
372+
return ErrNotFound
373+
}
374+
delete(r.pipelines, id)
375+
return nil
376+
}
377+
378+
// Get returns a copy of the pipeline with the given ID.
379+
func (r *PipelineRegistry) Get(id string) (Pipeline, error) {
380+
r.mu.RLock()
381+
defer r.mu.RUnlock()
382+
p, exists := r.pipelines[id]
383+
if !exists {
384+
return Pipeline{}, ErrNotFound
385+
}
386+
return p, nil
387+
}
388+
389+
// List returns a copy of all pipelines. Order is not guaranteed.
390+
func (r *PipelineRegistry) List() []Pipeline {
391+
r.mu.RLock()
392+
defer r.mu.RUnlock()
393+
out := make([]Pipeline, 0, len(r.pipelines))
394+
for _, p := range r.pipelines {
395+
out = append(out, p)
396+
}
397+
return out
398+
}
399+
400+
// Len returns the number of registered pipelines.
401+
func (r *PipelineRegistry) Len() int {
402+
r.mu.RLock()
403+
defer r.mu.RUnlock()
404+
return len(r.pipelines)
405+
}

web/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { ToastProvider } from './components/Toast'
33
import Layout from './components/Layout'
44
import Dashboard from './pages/Dashboard'
55
import Channels from './pages/Channels'
6+
import Sources from './pages/Sources'
7+
import Pipelines from './pages/Pipelines'
68
import Messages from './pages/Messages'
79
import AuditLog from './pages/AuditLog'
810
import Reports from './pages/Reports'
@@ -16,6 +18,8 @@ export default function App() {
1618
<Route index element={<Navigate to="/dashboard" replace />} />
1719
<Route path="dashboard" element={<Dashboard />} />
1820
<Route path="channels" element={<Channels />} />
21+
<Route path="sources" element={<Sources />} />
22+
<Route path="pipelines" element={<Pipelines />} />
1923
<Route path="messages" element={<Messages />} />
2024
<Route path="messages/:id" element={<Messages />} />
2125
<Route path="audit" element={<AuditLog />} />

web/src/api/client.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// During local dev Vite proxies /api, /healthz, /readyz to localhost:8080.
33
// In production nginx does the same proxy.
44

5-
import type { AuditEntry, Channel, HealthResponse, Message, MessageStatus, ReportSummary } from '../types'
5+
import type { AuditEntry, Channel, HealthResponse, Message, MessageStatus, Pipeline, ReportSummary, Source } from '../types'
66

77
async function request<T>(path: string, init?: RequestInit): Promise<T> {
88
const res = await fetch(path, {
@@ -46,6 +46,40 @@ export const api = {
4646
request<void>(`/api/v1/channels/${id}`, { method: 'DELETE' }),
4747
},
4848

49+
sources: {
50+
list: () => request<Source[]>('/api/v1/sources'),
51+
get: (id: string) => request<Source>(`/api/v1/sources/${id}`),
52+
create: (src: Omit<Source, 'created_at' | 'updated_at'>) =>
53+
request<Source>('/api/v1/sources', {
54+
method: 'POST',
55+
body: JSON.stringify(src),
56+
}),
57+
update: (id: string, src: Partial<Omit<Source, 'created_at' | 'updated_at'>>) =>
58+
request<Source>(`/api/v1/sources/${id}`, {
59+
method: 'PUT',
60+
body: JSON.stringify(src),
61+
}),
62+
delete: (id: string) =>
63+
request<void>(`/api/v1/sources/${id}`, { method: 'DELETE' }),
64+
},
65+
66+
pipelines: {
67+
list: () => request<Pipeline[]>('/api/v1/pipelines'),
68+
get: (id: string) => request<Pipeline>(`/api/v1/pipelines/${id}`),
69+
create: (p: Omit<Pipeline, 'created_at' | 'updated_at'>) =>
70+
request<Pipeline>('/api/v1/pipelines', {
71+
method: 'POST',
72+
body: JSON.stringify(p),
73+
}),
74+
update: (id: string, p: Partial<Omit<Pipeline, 'created_at' | 'updated_at'>>) =>
75+
request<Pipeline>(`/api/v1/pipelines/${id}`, {
76+
method: 'PUT',
77+
body: JSON.stringify(p),
78+
}),
79+
delete: (id: string) =>
80+
request<void>(`/api/v1/pipelines/${id}`, { method: 'DELETE' }),
81+
},
82+
4983
health: {
5084
liveness: () => request<HealthResponse>('/healthz'),
5185
readiness: () => request<HealthResponse>('/readyz'),

0 commit comments

Comments
 (0)