Skip to content

Commit ffd2748

Browse files
authored
Merge branch 'master' into VED-1042-add-hib-menc
2 parents c28805d + 779676e commit ffd2748

11 files changed

Lines changed: 220 additions & 52 deletions

File tree

lambdas/backend/src/controller/fhir_controller.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,16 @@ def _get_immunization_by_identifier(self, search_params: dict[str, list[str]], s
154154
return create_response(200, prepared_search_bundle)
155155

156156
def _search_immunizations(self, search_params: dict[str, list[str]], supplier_system: str) -> dict:
157-
validated_search_params = validate_and_retrieve_search_params(search_params)
157+
result = validate_and_retrieve_search_params(search_params)
158158

159159
search_bundle = self.fhir_service.search_immunizations(
160-
validated_search_params.patient_identifier,
161-
validated_search_params.immunization_targets,
160+
result.params.patient_identifier,
161+
result.params.immunization_targets,
162162
supplier_system,
163-
validated_search_params.date_from,
164-
validated_search_params.date_to,
165-
validated_search_params.include,
163+
result.params.date_from,
164+
result.params.date_to,
165+
result.params.include,
166+
result.invalid_immunization_targets,
166167
)
167168

168169
if self._has_too_many_search_results(search_bundle):

lambdas/backend/src/controller/parameter_parser.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import datetime
2-
from dataclasses import dataclass
2+
from dataclasses import dataclass, field
33
from typing import Optional
44

55
from common.models.constants import RedisHashKeys
@@ -33,6 +33,12 @@ def __repr__(self):
3333
return str(self.__dict__)
3434

3535

36+
@dataclass
37+
class SearchParamsResult:
38+
params: SearchParams
39+
invalid_immunization_targets: list[str] = field(default_factory=list)
40+
41+
3642
def process_patient_identifier(identifier_params: dict[str, list[str]]) -> str:
3743
"""Validate and parse patient identifier parameter.
3844
@@ -65,10 +71,9 @@ def process_patient_identifier(identifier_params: dict[str, list[str]]) -> str:
6571
return nhs_number
6672

6773

68-
def process_immunization_target(imms_params: dict[str, list[str]]) -> set[str]:
69-
"""Validate and parse immunization target parameter.
70-
71-
:raises ParameterExceptionError:
74+
def process_immunization_target(imms_params: dict[str, list[str]]) -> tuple[list[str], list[str]]:
75+
"""Validate and parse immunization target parameter. Returns (valid_vaccine_types, invalid_vaccine_types).
76+
Raises ParameterExceptionError only when no values provided or all values are invalid.
7277
"""
7378
vaccine_types = [
7479
vaccine_type
@@ -81,24 +86,27 @@ def process_immunization_target(imms_params: dict[str, list[str]]) -> set[str]:
8186
f"Search parameter {ImmunizationSearchParameterName.IMMUNIZATION_TARGET} must have one or more values."
8287
)
8388

84-
valid_vaccine_types = get_redis_client().hkeys(RedisHashKeys.VACCINE_TYPE_TO_DISEASES_HASH_KEY)
85-
if any(x not in valid_vaccine_types for x in vaccine_types):
89+
valid_vaccine_types_set = set(get_redis_client().hkeys(RedisHashKeys.VACCINE_TYPE_TO_DISEASES_HASH_KEY))
90+
valid = [v for v in vaccine_types if v in valid_vaccine_types_set]
91+
invalid = [v for v in vaccine_types if v not in valid_vaccine_types_set]
92+
93+
if not valid:
8694
raise ParameterExceptionError(
8795
f"{ImmunizationSearchParameterName.IMMUNIZATION_TARGET} must be one or more of the following: "
88-
f"{', '.join(sorted(valid_vaccine_types))}"
96+
f"{', '.join(sorted(valid_vaccine_types_set))}"
8997
)
9098

91-
return set(vaccine_types)
99+
return valid, invalid
92100

93101

94-
def process_mandatory_params(params: dict[str, list[str]]) -> tuple[str, set[str]]:
95-
"""Validate mandatory params and return (patient_identifier, vaccine_types).
102+
def process_mandatory_params(params: dict[str, list[str]]) -> tuple[str, list[str], list[str]]:
103+
"""Validate mandatory params and return (patient_identifier, valid_vaccine_types, invalid_vaccine_types).
96104
Raises ParameterExceptionError for any validation error.
97105
"""
98106
patient_identifier = process_patient_identifier(params)
99-
vaccine_types = process_immunization_target(params)
107+
vaccine_types, invalid_vaccine_types = process_immunization_target(params)
100108

101-
return patient_identifier, vaccine_types
109+
return patient_identifier, vaccine_types, invalid_vaccine_types
102110

103111

104112
def process_optional_params(
@@ -146,11 +154,11 @@ def process_optional_params(
146154
return date_from, date_to, include
147155

148156

149-
def validate_and_retrieve_search_params(params: dict[str, list[str]]) -> SearchParams:
157+
def validate_and_retrieve_search_params(params: dict[str, list[str]]) -> SearchParamsResult:
150158
"""Validate and retrieve search parameters.
151159
:raises ParameterExceptionError:
152160
"""
153-
patient_identifier, vaccine_types = process_mandatory_params(params)
161+
patient_identifier, vaccine_types, invalid_vaccine_types = process_mandatory_params(params)
154162
date_from, date_to, include = process_optional_params(params)
155163

156164
if date_from and date_to and date_from > date_to:
@@ -159,7 +167,8 @@ def validate_and_retrieve_search_params(params: dict[str, list[str]]) -> SearchP
159167
f"{ImmunizationSearchParameterName.DATE_TO}"
160168
)
161169

162-
return SearchParams(patient_identifier, vaccine_types, date_from, date_to, include)
170+
search_params = SearchParams(patient_identifier, set(vaccine_types), date_from, date_to, include)
171+
return SearchParamsResult(params=search_params, invalid_immunization_targets=invalid_vaccine_types)
163172

164173

165174
def parse_search_params(search_params_in_req: dict[str, list[str]]) -> dict[str, list[str]]:

lambdas/backend/src/service/fhir_service.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def search_immunizations(
198198
date_from: Optional[datetime.date],
199199
date_to: Optional[datetime.date],
200200
include: Optional[str],
201+
invalid_immunization_targets: Optional[list[str]] = None,
201202
) -> FhirBundle:
202203
"""
203204
Finds all instances of Immunization(s) for a specified patient for the given specified vaccine type(s).
@@ -262,6 +263,21 @@ def search_immunizations(
262263
)
263264
)
264265

