Skip to content

Commit 0c2652a

Browse files
authored
VED-835 Refactor create immunisation endpoint (#926)
1 parent 377b425 commit 0c2652a

File tree

14 files changed

+233
-261
lines changed

14 files changed

+233
-261
lines changed

backend/src/controller/fhir_api_exception_handler.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,27 @@
99
from controller.aws_apig_response_utils import create_response
1010
from models.errors import (
1111
Code,
12+
CustomValidationError,
13+
IdentifierDuplicationError,
1214
InvalidImmunizationId,
15+
InvalidJsonError,
1316
ResourceNotFoundError,
1417
Severity,
1518
UnauthorizedError,
1619
UnauthorizedVaxError,
20+
UnhandledResponseError,
1721
create_operation_outcome,
1822
)
1923

2024
_CUSTOM_EXCEPTION_TO_STATUS_MAP: dict[Type[Exception], int] = {
2125
InvalidImmunizationId: 400,
26+
InvalidJsonError: 400,
27+
CustomValidationError: 400,
2228
UnauthorizedError: 403,
2329
UnauthorizedVaxError: 403,
2430
ResourceNotFoundError: 404,
31+
IdentifierDuplicationError: 422,
32+
UnhandledResponseError: 500,
2533
}
2634

2735

backend/src/controller/fhir_controller.py

Lines changed: 20 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import urllib.parse
66
import uuid
77
from decimal import Decimal
8+
from json import JSONDecodeError
89
from typing import Optional
910

1011
from aws_lambda_typing.events import APIGatewayProxyEventV1
@@ -20,11 +21,11 @@
2021
Code,
2122
IdentifierDuplicationError,
2223
InvalidImmunizationId,
24+
InvalidJsonError,
2325
ParameterException,
2426
Severity,
2527
UnauthorizedError,
2628
UnauthorizedVaxError,
27-
UnhandledResponseError,
2829
ValidationError,
2930
create_operation_outcome,
3031
)
@@ -48,7 +49,8 @@ def make_controller(
4849

4950

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

5355
def __init__(
5456
self,
@@ -105,46 +107,22 @@ def get_immunization_by_id(self, aws_event: APIGatewayProxyEventV1) -> dict:
105107

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

108-
def create_immunization(self, aws_event):
109-
if not aws_event.get("headers"):
110-
return create_response(
111-
403,
112-
create_operation_outcome(
113-
resource_id=str(uuid.uuid4()),
114-
severity=Severity.error,
115-
code=Code.forbidden,
116-
diagnostics="Unauthorized request",
117-
),
118-
)
119-
120-
supplier_system = self._identify_supplier_system(aws_event)
110+
@fhir_api_exception_handler
111+
def create_immunization(self, aws_event: APIGatewayProxyEventV1) -> dict:
112+
supplier_system = get_supplier_system_header(aws_event)
121113

122114
try:
123-
immunisation = json.loads(aws_event["body"], parse_float=Decimal)
124-
except json.decoder.JSONDecodeError as e:
125-
return self._create_bad_request(f"Request's body contains malformed JSON: {e}")
126-
try:
127-
resource = self.fhir_service.create_immunization(immunisation, supplier_system)
128-
if "diagnostics" in resource:
129-
exp_error = create_operation_outcome(
130-
resource_id=str(uuid.uuid4()),
131-
severity=Severity.error,
132-
code=Code.invariant,
133-
diagnostics=resource["diagnostics"],
134-
)
135-
return create_response(400, json.dumps(exp_error))
136-
else:
137-
location = f"{get_service_url()}/Immunization/{resource.id}"
138-
version = "1"
139-
return create_response(201, None, {"Location": location, "E-Tag": version})
140-
except ValidationError as error:
141-
return create_response(400, error.to_operation_outcome())
142-
except IdentifierDuplicationError as duplicate:
143-
return create_response(422, duplicate.to_operation_outcome())
144-
except UnhandledResponseError as unhandled_error:
145-
return create_response(500, unhandled_error.to_operation_outcome())
146-
except UnauthorizedVaxError as unauthorized:
147-
return create_response(403, unauthorized.to_operation_outcome())
115+
immunisation: dict = json.loads(aws_event["body"], parse_float=Decimal)
116+
except JSONDecodeError as e:
117+
raise InvalidJsonError(message=str(f"Request's body contains malformed JSON: {e}"))
118+
119+
created_resource_id = self.fhir_service.create_immunization(immunisation, supplier_system)
120+
121+
return create_response(
122+
status_code=201,
123+
body=None,
124+
headers={"Location": f"{self._API_SERVICE_URL}/Immunization/{created_resource_id}", "E-Tag": "1"},
125+
)
148126

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

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

410388
def _validate_identifier_system(self, _id: str, _elements: str) -> Optional[dict]:
411389
if not _id:

backend/src/create_imms_handler.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import argparse
22
import logging
33
import pprint
4-
import uuid
54

6-
from constants import GENERIC_SERVER_ERROR_DIAGNOSTICS_MESSAGE
7-
from controller.aws_apig_response_utils import create_response
85
from controller.fhir_controller import FhirController, make_controller
96
from local_lambda import load_string
107
from log_structure import function_info
11-
from models.errors import Code, Severity, create_operation_outcome
128

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

2117

2218
def create_immunization(event, controller: FhirController):
23-
try:
24-
return controller.create_immunization(event)
25-
except Exception: # pylint: disable = broad-exception-caught
26-
logger.exception("Unhandled exception")
27-
exp_error = create_operation_outcome(
28-
resource_id=str(uuid.uuid4()),
29-
severity=Severity.error,
30-
code=Code.server_error,
31-
diagnostics=GENERIC_SERVER_ERROR_DIAGNOSTICS_MESSAGE,
32-
)
33-
return create_response(500, exp_error)
19+
return controller.create_immunization(event)
3420

3521

3622
if __name__ == "__main__":

backend/src/models/errors.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,21 @@ def to_operation_outcome(self) -> dict:
214214
)
215215

216216

217+
@dataclass
218+
class InvalidJsonError(RuntimeError):
219+
"""Raised when client provides an invalid JSON payload"""
220+
221+
message: str
222+
223+
def to_operation_outcome(self) -> dict:
224+
return create_operation_outcome(
225+
resource_id=str(uuid.uuid4()),
226+
severity=Severity.error,
227+
code=Code.invalid,
228+
diagnostics=self.message,
229+
)
230+
231+
217232
def create_operation_outcome(resource_id: str, severity: Severity, code: Code, diagnostics: str) -> dict:
218233
"""Create an OperationOutcome object. Do not use `fhir.resource` library since it adds unnecessary validations"""
219234
return {

backend/src/repository/fhir_repository.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import os
22
import time
3-
import uuid
43
from dataclasses import dataclass
54
from typing import Optional, Tuple
65

@@ -9,6 +8,8 @@
98
import simplejson as json
109
from boto3.dynamodb.conditions import Attr, Key
1110
from botocore.config import Config
11+
from fhir.resources.R4B.fhirtypes import Id
12+
from fhir.resources.R4B.immunization import Immunization
1213
from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table
1314
from responses import logger
1415

@@ -17,6 +18,7 @@
1718
ResourceNotFoundError,
1819
UnhandledResponseError,
1920
)
21+
from models.utils.generic_utils import get_contained_patient
2022
from models.utils.validation_utils import (
2123
check_identifier_system_value,
2224
get_vaccine_type,
@@ -151,33 +153,42 @@ def get_immunization_by_id_all(self, imms_id: str, imms: dict) -> Optional[dict]
151153
else:
152154
return None
153155

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

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

161-
if query_response is not None:
162-
raise IdentifierDuplicationError(identifier=attr.identifier)
166+
return False
167+
168+
def create_immunization(self, immunization: Immunization, supplier_system: str) -> Id:
169+
"""Creates a new immunization record returning the unique id if successful."""
170+
immunization_as_dict = immunization.dict()
171+
172+
patient = get_contained_patient(immunization_as_dict)
173+
attr = RecordAttributes(immunization_as_dict, patient)
163174

164175
response = self.table.put_item(
165176
Item={
166177
"PK": attr.pk,
167178
"PatientPK": attr.patient_pk,
168179
"PatientSK": attr.patient_sk,
169-
"Resource": json.dumps(attr.resource, use_decimal=True),
180+
"Resource": immunization.json(use_decimal=True),
170181
"IdentifierPK": attr.identifier,
171182
"Operation": "CREATE",
172183
"Version": 1,
173184
"SupplierSystem": supplier_system,
174185
}
175186
)
176187

177-
if response["ResponseMetadata"]["HTTPStatusCode"] == 200:
178-
return immunization
179-
else:
180-
raise UnhandledResponseError(message="Non-200 response from dynamodb", response=response)
188+
if response["ResponseMetadata"]["HTTPStatusCode"] != 200:
189+
raise UnhandledResponseError(message="Non-200 response from dynamodb", response=dict(response))
190+
191+
return immunization.id
181192

182193
def update_immunization(
183194
self,

backend/src/service/fhir_service.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import datetime
22
import logging
33
import os
4+
import uuid
45
from enum import Enum
5-
from typing import Optional, Union
6+
from typing import Optional, Union, cast
67
from uuid import uuid4
78

89
from fhir.resources.R4B.bundle import (
@@ -13,6 +14,8 @@
1314
BundleEntrySearch,
1415
BundleLink,
1516
)
17+
from fhir.resources.R4B.fhirtypes import Id
18+
from fhir.resources.R4B.identifier import Identifier
1619
from fhir.resources.R4B.immunization import Immunization
1720
from pydantic import ValidationError
1821

@@ -22,6 +25,7 @@
2225
from filter import Filter
2326
from models.errors import (
2427
CustomValidationError,
28+
IdentifierDuplicationError,
2529
InvalidPatientId,
2630
MandatoryError,
2731
ResourceNotFoundError,
@@ -132,26 +136,30 @@ def get_immunization_by_id_all(self, imms_id: str, imms: dict) -> Optional[dict]
132136
imms_resp = self.immunization_repo.get_immunization_by_id_all(imms_id, imms)
133137
return imms_resp
134138

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

139143
try:
140144
self.validator.validate(immunization)
141145
except (ValidationError, ValueError, MandatoryError) as error:
142146
raise CustomValidationError(message=str(error)) from error
143-
patient = self._validate_patient(immunization)
144-
145-
if "diagnostics" in patient:
146-
return patient
147147

148148
vaccination_type = get_vaccine_type(immunization)
149149

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

153-
immunisation = self.immunization_repo.create_immunization(immunization, patient, supplier_system)
154-
return Immunization.parse_obj(immunisation)
153+
# Set ID for the requested new record
154+
immunization["id"] = str(uuid.uuid4())
155+
156+
immunization_fhir_entity = Immunization.parse_obj(immunization)
157+
identifier = cast(Identifier, immunization_fhir_entity.identifier[0])
158+
159+
if self.immunization_repo.check_immunization_identifier_exists(identifier.system, identifier.value):
160+
raise IdentifierDuplicationError(identifier=f"{identifier.system}#{identifier.value}")
161+
162+
return self.immunization_repo.create_immunization(immunization_fhir_entity, supplier_system)
155163

156164
def update_immunization(
157165
self,

backend/tests/controller/test_fhir_api_exception_handler.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@
33
from unittest.mock import patch
44

55
from controller.fhir_api_exception_handler import fhir_api_exception_handler
6-
from models.errors import ResourceNotFoundError, UnauthorizedError, UnauthorizedVaxError
6+
from models.errors import (
7+
CustomValidationError,
8+
IdentifierDuplicationError,
9+
InvalidJsonError,
10+
ResourceNotFoundError,
11+
UnauthorizedError,
12+
UnauthorizedVaxError,
13+
UnhandledResponseError,
14+
)
715

816

917
class TestFhirApiExceptionHandler(unittest.TestCase):
@@ -27,6 +35,8 @@ def dummy_func():
2735
def test_exception_handler_handles_custom_exception_and_returns_fhir_response(self):
2836
"""Test that custom exceptions are handled by the wrapper and a valid response is returned to the client"""
2937
test_cases = [
38+
(InvalidJsonError("Invalid JSON provided"), 400, "invalid", "Invalid JSON provided"),
39+
(CustomValidationError("This field was invalid"), 400, "invariant", "This field was invalid"),
3040
(UnauthorizedError(), 403, "forbidden", "Unauthorized request"),
3141
(
3242
UnauthorizedVaxError(),
@@ -40,6 +50,18 @@ def test_exception_handler_handles_custom_exception_and_returns_fhir_response(se
4050
"not-found",
4151
"Immunization resource does not exist. ID: 123",
4252
),
53+
(
54+
IdentifierDuplicationError("system#id"),
55+
422,
56+
"duplicate",
57+
"The provided identifier: system#id is duplicated",
58+
),
59+
(
60+
UnhandledResponseError(message="Critical error", response={"outcome": "critical error"}),
61+
500,
62+
"exception",
63+
"Critical error\n{'outcome': 'critical error'}",
64+
),
4365
]
4466

4567
for error, expected_status, expected_code, expected_message in test_cases:

0 commit comments

Comments
 (0)