Skip to content

Commit 3cdcd6b

Browse files
authored
Merge pull request #9 from NHSDigital/feat/gateway-listener
Implement gateway listener with add worklist item action handler
2 parents 6e612df + 0a141a3 commit 3cdcd6b

12 files changed

Lines changed: 613 additions & 9 deletions

File tree

.env.development

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Azure Relay Configuration
2+
AZURE_RELAY_NAMESPACE=manbrs-gateway-dev.servicebus.windows.net
3+
AZURE_RELAY_HYBRID_CONNECTION=name-of-your-choice-relay-test-hc
4+
AZURE_RELAY_KEY_NAME=RootManageSharedAccessKey
5+
AZURE_RELAY_SHARED_ACCESS_KEY=YOUR_SHARED_ACCESS_KEY_HERE
6+
7+
# MWL Server Configuration
8+
MWL_AET=SCREENING_MWL
9+
MWL_PORT=4243
10+
MWL_DB_PATH=/var/lib/pacs/worklist.db
11+
12+
# PACS Server Configuration
13+
PACS_AET=SCREENING_PACS
14+
PACS_PORT=4244
15+
PACS_STORAGE_PATH=/var/lib/pacs/storage
16+
PACS_DB_PATH=/var/lib/pacs/pacs.db
17+
18+
# General Configuration
19+
LOG_LEVEL=INFO

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ The PACS server provides C-STORE functionality for receiving medical images:
115115

116116
See [PACS documentation](docs/pacs/README.md) for detailed information.
117117

118+
### Relay Listener
119+
120+
The Relay Listener handles incoming messages from the cloud service via Azure Relay:
121+
- Listens on configured Hybrid Connection
122+
- Processes worklist actions (e.g., create worklist item)
123+
124+
See [Relay Listener documentation](docs/relay-listener/README.md) for details.
125+
118126
## Testing
119127

120128
This project uses:

compose.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,28 @@ services:
4545
timeout: 5s
4646
retries: 3
4747

48+
listener:
49+
build:
50+
context: .
51+
dockerfile: Dockerfile
52+
container_name: listener
53+
command: ["uv", "run", "python", "-m", "src.relay_listener"]
54+
volumes:
55+
- pacs-db:/var/lib/pacs
56+
environment:
57+
- AZURE_RELAY_NAMESPACE=${AZURE_RELAY_NAMESPACE}
58+
- AZURE_RELAY_HYBRID_CONNECTION=${AZURE_RELAY_HYBRID_CONNECTION}
59+
- AZURE_RELAY_KEY_NAME=${AZURE_RELAY_KEY_NAME}
60+
- AZURE_RELAY_SHARED_ACCESS_KEY=${AZURE_RELAY_SHARED_ACCESS_KEY}
61+
- DB_PATH=${DB_PATH:-/var/lib/pacs/worklist.db}
62+
- LOG_LEVEL=INFO
63+
restart: unless-stopped
64+
healthcheck:
65+
test: ["CMD", "sqlite3", "/var/lib/pacs/worklist.db", "SELECT 1"]
66+
interval: 30s
67+
timeout: 5s
68+
retries: 3
69+
4870
volumes:
4971
pacs-storage:
5072
driver: local

