Skip to content

Commit 574518e

Browse files
authored
VED-903: Update identifier validation for update immunisation path (#986)
* Update identifier validation logic * Add custom error for invalid stored data * Add error handling if IdentifierPK is None * Update unit tests * Tidy up after rebase * Tidy up after rebase * Fix linting error * Address review comments * Address review round 2 comments
1 parent 30fb156 commit 574518e

File tree

18 files changed

+363
-181
lines changed

18 files changed

+363
-181
lines changed

lambdas/backend/src/controller/aws_apig_event_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from aws_lambda_typing.events import APIGatewayProxyEventV1
66

77
from controller.constants import E_TAG_HEADER_NAME, SUPPLIER_SYSTEM_HEADER_NAME
8-
from models.errors import ResourceVersionNotProvided, UnauthorizedError
8+
from models.errors import ResourceVersionNotProvidedError, UnauthorizedError
99
from utils import dict_utils
1010

1111

@@ -30,6 +30,6 @@ def get_resource_version_header(event: APIGatewayProxyEventV1) -> str:
3030
resource_version_header: Optional[str] = dict_utils.get_field(dict(event), "headers", E_TAG_HEADER_NAME)
3131

3232
if resource_version_header is None:
33-
raise ResourceVersionNotProvided(resource_type="Immunization")
33+
raise ResourceVersionNotProvidedError(resource_type="Immunization")
3434

3535
return resource_version_header

lambdas/backend/src/controller/fhir_api_exception_handler.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,19 @@
99
CustomValidationError,
1010
IdentifierDuplicationError,
1111
InconsistentIdentifierError,
12-
InconsistentResourceVersion,
12+
InconsistentResourceVersionError,
1313
ResourceNotFoundError,
1414
)
1515
from constants import GENERIC_SERVER_ERROR_DIAGNOSTICS_MESSAGE
1616
from controller.aws_apig_response_utils import create_response
1717
from models.errors import (
1818
Code,
1919
InconsistentIdError,
20-
InvalidImmunizationId,
20+
InvalidImmunizationIdError,
2121
InvalidJsonError,
22-
InvalidResourceVersion,
23-
ResourceVersionNotProvided,
22+
InvalidResourceVersionError,
23+
InvalidStoredDataError,
24+
ResourceVersionNotProvidedError,
2425
Severity,
2526
UnauthorizedError,
2627
UnauthorizedVaxError,
@@ -29,19 +30,20 @@
2930
)
3031

3132
_CUSTOM_EXCEPTION_TO_STATUS_MAP: dict[Type[Exception], int] = {
32-
InconsistentResourceVersion: 400,
33+
InconsistentResourceVersionError: 400,
3334
InconsistentIdentifierError: 400, # Identifier refers to the local FHIR identifier composed of system and value.
3435
InconsistentIdError: 400, # ID refers to the top-level ID of the FHIR resource.
35-
InvalidImmunizationId: 400,
36+
InvalidImmunizationIdError: 400,
3637
InvalidJsonError: 400,
37-
InvalidResourceVersion: 400,
38+
InvalidResourceVersionError: 400,
3839
CustomValidationError: 400,
39-
ResourceVersionNotProvided: 400,
40+
ResourceVersionNotProvidedError: 400,
4041
UnauthorizedError: 403,
4142
UnauthorizedVaxError: 403,
4243
ResourceNotFoundError: 404,
4344
IdentifierDuplicationError: 422,
4445
UnhandledResponseError: 500,
46+
InvalidStoredDataError: 500,
4547
}
4648

4749

