Skip to content

Commit

Permalink
Add azure function which interacts with NHS notify
Browse files Browse the repository at this point in the history
This azure function is triggered via HTTP and accepts json post data of
a specific format. The data is turned into mulitple requests to the
Notify send message endpoint. An individual routing plan may be
specified per recipient or a top level routing plan can be applied for
all recipients.

Currently responds with a list of send message response text from
Notify, we will decorate or wrap these in a later feature.
  • Loading branch information
steventux committed Oct 24, 2024
1 parent 6aa4fc0 commit bc9c50a
Show file tree
Hide file tree
Showing 10 changed files with 397 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/functions/notify/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
local.settings.json

17 changes: 17 additions & 0 deletions src/functions/notify/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# To enable ssh & remote debugging on app service change the base image to the one below
# FROM mcr.microsoft.com/azure-functions/python:4-python3.11-appservice
FROM mcr.microsoft.com/azure-functions/python:4-python3.11

ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
AzureFunctionsJobHost__Logging__Console__IsEnabled=true \
AzureWebJobsFeatureFlags=EnableWorkerIndexing \
AzureWebJobsStorage=UseDevelopmentStorage=true \
FUNCTIONS_WORKER_RUNTIME=python \
BASE_URL=https://sandbox.api.service.nhs.uk \
WEBSITES_PORT=8080


COPY requirements.txt /
RUN pip install -r /requirements.txt

COPY . /home/site/wwwroot
25 changes: 25 additions & 0 deletions src/functions/notify/function_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import json
import logging
import sys
import os

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.dirname(SCRIPT_DIR))

from helper import send_messages

import azure.functions as func

app = func.FunctionApp()


@app.function_name(name="Notify")
@app.route(route="notify/{notification_type}/send", auth_level=func.AuthLevel.ANONYMOUS)
def main(req: func.HttpRequest) -> func.HttpResponse:
notification_type: str = req.route_params.get("notification_type")
req_body_bytes: bytes = req.get_body()
json_body: str = json.loads(req_body_bytes.decode("utf-8"))
logging.info(f"JSON body: {json_body}")

if notification_type == "message":
return send_messages(json_body)
143 changes: 143 additions & 0 deletions src/functions/notify/helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import hashlib
import jwt
import logging
import os
import requests
import time
import uuid


ROUTING_PLANS = {
# FIXME: This is a sandbox routing plan id, not a real one.
"breast-screening-pilot": "b838b13c-f98c-4def-93f0-515d4e4f4ee1",
}
HEADERS = {
"Content-type": "application/json",
"Accept": "application/json",
}


def send_messages(data: dict) -> str:
responses: list = []
routing_plan_id: str = None

if "routing_plan" in data:
routing_plan_id = ROUTING_PLANS[data.pop("routing_plan")]

if "recipients" in data:
for message_data in data["recipients"]:
if "routing_plan" in message_data:
routing_plan_id = ROUTING_PLANS[message_data.pop("routing_plan")]

response: str = send_message(routing_plan_id, message_data)
responses.append(response)

return "\n".join(responses)


def send_message(routing_plan_id, message_data) -> str:
body: str = message_body(routing_plan_id, message_data)
response = requests.post(url(), json=body, headers=HEADERS)

if response:
logging.info(response.text)
else:
logging.error(f"{response.status_code} response from Notify API {url()}")
logging.error(response.text)

return response.text


def url() -> str:
return os.environ["BASE_URL"] + "/comms/v1/messages"


def message_body(routing_plan_id, message_data) -> dict:
nhs_number: str = message_data["nhs_number"]
date_of_birth: str = message_data["date_of_birth"]
appointment_time: str = message_data["appointment_time"]
appointment_date: str = message_data["appointment_date"]
appointment_type: str = message_data["appointment_type"]
appointment_location: str = message_data["appointment_location"]

return {
"data": {
"type": "Message",
"attributes": {
"messageReference": reference_uuid(nhs_number),
"routingPlanId": routing_plan_id,
"recipient": {
"nhsNumber": nhs_number,
"dateOfBirth": date_of_birth,
},
"personalisation": {
"appointmentDate": appointment_date,
"appointmentLocation": appointment_location,
"appointmentTime": appointment_time,
"appointmentType": appointment_type,
},
"originator": {
"odsCode": "X26"
},
},
}
}


def reference_uuid(val) -> str:
str_val = str(val)
return str(uuid.UUID(hashlib.md5(str_val.encode()).hexdigest()))


def get_access_token() -> str:
jwt: str = generate_auth_jwt()
headers: dict = {"Content-Type": "application/x-www-form-urlencoded"}

body = {
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": jwt,
}

response = requests.post(base_url(), data=body, headers=headers)
access_token = response["access_token"]

return access_token


def generate_auth_jwt() -> str:
algorithm: str = "RS512"
expiry_minutes: int = 5
headers: dict = {"alg": algorithm, "typ": "JWT", "kid": os.getenv("KID")}

payload: dict = {
"sub": os.getenv("API_KEY"),
"iss": os.getenv("API_KEY"),
"jti": str(uuid.uuid4()),
"aud": os.getenv("TOKEN_URL"),
"exp": int(time()) + 300, # 5mins in the future
}

private_key = get_private_key(os.getenv("PRIVATE_KEY_PATH"))

return generate_jwt(algorithm, private_key, headers, payload, expiry_minutes=5)


def generate_jwt(
algorithm: str,
private_key,
headers: dict,
payload: dict,
expiry_minutes: int = None,
) -> str:
if expiry_minutes:
expiry_date = datetime.now(timezone.utc) + timedelta(minutes=expiry_minutes)
payload["exp"] = expiry_date

return jwt.encode(payload, private_key, algorithm, headers)


def get_private_key(private_key_path: str) -> str:
with open(private_key_path, "r", encoding="utf-8") as f:
private_key = f.read()
return private_key
8 changes: 8 additions & 0 deletions src/functions/notify/host.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}

9 changes: 9 additions & 0 deletions src/functions/notify/local.settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "python",
"AzureWebJobsFeatureFlags": "EnableWorkerIndexing",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"BASE_URL": "https://sandbox.api.service.nhs.uk"
}
}
18 changes: 18 additions & 0 deletions src/functions/notify/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
azure-functions==1.21.3
certifi==2024.8.30
cffi==1.17.1
charset-normalizer==3.4.0
cryptography==43.0.3
idna==3.10
iniconfig==2.0.0
jwt==1.3.1
packaging==24.1
pluggy==1.5.0
pycparser==2.22
pytest==8.3.3
pytest-mock==3.14.0
python-dateutil==2.9.0.post0
requests==2.32.3
requests-mock==1.12.1
six==1.16.0
urllib3==2.2.3
Empty file.
32 changes: 32 additions & 0 deletions src/functions/notify/tests/test_function_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from function_app import main
import azure.functions as func
import json


class TestFunction:
def test_main(self, mocker):
mock = mocker.patch("function_app.send_messages")
data = {
"routing_plan": "breast-screening-pilot",
"recipients": [
{
"nhs_number": "0000000000",
},
{
"nhs_number": "0000000001",
}
]

}
req = func.HttpRequest(
method="POST",
body=bytes(json.dumps(data).encode("utf-8")),
url="/api/notify/message/send",
route_params={"notification_type": "message"},
)

func_call = main.build().get_user_function()
func_call(req)

mock.assert_called_once()
mock.assert_called_with(data)
Loading

0 comments on commit bc9c50a

Please sign in to comment.