Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/src/controller/fhir_api_exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,27 @@
from controller.aws_apig_response_utils import create_response
from models.errors import (
Code,
CustomValidationError,
IdentifierDuplicationError,
InvalidImmunizationId,
InvalidJsonError,
ResourceNotFoundError,
Severity,
UnauthorizedError,
UnauthorizedVaxError,
UnhandledResponseError,
create_operation_outcome,
)

_CUSTOM_EXCEPTION_TO_STATUS_MAP: dict[Type[Exception], int] = {
InvalidImmunizationId: 400,
InvalidJsonError: 400,
CustomValidationError: 400,
UnauthorizedError: 403,
UnauthorizedVaxError: 403,
ResourceNotFoundError: 404,
IdentifierDuplicationError: 422,
UnhandledResponseError: 500,
}


Expand Down
62 changes: 20 additions & 42 deletions backend/src/controller/fhir_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import urllib.parse
import uuid
from decimal import Decimal
from json import JSONDecodeError
from typing import Optional

from aws_lambda_typing.events import APIGatewayProxyEventV1
Expand All @@ -20,11 +21,11 @@
Code,
IdentifierDuplicationError,
InvalidImmunizationId,
InvalidJsonError,
ParameterException,
Severity,
UnauthorizedError,
UnauthorizedVaxError,
UnhandledResponseError,
ValidationError,
create_operation_outcome,
)
Expand All @@ -48,7 +49,8 @@ def make_controller(


class FhirController:
immunization_id_pattern = r"^[A-Za-z0-9\-.]{1,64}$"
_IMMUNIZATION_ID_PATTERN = r"^[A-Za-z0-9\-.]{1,64}$"
_API_SERVICE_URL = get_service_url()

def __init__(
self,
Expand Down Expand Up @@ -105,46 +107,22 @@ def get_immunization_by_id(self, aws_event: APIGatewayProxyEventV1) -> dict:

return create_response(200, resource.json(), {E_TAG_HEADER_NAME: version})

def create_immunization(self, aws_event):
if not aws_event.get("headers"):
return create_response(
403,
create_operation_outcome(
resource_id=str(uuid.uuid4()),
severity=Severity.error,
code=Code.forbidden,
diagnostics="Unauthorized request",
),
)

supplier_system = self._identify_supplier_system(aws_event)
@fhir_api_exception_handler
def create_immunization(self, aws_event: APIGatewayProxyEventV1) -> dict:
supplier_system = get_supplier_system_header(aws_event)

try:
immunisation = json.loads(aws_event["body"], parse_float=Decimal)
except json.decoder.JSONDecodeError as e:
return self._create_bad_request(f"Request's body contains malformed JSON: {e}")
try:
resource = self.fhir_service.create_immunization(immunisation, supplier_system)
if "diagnostics" in resource:
exp_error = create_operation_outcome(
resource_id=str(uuid.uuid4()),
severity=Severity.error,
code=Code.invariant,
diagnostics=resource["diagnostics"],
)
return create_response(400, json.dumps(exp_error))
else:
location = f"{get_service_url()}/Immunization/{resource.id}"
version = "1"
return create_response(201, None, {"Location": location, "E-Tag": version})
except ValidationError as error:
return create_response(400, error.to_operation_outcome())
except IdentifierDuplicationError as duplicate:
return create_response(422, duplicate.to_operation_outcome())
except UnhandledResponseError as unhandled_error:
return create_response(500, unhandled_error.to_operation_outcome())
except UnauthorizedVaxError as unauthorized:
return create_response(403, unauthorized.to_operation_outcome())
immunisation: dict = json.loads(aws_event["body"], parse_float=Decimal)
except JSONDecodeError as e:
raise InvalidJsonError(message=str(f"Request's body contains malformed JSON: {e}"))

created_resource_id = self.fhir_service.create_immunization(immunisation, supplier_system)

return create_response(
status_code=201,
body=None,
headers={"Location": f"{self._API_SERVICE_URL}/Immunization/{created_resource_id}", "E-Tag": "1"},
)

def update_immunization(self, aws_event):
try:
Expand Down Expand Up @@ -188,7 +166,7 @@ def update_immunization(self, aws_event):
)
return create_response(400, json.dumps(exp_error))
# Validate the imms id in the path params and body of request - end
except json.decoder.JSONDecodeError as e:
except JSONDecodeError as e:
return self._create_bad_request(f"Request's body contains malformed JSON: {e}")
except Exception as e:
return self._create_bad_request(f"Request's body contains string: {e}")
Expand Down Expand Up @@ -405,7 +383,7 @@ def search_immunizations(self, aws_event: APIGatewayProxyEventV1) -> dict:

def _is_valid_immunization_id(self, immunization_id: str) -> bool:
"""Validates if the given unique Immunization ID is valid."""
return False if not re.match(self.immunization_id_pattern, immunization_id) else True
return False if not re.match(self._IMMUNIZATION_ID_PATTERN, immunization_id) else True

def _validate_identifier_system(self, _id: str, _elements: str) -> Optional[dict]:
if not _id:
Expand Down
16 changes: 1 addition & 15 deletions backend/src/create_imms_handler.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import argparse
import logging
import pprint
import uuid

from constants import GENERIC_SERVER_ERROR_DIAGNOSTICS_MESSAGE
from controller.aws_apig_response_utils import create_response
from controller.fhir_controller import FhirController, make_controller
from local_lambda import load_string
from log_structure import function_info
from models.errors import Code, Severity, create_operation_outcome

logging.basicConfig(level="INFO")
logger = logging.getLogger()
Expand All @@ -20,17 +16,7 @@ def create_imms_handler(event, _context):


def create_immunization(event, controller: FhirController):
try:
return controller.create_immunization(event)
except Exception: # pylint: disable = broad-exception-caught
logger.exception("Unhandled exception")
exp_error = create_operation_outcome(
resource_id=str(uuid.uuid4()),
severity=Severity.error,
code=Code.server_error,
diagnostics=GENERIC_SERVER_ERROR_DIAGNOSTICS_MESSAGE,
)
return create_response(500, exp_error)
return controller.create_immunization(event)


if __name__ == "__main__":
Expand Down
15 changes: 15 additions & 0 deletions backend/src/models/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,21 @@ def to_operation_outcome(self) -> dict:
)