lambdas/backend/src/controller/fhir_controller.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
from models.errors import (
2323
Code,
2424
InconsistentIdError,
25-
InvalidImmunizationId,
25+
InvalidImmunizationIdError,
2626
InvalidJsonError,
27-
InvalidResourceVersion,
28-
ParameterException,
27+
InvalidResourceVersionError,
28+
ParameterExceptionError,
2929
Severity,
3030
UnauthorizedError,
3131
UnauthorizedVaxError,
@@ -100,7 +100,7 @@ def get_immunization_by_id(self, aws_event: APIGatewayProxyEventV1) -> dict:
100100
imms_id = get_path_parameter(aws_event, "id")
101101

102102
if not self._is_valid_immunization_id(imms_id):
103-
raise InvalidImmunizationId()
103+
raise InvalidImmunizationIdError()
104104

105105
supplier_system = get_supplier_system_header(aws_event)
106106

@@ -132,10 +132,10 @@ def update_immunization(self, aws_event: APIGatewayProxyEventV1) -> dict:
132132
resource_version = get_resource_version_header(aws_event)
133133

134134
if not self._is_valid_immunization_id(imms_id):
135-
raise InvalidImmunizationId()
135+
raise InvalidImmunizationIdError()
136136

137137
if not self._is_valid_resource_version(resource_version):
138-
raise InvalidResourceVersion(resource_version=resource_version)
138+
raise InvalidResourceVersionError(resource_version=resource_version)
139139

140140
try:
141141
immunization = json.loads(aws_event["body"], parse_float=Decimal)
@@ -156,7 +156,7 @@ def delete_immunization(self, aws_event: APIGatewayProxyEventV1) -> dict:
156156
imms_id = get_path_parameter(aws_event, "id")
157157

158158
if not self._is_valid_immunization_id(imms_id):
159-
raise InvalidImmunizationId()
159+
raise InvalidImmunizationIdError()
160160

161161
supplier_system = get_supplier_system_header(aws_event)
162162

@@ -167,10 +167,10 @@ def delete_immunization(self, aws_event: APIGatewayProxyEventV1) -> dict:
167167
def search_immunizations(self, aws_event: APIGatewayProxyEventV1) -> dict:
168168
try:
169169
search_params = process_search_params(process_params(aws_event))
170-
except ParameterException as e:
170+
except ParameterExceptionError as e:
171171
return self._create_bad_request(e.message)
172172
if search_params is None:
173-
raise ParameterException("Failed to parse parameters.")
173+
raise ParameterExceptionError("Failed to parse parameters.")
174174

175175
# Check vaxx type permissions- start
176176
try:

lambdas/backend/src/models/errors.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def to_operation_outcome() -> dict:
6666

6767

6868
@dataclass
69-
class ResourceVersionNotProvided(RuntimeError):
69+
class ResourceVersionNotProvidedError(RuntimeError):
7070
"""Return this error when client has failed to provide the FHIR resource version where required"""
7171

7272
resource_type: str
@@ -84,15 +84,15 @@ def to_operation_outcome(self) -> dict:
8484

8585

8686
@dataclass
87-
class ParameterException(RuntimeError):
87+
class ParameterExceptionError(RuntimeError):
8888
message: str
8989

9090
def __str__(self):
9191
return self.message
9292

9393

9494
@dataclass
95-
class InvalidImmunizationId(ApiValidationError):
95+
class InvalidImmunizationIdError(ApiValidationError):
9696
"""Use this when the unique Immunization ID is invalid"""
9797

9898
def to_operation_outcome(self) -> dict:
@@ -105,7 +105,7 @@ def to_operation_outcome(self) -> dict:
105105

106106

107107
@dataclass
108-
class InvalidResourceVersion(ApiValidationError):
108+
class InvalidResourceVersionError(ApiValidationError):
109109
"""Use this when the resource version is invalid"""
110110

111111
resource_version: Any
@@ -155,3 +155,21 @@ def to_operation_outcome(self) -> dict:
155155
code=Code.invalid,
156156
diagnostics=self.message,
157157
)
158+
159+
160+
@dataclass
161+
class InvalidStoredDataError(RuntimeError):
162+
"""Use this when a piece of stored data is invalid and the operation cannot be completed"""
163+
164+
data_type: str
165+
166+
def __str__(self):
167+
return f"Invalid data stored for immunization record: {self.data_type}"
168+
169+
def to_operation_outcome(self) -> dict:
170+
return create_operation_outcome(
171+
resource_id=str(uuid.uuid4()),
172+
severity=Severity.error,
173+
code=Code.server_error,
174+
diagnostics=self.__str__(),
175+
)