266+
if invalid_immunization_targets:
267+
invalid_list = ", ".join(sorted(invalid_immunization_targets))
268+
entries.append(
269+
BundleEntry(
270+
resource=OperationOutcome.construct(
271+
**create_operation_outcome(
272+
resource_id=str(uuid.uuid4()),
273+
severity=Severity.warning,
274+
code=Code.invalid,
275+
diagnostics=f"Your search included invalid -immunization.target value(s) that were ignored: {invalid_list}. The search was performed using the valid value(s) only.",
276+
)
277+
)
278+
)
279+
)
280+
265281
return FhirBundle(
266282
type="searchset",
267283
entry=entries,

lambdas/backend/tests/controller/test_fhir_controller.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -995,7 +995,7 @@ def test_search_immunizations_is_successful(self):
995995

996996
# Then
997997
self.service.search_immunizations.assert_called_once_with(
998-
self.nhs_number_valid_value, {vaccine_type}, "test", None, None, None
998+
self.nhs_number_valid_value, {vaccine_type}, "test", None, None, None, []
999999
)
10001000
self.assertEqual(response["statusCode"], 200)
10011001
self.assertEqual(
@@ -1038,7 +1038,7 @@ def test_search_immunizations_is_successful_when_using_the_post_endpoint(self):
10381038

10391039
# Then
10401040
self.service.search_immunizations.assert_called_once_with(
1041-
self.nhs_number_valid_value, {vaccine_type}, "test", None, None, None
1041+
self.nhs_number_valid_value, {vaccine_type}, "test", None, None, None, []
10421042
)
10431043
self.assertEqual(response["statusCode"], 200)
10441044
self.assertEqual(
@@ -1111,17 +1111,17 @@ def test_search_immunizations_returns_a_validation_error_when_patient_identifier
11111111
)
11121112
self.service.search_immunizations.assert_not_called()
11131113

1114-
def test_search_immunizations_returns_a_validation_error_when_immunization_target_invalid(self):
1115-
"""it should return a 400 invalid error for multiple invalid -immunization.target scenarios"""
1114+
def test_search_immunizations_returns_400_when_immunization_target_empty_or_all_invalid(self):
1115+
"""it should return 400 when -immunization.target has no values or only invalid values"""
11161116
test_cases = [
11171117
([], "Search parameter -immunization.target must have one or more values."),
1118-
(["COVID,FLU,CHICKENS"], "-immunization.target must be one or more of the following: COVID, FLU"),
1118+
(["CHICKENS"], "-immunization.target must be one or more of the following: COVID, FLU"),
11191119
]
11201120

1121-
for test_patient_id, expected_error in test_cases:
1121+
for target_values, expected_error in test_cases:
11221122
with self.subTest(msg=expected_error):
11231123
test_lambda_event = copy.deepcopy(self.test_lambda_event)
1124-
test_lambda_event["multiValueQueryStringParameters"]["-immunization.target"] = test_patient_id
1124+
test_lambda_event["multiValueQueryStringParameters"]["-immunization.target"] = target_values
11251125

11261126
# When
11271127
response = self.controller.search_immunizations(test_lambda_event)
@@ -1134,6 +1134,32 @@ def test_search_immunizations_returns_a_validation_error_when_immunization_targe
11341134
)
11351135
self.service.search_immunizations.assert_not_called()
11361136

1137+
def test_search_immunizations_returns_200_with_operation_outcome_for_invalid_targets(self):
1138+
"""it should return searchset with data for valid targets and OperationOutcome for invalid -immunization.target"""
1139+
self.service.search_immunizations.return_value = Bundle.construct(
1140+
entry=[BundleEntry.construct(resource=Immunization.construct(**{"id": "something"}))],
1141+
link=[BundleLink.construct(relation="self", url="patient-search-url")],
1142+
type="searchset",
1143+
total=1,
1144+
)
1145+
test_lambda_event = copy.deepcopy(self.test_lambda_event)
1146+
test_lambda_event["multiValueQueryStringParameters"]["-immunization.target"] = ["COVID,FLU,CHICKENS"]
1147+
1148+
# When
1149+
response = self.controller.search_immunizations(test_lambda_event)
1150+
1151+
# Then
1152+
self.assertEqual(response["statusCode"], 200)
1153+
self.service.search_immunizations.assert_called_once_with(
1154+
self.nhs_number_valid_value,
1155+
{"COVID", "FLU"},
1156+
"test",
1157+
datetime.date(2000, 1, 1),
1158+
datetime.date(2024, 1, 1),
1159+
"Immunization:patient",
1160+
["CHICKENS"],
1161+
)
1162+
11371163
def test_search_immunizations_returns_a_validation_error_when_optional_params_invalid(self):
11381164
"""it should return a 400 invalid error for multiple invalid optional parameter scenarios"""
11391165
test_cases = [
@@ -1186,6 +1212,7 @@ def test_search_immunizations_raises_error_if_too_many_results_found(self):
11861212
datetime.date(2000, 1, 1),
11871213
datetime.date(2024, 1, 1),
11881214
"Immunization:patient",
1215+
[],
11891216
)
11901217
self.assertEqual(response["statusCode"], 400)
11911218
self.assertEqual(

lambdas/backend/tests/controller/test_parameter_parser.py

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,43 +43,50 @@ def test_process_search_params_checks_patient_identifier_format(self):
4343
}
4444
)
4545