@dataclass
class InvalidJsonError(RuntimeError):
"""Raised when client provides an invalid JSON payload"""

message: str

def to_operation_outcome(self) -> dict:
return create_operation_outcome(
resource_id=str(uuid.uuid4()),
severity=Severity.error,
code=Code.invalid,
diagnostics=self.message,
)


def create_operation_outcome(resource_id: str, severity: Severity, code: Code, diagnostics: str) -> dict:
"""Create an OperationOutcome object. Do not use `fhir.resource` library since it adds unnecessary validations"""
return {
Expand Down
37 changes: 24 additions & 13 deletions backend/src/repository/fhir_repository.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import os
import time
import uuid
from dataclasses import dataclass
from typing import Optional, Tuple

Expand All @@ -9,6 +8,8 @@
import simplejson as json
from boto3.dynamodb.conditions import Attr, Key
from botocore.config import Config
from fhir.resources.R4B.fhirtypes import Id
from fhir.resources.R4B.immunization import Immunization
from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table
from responses import logger

Expand All @@ -17,6 +18,7 @@
ResourceNotFoundError,
UnhandledResponseError,
)
from models.utils.generic_utils import get_contained_patient
from models.utils.validation_utils import (
check_identifier_system_value,
get_vaccine_type,
Expand Down Expand Up @@ -151,33 +153,42 @@ def get_immunization_by_id_all(self, imms_id: str, imms: dict) -> Optional[dict]
else:
return None

def create_immunization(self, immunization: dict, patient: any, supplier_system: str) -> dict:
new_id = str(uuid.uuid4())
immunization["id"] = new_id
attr = RecordAttributes(immunization, patient)
def check_immunization_identifier_exists(self, system: str, unique_id: str) -> bool:
"""Checks whether an immunization with the given immunization identifier (system + local ID) exists."""
response = self.table.query(
IndexName="IdentifierGSI",
KeyConditionExpression=Key("IdentifierPK").eq(f"{system}#{unique_id}"),
)

query_response = _query_identifier(self.table, "IdentifierGSI", "IdentifierPK", attr.identifier)
if "Items" in response and len(response["Items"]) > 0:
return True

if query_response is not None:
raise IdentifierDuplicationError(identifier=attr.identifier)
return False

def create_immunization(self, immunization: Immunization, supplier_system: str) -> Id:
"""Creates a new immunization record returning the unique id if successful."""
immunization_as_dict = immunization.dict()

patient = get_contained_patient(immunization_as_dict)
attr = RecordAttributes(immunization_as_dict, patient)

response = self.table.put_item(
Item={
"PK": attr.pk,
"PatientPK": attr.patient_pk,
"PatientSK": attr.patient_sk,
"Resource": json.dumps(attr.resource, use_decimal=True),
"Resource": immunization.json(use_decimal=True),
"IdentifierPK": attr.identifier,
"Operation": "CREATE",
"Version": 1,
"SupplierSystem": supplier_system,
}
)

if response["ResponseMetadata"]["HTTPStatusCode"] == 200:
return immunization
else:
raise UnhandledResponseError(message="Non-200 response from dynamodb", response=response)
if response["ResponseMetadata"]["HTTPStatusCode"] != 200:
raise UnhandledResponseError(message="Non-200 response from dynamodb", response=dict(response))

return immunization.id

def update_immunization(
self,
Expand Down
24 changes: 16 additions & 8 deletions backend/src/service/fhir_service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import datetime
import logging
import os
import uuid
from enum import Enum
from typing import Optional, Union
from typing import Optional, Union, cast
from uuid import uuid4

from fhir.resources.R4B.bundle import (
Expand All @@ -13,6 +14,8 @@
BundleEntrySearch,
BundleLink,
)
from fhir.resources.R4B.fhirtypes import Id
from fhir.resources.R4B.identifier import Identifier
from fhir.resources.R4B.immunization import Immunization
from pydantic import ValidationError

Expand All @@ -22,6 +25,7 @@
from filter import Filter
from models.errors import (
CustomValidationError,
IdentifierDuplicationError,
InvalidPatientId,
MandatoryError,
ResourceNotFoundError,
Expand Down Expand Up @@ -132,26 +136,30 @@ def get_immunization_by_id_all(self, imms_id: str, imms: dict) -> Optional[dict]
imms_resp = self.immunization_repo.get_immunization_by_id_all(imms_id, imms)
return imms_resp

def create_immunization(self, immunization: dict, supplier_system: str) -> dict | Immunization:
def create_immunization(self, immunization: dict, supplier_system: str) -> Id:
if immunization.get("id") is not None:
raise CustomValidationError("id field must not be present for CREATE operation")

try:
self.validator.validate(immunization)
except (ValidationError, ValueError, MandatoryError) as error:
raise CustomValidationError(message=str(error)) from error
patient = self._validate_patient(immunization)

if "diagnostics" in patient:
return patient

vaccination_type = get_vaccine_type(immunization)

if not self.authoriser.authorise(supplier_system, ApiOperationCode.CREATE, {vaccination_type}):
raise UnauthorizedVaxError()

immunisation = self.immunization_repo.create_immunization(immunization, patient, supplier_system)
return Immunization.parse_obj(immunisation)
# Set ID for the requested new record
immunization["id"] = str(uuid.uuid4())

immunization_fhir_entity = Immunization.parse_obj(immunization)
identifier = cast(Identifier, immunization_fhir_entity.identifier[0])

if self.immunization_repo.check_immunization_identifier_exists(identifier.system, identifier.value):
raise IdentifierDuplicationError(identifier=f"{identifier.system}#{identifier.value}")

return self.immunization_repo.create_immunization(immunization_fhir_entity, supplier_system)

def update_immunization(
self,
Expand Down
24 changes: 23 additions & 1 deletion backend/tests/controller/test_fhir_api_exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
from unittest.mock import patch

from controller.fhir_api_exception_handler import fhir_api_exception_handler
from models.errors import ResourceNotFoundError, UnauthorizedError, UnauthorizedVaxError
from models.errors import (
CustomValidationError,
IdentifierDuplicationError,
InvalidJsonError,
ResourceNotFoundError,
UnauthorizedError,
UnauthorizedVaxError,
UnhandledResponseError,
)


class TestFhirApiExceptionHandler(unittest.TestCase):
Expand All @@ -27,6 +35,8 @@ def dummy_func():
def test_exception_handler_handles_custom_exception_and_returns_fhir_response(self):
"""Test that custom exceptions are handled by the wrapper and a valid response is returned to the client"""
test_cases = [
(InvalidJsonError("Invalid JSON provided"), 400, "invalid", "Invalid JSON provided"),
(CustomValidationError("This field was invalid"), 400, "invariant", "This field was invalid"),
(UnauthorizedError(), 403, "forbidden", "Unauthorized request"),
(
UnauthorizedVaxError(),
Expand All @@ -40,6 +50,18 @@ def test_exception_handler_handles_custom_exception_and_returns_fhir_response(se
"not-found",
"Immunization resource does not exist. ID: 123",
),
(
IdentifierDuplicationError("system#id"),
422,
"duplicate",
"The provided identifier: system#id is duplicated",
),
(
UnhandledResponseError(message="Critical error", response={"outcome": "critical error"}),
500,
"exception",
"Critical error\n{'outcome': 'critical error'}",
),
]

for error, expected_status, expected_code, expected_message in test_cases:
Expand Down
Loading