lambdas/backend/src/parameter_parser.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from common.models.constants import Constants
1010
from common.models.utils.generic_utils import nhs_number_mod11_check
1111
from common.redis_client import get_redis_client
12-
from models.errors import ParameterException
12+
from models.errors import ParameterExceptionError
1313

1414
ERROR_MESSAGE_DUPLICATED_PARAMETERS = 'Parameters may not be duplicated. Use commas for "or".'
1515

@@ -48,20 +48,20 @@ def process_patient_identifier(identifier_params: ParamContainer) -> str:
4848
patient_identifier = patient_identifiers[0] if len(patient_identifiers) == 1 else None
4949

5050
if patient_identifier is None:
51-
raise ParameterException(f"Search parameter {patient_identifier_key} must have one value.")
51+
raise ParameterExceptionError(f"Search parameter {patient_identifier_key} must have one value.")
5252

5353
patient_identifier_parts = patient_identifier.split("|")
5454
identifier_system = patient_identifier_parts[0]
5555
if len(patient_identifier_parts) != 2 or identifier_system != patient_identifier_system:
56-
raise ParameterException(
56+
raise ParameterExceptionError(
5757
"patient.identifier must be in the format of "
5858
f'"{patient_identifier_system}|{{NHS number}}" '
5959
f'e.g. "{patient_identifier_system}|9000000009"'
6060
)
6161

6262
nhs_number = patient_identifier_parts[1]
6363
if not nhs_number_mod11_check(nhs_number):
64-
raise ParameterException("Search parameter patient.identifier must be a valid NHS number.")
64+
raise ParameterExceptionError("Search parameter patient.identifier must be a valid NHS number.")
6565

6666
return nhs_number
6767

@@ -75,11 +75,11 @@ def process_immunization_target(imms_params: ParamContainer) -> list[str]:
7575
vaccine_type for vaccine_type in set(imms_params.get(immunization_target_key, [])) if vaccine_type is not None
7676
]
7777
if len(vaccine_types) < 1:
78-
raise ParameterException(f"Search parameter {immunization_target_key} must have one or more values.")
78+
raise ParameterExceptionError(f"Search parameter {immunization_target_key} must have one or more values.")
7979

