Skip to content

Commit

Permalink
Add message batch API endpoint
Browse files Browse the repository at this point in the history
Adds /api/message/batch which follows the same HMAC verification and request body schema validation as with status creation.
  • Loading branch information
steventux committed Jan 27, 2025
1 parent d6b3a14 commit 79941ba
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 13 deletions.
24 changes: 24 additions & 0 deletions src/notify/app/route_handlers/message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from flask import request
import app.validators.request_validator as request_validator
import app.services.message_batch_dispatcher as message_batch_dispatcher


def batch():
json_data = request.json or {}
valid_headers, error_message = request_validator.verify_headers(dict(request.headers))

if not valid_headers:
return {"status": "failed", "error": error_message}, 401

if not request_validator.verify_signature(dict(request.headers), json_data):
return {"status": "failed", "error": "Invalid signature"}, 403

valid_body, error_message = request_validator.verify_body(json_data)

if not valid_body:
return {"status": "failed", "error": error_message}, 422

status_code, response = message_batch_dispatcher.dispatch(json_data)
status = "success" if status_code == 201 else "failed"

return {"status": status, "response": response}, status_code
6 changes: 6 additions & 0 deletions src/notify/app/routes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from flask import Blueprint, jsonify

import app.route_handlers.message
import app.route_handlers.status


Expand All @@ -11,6 +12,11 @@ def health_check():
return jsonify({"status": "healthy"}), 200


@api.route("/message/batch", methods=["POST"])
def message_batch():
return app.route_handlers.message.batch()


@api.route("/status/create", methods=["POST"])
def create_status():
return app.route_handlers.status.create()
4 changes: 2 additions & 2 deletions src/notify/app/services/message_batch_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
import uuid


def dispatch(body: dict) -> tuple[bool, str]:
def dispatch(body: dict) -> tuple[int, str]:
response = requests.post(url(), json=body, headers=headers())
logging.info(f"Response from Notify API {url()}: {response.status_code}")

success = response.status_code == 201
status = models.MessageBatchStatuses.SENT if success else models.MessageBatchStatuses.FAILED
message_batch_recorder.save_batch(body, response.json(), status)

return success, response.json()
return response.status_code, response.json()


def headers() -> dict:
Expand Down
12 changes: 9 additions & 3 deletions src/notify/app/validators/request_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,16 @@ def verify_signature(headers: dict, body: dict) -> bool:

def verify_body(body: dict) -> tuple[bool, str]:
try:
schema_type = body["data"][0]["type"]
body_data = body["data"]

if type(body_data) is list:
schema_type = body_data[0]["type"]
else:
schema_type = body_data["type"]

return schema_validator.validate_with_schema(schema_type, body)
except KeyError:
return False, "Invalid body"
except KeyError as e:
return False, f"Invalid body: {e}"


def signature_secret() -> str:
Expand Down
2 changes: 1 addition & 1 deletion src/notify/app/validators/schema_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
schema_path = os.path.dirname(os.path.abspath(__file__)) + "/schemas/"
schema = json.load(open(schema_path + "nhs-notify.json"))
schema_path_identifiers = {
"BatchMessage": "/v1/message-batches",
"MessageBatch": "/v1/message-batches",
"ChannelStatus": "/\u003Cclient-provided-channel-status-URI\u003E",
"MessageStatus": "/\u003Cclient-provided-message-status-URI\u003E",
}
Expand Down
62 changes: 62 additions & 0 deletions tests/integration/notify/app/route_handlers/test_message_batch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from app import create_app
from app.validators.request_validator import API_KEY_HEADER_NAME, SIGNATURE_HEADER_NAME, signature_secret
import app.utils.hmac_signature as hmac_signature
import json
import pytest
import requests_mock


@pytest.fixture
def setup(monkeypatch):
"""Set up environment variables for tests."""
monkeypatch.setenv('APPLICATION_ID', 'application_id')
monkeypatch.setenv('NOTIFY_API_KEY', 'api_key')
monkeypatch.setenv("NOTIFY_API_URL", "http://example.com")


@pytest.fixture
def client():
app = create_app()
yield app.test_client()


def test_message_batch_request_validation_fails(setup, client, message_batch_post_body):
"""Test that invalid request header values fail HMAC signature validation."""
headers = {API_KEY_HEADER_NAME: "api_key", SIGNATURE_HEADER_NAME: "signature"}

response = client.post('/api/message/batch', json=message_batch_post_body, headers=headers)

assert response.status_code == 403
assert response.get_json() == {"status": "failed", "error": "Invalid signature"}


def test_message_batch_succeeds(setup, client, message_batch_post_body, message_batch_post_response):
"""Test that valid request header values pass HMAC signature validation."""
signature = hmac_signature.create_digest(signature_secret(), json.dumps(message_batch_post_body, sort_keys=True))

headers = {API_KEY_HEADER_NAME: "api_key", SIGNATURE_HEADER_NAME: signature}

with requests_mock.Mocker() as rm:
rm.post(
"http://example.com/comms/v1/message-batches",
status_code=201,
json=message_batch_post_response
)

response = client.post('/api/message/batch', json=message_batch_post_body, headers=headers)

assert response.status_code == 201
assert response.get_json() == {"status": "success", "response": message_batch_post_response}


def test_message_batch_fails_with_invalid_post_body(setup, client, message_batch_post_body):
"""Test that invalid request body fails schema validation."""
message_batch_post_body["data"]["type"] = "invalid"
signature = hmac_signature.create_digest(signature_secret(), json.dumps(message_batch_post_body, sort_keys=True))

headers = {API_KEY_HEADER_NAME: "api_key", SIGNATURE_HEADER_NAME: signature}

response = client.post('/api/message/batch', json=message_batch_post_body, headers=headers)

assert response.status_code == 422
assert response.get_json() == {"status": "failed", "error": "Invalid body: 'invalid'"}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
from datetime import datetime, timedelta
import app.utils.database as database
import app.utils.hmac_signature as hmac_signature
import hashlib
import hmac
import json
import pytest

Expand Down
10 changes: 5 additions & 5 deletions tests/unit/notify/app/services/test_message_batch_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ def test_message_batch_dispatcher_succeeds(mocker, setup, message_batch_post_bod
json=message_batch_post_response
)

success, response = message_batch_dispatcher.dispatch(message_batch_post_body)
status_code, response = message_batch_dispatcher.dispatch(message_batch_post_body)

assert adapter.call_count == 1
assert success
assert status_code == 201
assert response == message_batch_post_response

assert mock_access_token.call_count == 1
Expand All @@ -50,14 +50,14 @@ def test_message_batch_dispatcher_fails(mocker, setup, message_batch_post_body):
with requests_mock.Mocker() as rm:
adapter = rm.post(
"http://example.com/comms/v1/message-batches",
status_code=500,
status_code=400,
json={"error": "Bad request"}
)

success, response = message_batch_dispatcher.dispatch(message_batch_post_body)
status_code, response = message_batch_dispatcher.dispatch(message_batch_post_body)

assert adapter.call_count == 1
assert not success
assert status_code == 400
assert response == {"error": "Bad request"}

assert mock_access_token.call_count == 1
Expand Down

0 comments on commit 79941ba

Please sign in to comment.