Skip to content

Commit 5c34807

Browse files
authored
Refactor FhirController to handle larger search responses and improve error handling for oversized queries (#1409)
Refactors FHIR search response handling to serialize once, then reuse that payload for both size validation and response shaping in fhir_controller, reducing duplicate bundle conversion paths. Adds a shared _create_search_response flow so identifier and target-disease searches consistently return a TooManyResultsError when payload size exceeds MAX_RESPONSE_SIZE_BYTES. Adds coverage for oversized target-disease searches to confirm the API returns HTTP 400 with the expected “narrow down the search” diagnostic when response size limits are breached. Expands repository tests to verify find_immunizations paginates across DynamoDB LastEvaluatedKey pages and aggregates results from all returned query pages.
1 parent 639b3c5 commit 5c34807

3 files changed

Lines changed: 89 additions & 18 deletions

File tree

lambdas/backend/src/controller/fhir_controller.py

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def _get_immunization_by_identifier(self, search_params: dict[str, list[str]], s
160160
identifier = Identifier.construct(system=identifier_components[0], value=identifier_components[1])
161161

162162
search_bundle = self.fhir_service.get_immunization_by_identifier(identifier, supplier_system, element)
163-
prepared_search_bundle = self._prepare_search_bundle(search_bundle)
163+
prepared_search_bundle = self._prepare_search_bundle(search_bundle.json())
164164

165165
return create_response(200, prepared_search_bundle)
166166

@@ -177,12 +177,7 @@ def _search_immunizations(self, search_params: dict[str, list[str]], supplier_sy
177177
result.invalid_immunization_targets,
178178
)
179179

180-
if self._has_too_many_search_results(search_bundle):
181-
raise TooManyResultsError("Search returned too many results. Please narrow down the search")
182-
183-
prepared_search_bundle = self._prepare_search_bundle(search_bundle)
184-
185-
return create_response(200, prepared_search_bundle)
180+
return self._create_search_response(search_bundle)
186181

187182
def _search_immunizations_by_target_disease(self, search_params: dict[str, list[str]], supplier_system: str) -> dict:
188183
result = validate_and_retrieve_search_params_by_disease(search_params)
@@ -208,7 +203,15 @@ def _search_immunizations_by_target_disease(self, search_params: dict[str, list[
208203
invalid_target_diseases=result.invalid_target_diseases,
209204
)
210205

211-
prepared_search_bundle = self._prepare_search_bundle(search_bundle)
206+
return self._create_search_response(search_bundle)
207+
208+
def _create_search_response(self, search_bundle: Bundle) -> dict:
209+
search_response_json = search_bundle.json(use_decimal=True)
210+
211+
if len(search_response_json) > MAX_RESPONSE_SIZE_BYTES:
212+
raise TooManyResultsError("Search returned too many results. Please narrow down the search")
213+
214+
prepared_search_bundle = self._prepare_search_bundle(search_response_json)
212215
return create_response(200, prepared_search_bundle)
213216

214217
@staticmethod
@@ -220,11 +223,11 @@ def _is_valid_immunization_id(self, immunization_id: str) -> bool:
220223
return False if not re.match(self._IMMUNIZATION_ID_PATTERN, immunization_id) else True
221224

222225
@staticmethod
223-
def _prepare_search_bundle(search_response: Bundle) -> dict:
226+
def _prepare_search_bundle(search_response_json: str) -> dict:
224227
"""Workaround for fhir.resources dict() or json() removing the empty "entry" list. Team also specified that
225228
total should be the final key in the object. Should investigate if this can be resolved with later version of
226229
the library."""
227-
search_response_dict = json.loads(search_response.json())
230+
search_response_dict = json.loads(search_response_json)
228231

229232
if "entry" not in search_response_dict:
230233
search_response_dict["entry"] = []
@@ -244,13 +247,6 @@ def _is_identifier_search(search_params: dict[str, list[str]]) -> bool:
244247
or IdentifierSearchParameterName.ELEMENTS in search_params
245248
)
246249

247-
@staticmethod
248-
def _has_too_many_search_results(search_response: Bundle) -> bool:
249-
"""Checks whether the response is too large - 6MB Lambda limit. Note: this condition should never happen as it
250-
would require a very large number of vaccs for a single patient. It is also very rudimentary and all it does is
251-
ensure we can raise and return a nice looking error. Consider using pagination as a more robust approach."""
252-
return len(search_response.json(use_decimal=True)) > MAX_RESPONSE_SIZE_BYTES
253-
254250
@staticmethod
255251
def _get_search_params_from_request(
256252
aws_event: APIGatewayProxyEventV1, is_post_endpoint_req: bool

lambdas/backend/tests/controller/test_fhir_controller_target_disease.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,30 @@ def test_search_by_target_disease_with_mixed_valid_and_invalid_returns_200_with_
287287
self.assertEqual(len(call_args[1]["invalid_target_diseases"]), 1)
288288
self.assertIn("invalid-no-pipe", call_args[1]["invalid_target_diseases"][0])
289289
self.assertIn("Invalid format", call_args[1]["invalid_target_diseases"][0])
290+
291+
@patch("controller.fhir_controller.MAX_RESPONSE_SIZE_BYTES", 5)
292+
def test_search_by_target_disease_returns_400_when_response_too_large(self):
293+
"""it should return the same narrow-the-search error for oversized target-disease searches"""
294+
self.mock_redis.hget.side_effect = self._hget_target_disease_codes_and_mmr
295+
self.service.search_immunizations.return_value = Bundle.construct(
296+
entry=[BundleEntry.construct(resource=Immunization.construct(**{"id": "imms-1"}))],
297+
link=[BundleLink.construct(relation="self", url="search-url")],
298+
type="searchset",
299+
total=1,
300+
)
301+
lambda_event = {
302+
"headers": {"SupplierSystem": "test"},
303+
"multiValueQueryStringParameters": {
304+
self.patient_identifier_key: [self.patient_identifier_valid_value],
305+
self.target_disease_key: [f"{self.snomed_system}|{self.measles_code}"],
306+
},
307+
}
308+
309+
response = self.controller.search_immunizations(lambda_event)
310+
response_body = json.loads(response["body"])
311+
312+
self.assertEqual(response["statusCode"], 400)
313+
self.assertEqual(
314+
response_body["issue"][0]["diagnostics"],
315+
"Search returned too many results. Please narrow down the search",
316+
)

lambdas/backend/tests/repository/test_fhir_repository.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import time
22
import unittest
33
import uuid
4-
from unittest.mock import ANY, MagicMock, Mock, patch
4+
from unittest.mock import ANY, MagicMock, Mock, call, patch
55

66
import botocore.exceptions
77
import simplejson as json
@@ -606,6 +606,54 @@ def test_find_immunizations_returns_empty_list_when_not_found(self):
606606
)
607607
self.assertEqual(result, [])
608608

609+
def test_find_immunizations_queries_all_dynamodb_pages(self):
610+
"""it should continue querying DynamoDB until LastEvaluatedKey is exhausted"""
611+
nhs_number = "a-patient-id"
612+
imms1 = {"id": 1, "meta": {"versionId": 1}}
613+
imms2 = {"id": 2, "meta": {"versionId": 2}}
614+
first_page_last_evaluated_key = {"PK": "Immunization#1", "PatientPK": _make_patient_pk(nhs_number)}
615+
self.table.query = MagicMock(
616+
side_effect=[
617+
{
618+
"ResponseMetadata": {"HTTPStatusCode": 200},
619+
"Items": [
620+
{
621+
"Resource": json.dumps(imms1),
622+
"PatientSK": "COVID#some_other_text",
623+
"Version": "1",
624+
}
625+
],
626+
"LastEvaluatedKey": first_page_last_evaluated_key,
627+
},
628+
{
629+
"ResponseMetadata": {"HTTPStatusCode": 200},
630+
"Items": [
631+
{
632+
"Resource": json.dumps(imms2),
633+
"PatientSK": "COVID#some_other_text",
634+
"Version": "2",
635+
}
636+
],
637+
},
638+
]
639+
)
640+
641+
results = self.repository.find_immunizations(nhs_number, {"COVID"})
642+
expected_query_kwargs = {
643+
"IndexName": "PatientGSI",
644+
"KeyConditionExpression": Key("PatientPK").eq(_make_patient_pk(nhs_number)),
645+
"FilterExpression": Attr("DeletedAt").not_exists() | Attr("DeletedAt").eq("reinstated"),
646+
}
647+
648+
self.assertEqual(
649+
self.table.query.call_args_list,
650+
[
651+
call(**expected_query_kwargs),
652+
call(**{**expected_query_kwargs, "ExclusiveStartKey": first_page_last_evaluated_key}),
653+
],
654+
)
655+
self.assertListEqual(results, [imms1, imms2])
656+
609657
def test_find_immunizations_returns_resources_including_meta(self):
610658
"""it should map Resource list into a list of Immunizations"""
611659
imms1 = {"id": 1, "meta": {"versionId": 1}}

0 commit comments

Comments
 (0)