46-
def test_process_search_params_whitelists_immunization_target(self):
47-
mock_redis_key = "RSV"
48-
self.mock_redis.hkeys.return_value = [mock_redis_key]
46+
def test_process_search_params_raises_error_when_all_immunization_targets_invalid(self):
47+
self.mock_redis.hkeys.return_value = ["RSV"]
4948
self.mock_redis_getter.return_value = self.mock_redis
5049

5150
with self.assertRaises(ParameterExceptionError) as e:
5251
validate_and_retrieve_search_params(
5352
{
5453
self.patient_identifier_key: ["https://fhir.nhs.uk/Id/nhs-number|9000000009"],
55-
self.immunization_target_key: [
56-
"FLU",
57-
"COVID",
58-
"NOT-A-REAL-VALUE",
59-
],
54+
self.immunization_target_key: ["NOT-A-REAL-VALUE"],
6055
}
6156
)
62-
self.assertEqual(
63-
str(e.exception), f"-immunization.target must be one or more of the following: {mock_redis_key}"
57+
self.assertEqual(str(e.exception), "-immunization.target must be one or more of the following: RSV")
58+
59+
def test_process_search_params_filters_invalid_immunization_targets_and_returns_valid_plus_invalid_list(self):
60+
self.mock_redis.hkeys.return_value = ["RSV", "FLU", "COVID"]
61+
self.mock_redis_getter.return_value = self.mock_redis
62+
63+
result = validate_and_retrieve_search_params(
64+
{
65+
self.patient_identifier_key: ["https://fhir.nhs.uk/Id/nhs-number|9000000009"],
66+
self.immunization_target_key: ["FLU", "COVID", "NOT-A-REAL-VALUE"],
67+
}
6468
)
69+
self.assertCountEqual(result.params.immunization_targets, {"FLU", "COVID"})
70+
self.assertCountEqual(result.invalid_immunization_targets, ["NOT-A-REAL-VALUE"])
6571

6672
def test_process_search_params_immunization_target(self):
6773
mock_redis_key = "RSV"
6874
self.mock_redis.hkeys.return_value = [mock_redis_key]
6975
self.mock_redis_getter.return_value = self.mock_redis
70-
params = validate_and_retrieve_search_params(
76+
result = validate_and_retrieve_search_params(
7177
{
7278
self.patient_identifier_key: ["https://fhir.nhs.uk/Id/nhs-number|9000000009"],
7379
self.immunization_target_key: ["RSV"],
7480
}
7581
)
7682

77-
self.assertIsNotNone(params)
83+
self.assertIsNotNone(result.params)
84+
self.assertEqual(result.invalid_immunization_targets, [])
7885

7986
def test_search_params_date_from_must_be_before_date_to(self):
8087
self.mock_redis.hkeys.return_value = ["RSV"]
8188
self.mock_redis_getter.return_value = self.mock_redis
82-
params = validate_and_retrieve_search_params(
89+
result = validate_and_retrieve_search_params(
8390
{
8491
self.patient_identifier_key: ["https://fhir.nhs.uk/Id/nhs-number|9000000009"],
8592
self.immunization_target_key: ["RSV"],
@@ -88,9 +95,9 @@ def test_search_params_date_from_must_be_before_date_to(self):
8895
}
8996
)
9097

91-
self.assertIsNotNone(params)
98+
self.assertIsNotNone(result.params)
9299

93-
params = validate_and_retrieve_search_params(
100+
result = validate_and_retrieve_search_params(
94101
{
95102
self.patient_identifier_key: ["https://fhir.nhs.uk/Id/nhs-number|9000000009"],
96103
self.immunization_target_key: ["RSV"],
@@ -99,7 +106,7 @@ def test_search_params_date_from_must_be_before_date_to(self):
99106
}
100107
)
101108

102-
self.assertIsNotNone(params)
109+
self.assertIsNotNone(result.params)
103110

104111
with self.assertRaises(ParameterExceptionError) as e:
105112
_ = validate_and_retrieve_search_params(
@@ -149,7 +156,7 @@ def test_process_search_params_dedupes_immunization_targets_and_respects_include
149156
self.mock_redis.hkeys.return_value = ["RSV", "FLU"]
150157
self.mock_redis_getter.return_value = self.mock_redis
151158

152-
params = validate_and_retrieve_search_params(
159+
result = validate_and_retrieve_search_params(
153160
{
154161
self.patient_identifier_key: ["https://fhir.nhs.uk/Id/nhs-number|9000000009"],
155162
self.immunization_target_key: ["RSV", "RSV", "FLU"],
@@ -158,11 +165,11 @@ def test_process_search_params_dedupes_immunization_targets_and_respects_include
158165
)
159166

160167
# immunization targets should be deduped and preserve valid entries
161-
self.assertIsInstance(params.immunization_targets, set)
162-
self.assertCountEqual(params.immunization_targets, {"RSV", "FLU"})
168+
self.assertIsInstance(result.params.immunization_targets, set)
169+
self.assertCountEqual(result.params.immunization_targets, {"RSV", "FLU"})
163170

164171
# include should be returned as provided
165-
self.assertEqual(params.include, "immunization:patient")
172+
self.assertEqual(result.params.include, "immunization:patient")
166173

167174
def test_process_search_params_raises_date_errors(self):
168175
"""When multiple date-related errors occur they should be returned together."""

lambdas/backend/tests/service/test_fhir_service.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,3 +966,39 @@ def test_search_raises_unauthorised_error_if_no_permissions(self):
966966
self.MOCK_SUPPLIER_SYSTEM_NAME, ApiOperationCode.SEARCH, {vaccine_type}
967967
)
968968
self.imms_repo.find_immunizations.assert_not_called()
969+
970+
def test_search_immunizations_includes_operation_outcome_when_invalid_immunization_targets_provided(self):
971+
"""it should include an OperationOutcome in the bundle when invalid -immunization.target values were provided"""
972+
mock_resource = create_covid_immunization_dict("1234-some-id")
973+
vaccine_type = "COVID"
974+
self.authoriser.filter_permitted_vacc_types.return_value = {vaccine_type}
975+
self.imms_repo.find_immunizations.return_value = [mock_resource]
976+
977+
result = self.fhir_service.search_immunizations(
978+
VALID_NHS_NUMBER,
979+
{vaccine_type},
980+
self.MOCK_SUPPLIER_SYSTEM_NAME,
981+
None,
982+
None,
983+
None,
984+
invalid_immunization_targets=["TEST_VALUE", "CHICKENS"],
985+
)
986+
987+
self.assertEqual(result.type, "searchset")
988+
self.assertEqual(result.total, 1)
989+
self.assertEqual(len(result.entry), 3)
990+
self.assertEqual(result.entry[0].resource.resource_type, "Immunization")
991+
self.assertEqual(result.entry[1].resource.resource_type, "Patient")
992+
self.assertEqual(result.entry[2].resource.resource_type, "OperationOutcome")
993+
issue_0 = result.entry[2].resource.issue[0]
994+
diagnostics = issue_0["diagnostics"] if isinstance(issue_0, dict) else issue_0.diagnostics
995+
self.assertIn("TEST_VALUE", diagnostics)
996+
self.assertIn("CHICKENS", diagnostics)
997+
self.assertIn("invalid -immunization.target value(s) that were ignored", diagnostics)
998+
self.assertEqual(
999+
result.link[0].url,
1000+
"https://internal-dev.api.service.nhs.uk/immunisation-fhir-api/FHIR/R4/Immunization"
1001+
"?immunization.target=COVID"
1002+
"&-immunization.target=COVID"
1003+
"&patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9990548609",
1004+
)

lambdas/shared/src/common/api_clients/errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def to_operation_outcome() -> dict:
3838
return create_operation_outcome(
3939
resource_id=str(uuid.uuid4()),
4040
severity=Severity.error,
41-
code=Code.invalid,
41+
code=Code.invalid_access_token,
4242
diagnostics=msg,
4343
)
4444

lambdas/shared/src/common/models/errors.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
class Code(str, Enum):
77
forbidden = "forbidden"
88
not_found = "not-found"
9-
invalid = "invalid or missing access token"
9+
invalid = "invalid"
10+
invalid_access_token = "invalid access token"
1011
exception = "exception"
1112
server_error = "internal server error"
1213
invariant = "invariant"

0 commit comments

Comments
 (0)