docs/relay-listener/README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Azure Relay listener
2+
3+
Relay listener uses websocket communication to Manage Breast Screening service via Azure Relay.
4+
The listener processes worklist actions sent from Manage/Django and creates worklist items in the Modality Worklist server.
5+
6+
7+
## Architecture
8+
9+
```
10+
┌─────────────────────┐ ┌──────────────────────┐
11+
│ Django (Manage) │ │ Gateway (Behind FW) │
12+
└─────────────────────┘ └──────────────────────┘
13+
│ │
14+
│ (1) Send Worklist Actions │
15+
│ ────────────────────────────────> │
16+
│ Connection: name-of-your-choice-relay-test-hc │
17+
│ Django: SENDER │
18+
│ Gateway: LISTENER (relay-listener) │
19+
│ │
20+
```
21+
22+
23+
## Firewall Compatibility
24+
25+
Connection works through firewalls because:
26+
27+
- All communication uses **outbound HTTPS (port 443)**
28+
- "Listening" means maintaining a persistent outbound WebSocket connection
29+
- Azure Relay pushes messages down existing connections
30+
- No inbound ports required on the gateway
31+
32+
## Setup Instructions
33+
34+
### 1. Create Azure Relay Resources
35+
36+
In Azure Portal:
37+
38+
1. Create an Azure Relay namespace (if not exists):
39+
- Name: `manbrs-gateway-dev`
40+
- Region: UK South
41+
42+
2. Create Hybrid Connection:
43+
- `name-of-your-choice-relay-test-hc` (for worklist actions)
44+
45+
3. Get the Shared Access Policy:
46+
- Policy Name: `RootManageSharedAccessKey` (default)
47+
- Copy the Primary Key
48+
49+
### 2. Copy environment variables from .env.development to .env
50+
51+
#### Gateway (.env or .env.development)
52+
53+
```bash
54+
AZURE_RELAY_NAMESPACE=manbrs-gateway-dev.servicebus.windows.net
55+
AZURE_RELAY_HYBRID_CONNECTION=name-of-your-choice-relay-test-hc
56+
AZURE_RELAY_KEY_NAME=RootManageSharedAccessKey
57+
AZURE_RELAY_SHARED_ACCESS_KEY=your_actual_key_here
58+
```
59+
60+
#### Django Manage (.env)
61+
62+
```bash
63+
AZURE_RELAY_NAMESPACE=manbrs-gateway-dev.servicebus.windows.net
64+
AZURE_RELAY_HYBRID_CONNECTION=name-of-your-choice-relay-test-hc
65+
AZURE_RELAY_KEY_NAME=RootManageSharedAccessKey
66+
AZURE_RELAY_SHARED_ACCESS_KEY=your_actual_key_here
67+
```
68+
69+
### 3. Start the Gateway Services
70+
71+
72+
```bash
73+
docker compose up --build
74+
```
75+
76+
77+
## Message Flows
78+
79+
### Worklist Creation (Django → Gateway)
80+
81+
1. User clicks "Send to Modality" in clinic UI
82+
2. Django creates `GatewayAction` with payload
83+
3. Manage Breast Screening service sends via relay (as sender) to `name-of-your-choice-relay-test-hc`
84+
4. Gateway `src.relay_listener.py` receives (as listener)
85+
5. Gateway creates worklist item in Modality Worklist server storage via `CreateWorklistItem` service class.
86+
6. Gateway sends success/failure response back.
87+
88+
89+
## Testing
90+
91+
1. Start both gateway and Manage/Django services
92+
2. Open clinic UI and send appointment to modality
93+
3. Monitor logs to trace message flow
94+
4. Verify worklist item created in Modality Emulator

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"pylibjpeg-libjpeg>=2.0.0",
1414
"pylibjpeg-openjpeg>=2.2.0",
1515
"pillow>=11.0.0",
16+
"websockets==15.0.1",
1617
]
1718

1819
[dependency-groups]
@@ -22,6 +23,7 @@ dev = [
2223
"ruff>=0.14.1,<0.15",
2324
"pyright>=1.1.390",
2425
"ipdb>=0.13.13,<0.14",
26+
"pytest-asyncio>=1.3.0,<1.4.0",
2527
]
2628

2729
[tool.uv]