8080
valid_vaccine_types = get_redis_client().hkeys(Constants.VACCINE_TYPE_TO_DISEASES_HASH_KEY)
8181
if any(x not in valid_vaccine_types for x in vaccine_types):
82-
raise ParameterException(
82+
raise ParameterExceptionError(
8383
f"immunization-target must be one or more of the following: {', '.join(valid_vaccine_types)}"
8484
)
8585

@@ -148,7 +148,7 @@ def process_search_params(params: ParamContainer) -> SearchParams:
148148
errors.append(f"Search parameter {date_from_key} must be before {date_to_key}")
149149

150150
if errors:
151-
raise ParameterException("; ".join(errors))
151+
raise ParameterExceptionError("; ".join(errors))
152152

153153
return SearchParams(patient_identifier, vaccine_types, date_from, date_to, include)
154154

@@ -163,7 +163,7 @@ def parse_multi_value_query_parameters(
163163
multi_value_query_params: dict[str, list[str]],
164164
) -> ParamContainer:
165165
if any(len(v) > 1 for k, v in multi_value_query_params.items()):
166-
raise ParameterException(ERROR_MESSAGE_DUPLICATED_PARAMETERS)
166+
raise ParameterExceptionError(ERROR_MESSAGE_DUPLICATED_PARAMETERS)
167167
params = [(k, split_and_flatten(v)) for k, v in multi_value_query_params.items()]
168168

169169
return dict(params)
@@ -177,7 +177,7 @@ def parse_body_params(aws_event: APIGatewayProxyEventV1) -> ParamContainer:
177177
parsed_body = parse_qs(decoded_body)
178178

179179
if any(len(v) > 1 for k, v in parsed_body.items()):
180-
raise ParameterException(ERROR_MESSAGE_DUPLICATED_PARAMETERS)
180+
raise ParameterExceptionError(ERROR_MESSAGE_DUPLICATED_PARAMETERS)
181181
items = {k: split_and_flatten(v) for k, v in parsed_body.items()}
182182
return items
183183
return {}
@@ -186,7 +186,7 @@ def parse_body_params(aws_event: APIGatewayProxyEventV1) -> ParamContainer:
186186
body_params = parse_body_params(aws_event)
187187

188188
if len(set(query_params.keys()) & set(body_params.keys())) > 0:
189-
raise ParameterException(ERROR_MESSAGE_DUPLICATED_PARAMETERS)
189+
raise ParameterExceptionError(ERROR_MESSAGE_DUPLICATED_PARAMETERS)
190190

191191
parsed_params = {
192192
key: sorted(query_params.get(key, []) + body_params.get(key, []))

lambdas/backend/src/repository/fhir_repository.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from boto3.dynamodb.conditions import Attr, Key
1010
from botocore.config import Config
1111
from fhir.resources.R4B.fhirtypes import Id
12+
from fhir.resources.R4B.identifier import Identifier
1213
from fhir.resources.R4B.immunization import Immunization
1314
from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table
1415
from responses import logger
@@ -23,7 +24,7 @@
2324
from common.models.utils.validation_utils import (
2425
get_vaccine_type,
2526
)
26-
from models.errors import UnhandledResponseError
27+
from models.errors import InvalidStoredDataError, UnhandledResponseError
2728

2829

2930
def create_table(table_name=None, endpoint_url=None, region_name="eu-west-2"):
@@ -50,6 +51,18 @@ def _query_identifier(table, index, pk, identifier):
5051
return queryresponse
5152

5253

54+
def get_fhir_identifier_from_identifier_pk(identifier_pk: str) -> Identifier:
55+
split_identifier = identifier_pk.split("#", 1)
56+
57+
if len(split_identifier) != 2:
58+
raise InvalidStoredDataError(data_type="identifier")
59+
60+
supplier_code = split_identifier[0]
61+
supplier_unique_id = split_identifier[1]
62+
63+
return Identifier(system=supplier_code, value=supplier_unique_id)
64+
65+
5366
@dataclass
5467
class RecordAttributes:
5568
pk: str
@@ -101,10 +114,10 @@ def get_immunization_by_identifier(self, identifier_pk: str) -> tuple[Optional[d
101114
else:
102115
return None, None
103116

104-
def get_immunization_and_resource_meta_by_id(
117+
def get_immunization_resource_and_metadata_by_id(
105118
self, imms_id: str, include_deleted: bool = False
106119
) -> tuple[Optional[dict], Optional[ImmunizationRecordMetadata]]:
107-
"""Retrieves the immunization and resource metadata from the VEDS table"""
120+
"""Retrieves the immunization resource and metadata from the VEDS table"""
108121
response = self.table.get_item(Key={"PK": _make_immunization_pk(imms_id)})
109122
item = response.get("Item")
110123

@@ -119,7 +132,18 @@ def get_immunization_and_resource_meta_by_id(
119132
if is_deleted and not include_deleted:
120133
return None, None
121134

122-
imms_record_meta = ImmunizationRecordMetadata(int(item.get("Version")), is_deleted, is_reinstated)
135+
# The FHIR Identifier which is returned in the metadata is based on the IdentifierPK from the database because
136+
# we keep this attribute up to date in case of any changes rather than modifying the JSON resource. For example,
137+
# when we performed the V2 to V5 data migration as part of issue VED-893.
138+
139+
identifier_pk = item.get("IdentifierPK")
140+
141+
if identifier_pk is None:
142+
raise InvalidStoredDataError(data_type="identifier")
143+
144+
identifier = get_fhir_identifier_from_identifier_pk(identifier_pk)
145+
146+
imms_record_meta = ImmunizationRecordMetadata(identifier, int(item.get("Version")), is_deleted, is_reinstated)
123147

124148
return json.loads(item.get("Resource", {})), imms_record_meta
125149

0 commit comments

Comments
 (0)