generated from nhs-england-tools/repository-template
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add azure function which interacts with NHS notify
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
Showing
10 changed files
with
397 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
local.settings.json | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)" | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.