src/relay_listener.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
RelayListener
3+
Receives worklist actions from manage-screening.
4+
Supports creation of Modality Worklist Items.
5+
"""
6+
7+
import asyncio
8+
import base64
9+
import hashlib
10+
import hmac
11+
import json
12+
import logging
13+
import os
14+
import time
15+
import urllib.parse
16+
17+
from websockets.asyncio.client import connect
18+
19+
from services.mwl.create_worklist_item import CreateWorklistItem
20+
from services.storage import MWLStorage
21+
22+
logger = logging.getLogger(__name__)
23+
24+
DB_PATH = os.getenv("MWL_DB_PATH", "/var/lib/pacs/worklist.db")
25+
SAS_TOKEN_EXPIRY_SECONDS = 3600
26+
27+
ACTIONS = {
28+
"worklist.create_item": CreateWorklistItem,
29+
}
30+
31+
32+
class RelayListener:
33+
"""Socket Listener for Azure Relay."""
34+
35+
def __init__(self, storage: MWLStorage):
36+
self.storage = storage
37+
self.relay_uri = RelayURI()
38+
39+
async def listen(self):
40+
"""Listen for messages from Azure Relay."""
41+
42+
logger.info(f"Connecting to Azure Relay: {self.relay_uri.hybrid_connection_name}...")
43+
44+
async with connect(self.relay_uri.connection_url(), compression=None) as websocket:
45+
logger.info("Connected - waiting for worklist actions...")
46+
47+
async for message in websocket:
48+
try:
49+
data = json.loads(message)
50+
51+
if "accept" in data:
52+
accept_url = data["accept"]["address"]
53+
logger.info("Incoming connection...")
54+
55+
async with connect(accept_url, compression=None) as client_ws:
56+
client_message = await asyncio.wait_for(client_ws.recv(), timeout=30)
57+
payload = json.loads(client_message)
58+
response = self.process_action(payload)
59+
60+
# Send acknowledgment
61+
await client_ws.send(json.dumps(response))
62+
63+
except asyncio.TimeoutError:
64+
logger.error("Timeout waiting for message")
65+
except Exception as e:
66+
logger.error(f"Error: {e}")
67+
68+
def process_action(self, payload: dict):
69+
"""Process incoming action payload."""
70+
action_name = payload.get("action_type", "no-op")
71+
72+
action_class = ACTIONS.get(action_name)
73+
if not action_class:
74+
raise ValueError(f"Unknown action: {action_name}")
75+
76+
return action_class(self.storage).call(payload)
77+
78+
79+
class RelayURI:
80+
def __init__(self):
81+
self.relay_namespace = os.getenv("AZURE_RELAY_NAMESPACE", "relay-test.servicebus.windows.net")
82+
self.hybrid_connection_name = os.getenv("AZURE_RELAY_HYBRID_CONNECTION", "relay-test-hc")
83+
self.key_name = os.getenv("AZURE_RELAY_KEY_NAME", "RootManageSharedAccessKey")
84+
self.shared_access_key = os.getenv("AZURE_RELAY_SHARED_ACCESS_KEY", "")
85+
86+
def create_sas_token(self, expiry_seconds: int = SAS_TOKEN_EXPIRY_SECONDS) -> str:
87+
"""Create SAS token for Azure Relay authentication."""
88+
uri = f"http://{self.relay_namespace}/{self.hybrid_connection_name}"
89+
encoded_uri = urllib.parse.quote_plus(uri)
90+
expiry = str(int(time.time() + expiry_seconds))
91+
signature = base64.b64encode(
92+
hmac.new(self.shared_access_key.encode(), f"{encoded_uri}\n{expiry}".encode(), hashlib.sha256).digest()
93+
)
94+
return (
95+
f"SharedAccessSignature sr={encoded_uri}"
96+
f"&sig={urllib.parse.quote_plus(signature)}"
97+
f"&se={expiry}&skn={self.key_name}"
98+
)
99+
100+
def connection_url(self) -> str:
101+
token = self.create_sas_token()
102+
return (
103+
f"wss://{self.relay_namespace}/$hc/{self.hybrid_connection_name}"
104+
f"?sb-hc-action=listen&sb-hc-token={urllib.parse.quote_plus(token)}"
105+
)
106+
107+
108+
async def main():
109+
logging.basicConfig(
110+
level=os.getenv("LOG_LEVEL", "INFO").upper(),
111+
format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
112+
)
113+
114+
logger.info("Socket Listener Starting...")
115+
storage = MWLStorage(db_path=DB_PATH)
116+
117+
while True:
118+
try:
119+
await RelayListener(storage).listen()
120+
except KeyboardInterrupt:
121+
logger.warning("\nShutting down...")
122+
break
123+
except Exception as e:
124+
logger.warning(f"Connection error: {e}")
125+
logger.warning("Retrying in 5 seconds...")
126+
await asyncio.sleep(5)
127+
128+
129+
if __name__ == "__main__":
130+
asyncio.run(main())
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import logging
2+
3+
from services.storage import MWLStorage, WorklistItem
4+
5+
logger = logging.getLogger(__name__)
6+
7+
8+
class CreateWorklistItem:
9+
def __init__(self, storage: MWLStorage):
10+
self.storage = storage
11+
12+
def call(self, payload: dict):
13+
try:
14+
action_id = payload.get("action_id")
15+
if not action_id:
16+
raise ValueError("Missing action_id in payload")
17+
18+
params = payload.get("parameters", {})
19+
20+
item = params.get("worklist_item", {})
21+
participant = item.get("participant", {})
22+
scheduled = item.get("scheduled", {})
23+
procedure = item.get("procedure", {})
24+
25+
self.storage.store_worklist_item(
26+
WorklistItem(
27+
accession_number=item.get("accession_number"),
28+
patient_id=participant.get("nhs_number"),
29+
patient_name=participant.get("name"),
30+
patient_birth_date=participant.get("birth_date"),
31+
patient_sex=participant.get("sex", ""),
32+
scheduled_date=scheduled.get("date"),
33+
scheduled_time=scheduled.get("time"),
34+
modality=procedure.get("modality"),
35+
study_description=procedure.get("study_description", ""),
36+
source_message_id=action_id,
37+
)
38+
)
39+
logger.info(f"Created worklist item: {item.get('accession_number')}")
40+
return {"status": "created", "action_id": action_id}
41+
except Exception as e:
42+
logger.error(f"Failed to create worklist item: {e}")
43+
return {"status": "error", "action_id": action_id, "error": str(e)}

0 commit comments

Comments
 (0)