Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
375301f
[NDR-423] FHIR GET and SEARCH by ID
jameslinnell Feb 25, 2026
c7f4616
[NDR-423] Unit tests
jameslinnell Feb 25, 2026
fe67103
[NDR-423] e2e tests
jameslinnell Feb 25, 2026
b2e2dec
[NDR-423] 404 test
jameslinnell Feb 25, 2026
a8d4da2
[NDR-423] Don't return deleted items on get_item
jameslinnell Feb 25, 2026
b19ef80
[NDR-423] Search tests
jameslinnell Feb 25, 2026
af359e8
[NDR-423] Docker fix
jameslinnell Feb 26, 2026
471d5a8
[NDR-423] Update unit tests
jameslinnell Feb 26, 2026
949be15
[NDR-423] upload service now checks PDM by ID
jameslinnell Feb 26, 2026
e33cc2b
[NDR-423] LG FHIR E2E tests fix
jameslinnell Feb 26, 2026
d8e697a
[NDR-423] Update file update
jameslinnell Feb 26, 2026
4ab55b6
[NDR-423] Fix upload test
jameslinnell Feb 26, 2026
e79e758
[NDR-423] LG FHIR test
jameslinnell Feb 26, 2026
f94837e
[NDR-423] Removed now broken 500 error
jameslinnell Feb 27, 2026
d7155af
[NDR-423] Update tests
jameslinnell Mar 3, 2026
6f2464c
[NDR-423] Update tests
jameslinnell Mar 4, 2026
b399b7e
[NDR-423] Fix DocStatus tests
jameslinnell Mar 4, 2026
1db1e18
[NDR-423] Update DocStatus
jameslinnell Mar 4, 2026
b144569
[NDR-423] Remove print
jameslinnell Mar 4, 2026
1d704db
[NDR-423] PR changes
jameslinnell Mar 12, 2026
89e0852
[NDR-423] PR changes
jameslinnell Mar 12, 2026
7b40f1c
[NDR-423] Small change
jameslinnell Mar 12, 2026
6f4eca0
[NDR-423] Fix search tests
jameslinnell Mar 12, 2026
22ad8df
[NDR-423] Search fix
jameslinnell Mar 12, 2026
0d47413
[NDR-423] Fix e2e test help message
jameslinnell Mar 12, 2026
15317c6
[NDR-423] Remove search test file that has been renamed in a previous PR
jameslinnell Mar 12, 2026
b9e43fb
[NDR-423] Removed MTLS 500 error test
jameslinnell Mar 13, 2026
ede15db
[NDR-423] Still return SNOMED
jameslinnell Mar 16, 2026
89d693b
[NDR-436] Use CORE table
jameslinnell Mar 16, 2026
25c3c5f
[NDR-436] More tests
jameslinnell Mar 16, 2026
5c8b2df
[NDR-436] Tests
jameslinnell Mar 16, 2026
b101345
[NDR-436] Lloyd George e2e tests
jameslinnell Mar 16, 2026
e719067
[NDR-436] Fix test
jameslinnell Mar 16, 2026
d5df769
[NDR-436] Remove prints
jameslinnell Mar 17, 2026
e23ea1d
[NDR-436] Trivy in tool-versions
jameslinnell Mar 17, 2026
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
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ python 3.11.14
shellcheck 0.11.0
terraform 1.14.6
terraform-docs 0.20.0
trivy 0.69.2
trivy 0.69.2
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,14 @@ download-api-certs: ## Downloads mTLS certificates (use with dev envs only). Usa
rm -rf ./lambdas/mtls_env_certs/$(WORKSPACE)
./scripts/aws/download-api-certs.sh $(WORKSPACE)

test-lg-fhir-api-e2e: ## Runs LG FHIR API E2E tests. See readme for required environment variables. Usage: make test-fhir-api-e2e-lg CONTAINER=<true|false>
test-lg-fhir-api-e2e: ## Runs LG FHIR API E2E tests. See readme for required environment variables. Usage: make test-lg-fhir-api-e2e CONTAINER=<true|false>
ifeq ($(CONTAINER), true)
cd ./lambdas && PYTHONPATH=. poetry run pytest tests/e2e/api --ignore=tests/e2e/api/fhir -vv
else
cd ./lambdas && ./venv/bin/python3 -m pytest tests/e2e/api --ignore=tests/e2e/api/fhir -vv
endif

test-core-fhir-api-e2e: guard-WORKSPACE ## Runs Core FHIR API E2E tests. Usage: make test-fhir-api-e2e-core WORKSPACE=<workspace> CONTAINER=<true|false>
test-core-fhir-api-e2e: guard-WORKSPACE ## Runs Core FHIR API E2E tests. Usage: make test-core-fhir-api-e2e WORKSPACE=<workspace> CONTAINER=<true|false>
./scripts/test/run-e2e-fhir-api-tests.sh --workspace $(WORKSPACE) --container $(CONTAINER)
rm -rf ./lambdas/mtls_env_certs/$(WORKSPACE)

Expand Down
309 changes: 154 additions & 155 deletions apim/specification.yaml

Large diffs are not rendered by default.

105 changes: 83 additions & 22 deletions lambdas/handlers/get_fhir_document_reference_handler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from enums.lambda_error import LambdaError
import uuid
from typing import Optional, Tuple

from oauthlib.oauth2 import WebApplicationClient

from enums.lambda_error import LambdaError
from enums.mtls import MtlsCommonNames
from enums.snomed_codes import SnomedCodes
from services.base.ssm_service import SSMService
from services.dynamic_configuration_service import DynamicConfigurationService
from services.get_fhir_document_reference_service import GetFhirDocumentReferenceService
Expand All @@ -15,6 +21,7 @@
SearchPatientException,
)
from utils.lambda_handler_utils import extract_bearer_token
from utils.lambda_header_utils import validate_common_name_in_mtls
from utils.lambda_response import ApiGatewayResponse

logger = LoggingService(__name__)
Expand All @@ -29,44 +36,52 @@
"APPCONFIG_ENVIRONMENT",
"PRESIGNED_ASSUME_ROLE",
"CLOUDFRONT_URL",
]
],
)
def lambda_handler(event, context):
try:
common_name = validate_common_name_in_mtls(event.get("requestContext"))
bearer_token = extract_bearer_token(event, context)
selected_role_id = event.get("headers", {}).get("cis2-urid", None)

document_id, snomed_code = extract_document_parameters(event)
if not snomed_code:
snomed_code = _determine_document_type(common_name=common_name)

get_document_service = GetFhirDocumentReferenceService()
document_reference = get_document_service.handle_get_document_reference_request(
snomed_code, document_id
snomed_code,
document_id,
)

if selected_role_id and bearer_token:
verify_user_authorisation(
bearer_token, selected_role_id, document_reference.nhs_number
bearer_token,
selected_role_id,
document_reference.nhs_number,
)

document_reference_response = (
get_document_service.create_document_reference_fhir_response(
document_reference
document_reference,
)
)

logger.info(
f"Successfully retrieved document reference for document_id: {document_id}, snomed_code: {snomed_code}"
f"Successfully retrieved document reference for document_id: {document_id}",
)

return ApiGatewayResponse(
status_code=200, body=document_reference_response, methods="GET"
status_code=200,
body=document_reference_response,
methods="GET",
).create_api_gateway_response()

except GetFhirDocumentReferenceException as exception:
return ApiGatewayResponse(
status_code=exception.status_code,
body=exception.error.create_error_response().create_error_fhir_response(
exception.error.value.get("fhir_coding")
exception.error.value.get("fhir_coding"),
),
methods="GET",
).create_api_gateway_response()
Expand All @@ -75,12 +90,13 @@ def lambda_handler(event, context):
def extract_document_parameters(event):
"""Extract document ID and SNOMED code from path parameters"""
path_params = event.get("pathParameters", {}).get("id", None)
document_id, snomed_code = get_id_and_snomed_from_path_parameters(path_params)
document_id, snomed_code = get_id_from_path_parameters(path_params)

if not document_id or not snomed_code:
logger.error("Missing document id or snomed code in request path parameters.")
if not document_id:
logger.error("Missing document ID in request path parameters.")
raise GetFhirDocumentReferenceException(
400, LambdaError.DocumentReferenceMissingParameters
400,
LambdaError.DocumentReferenceMissingParameters,
)

return document_id, snomed_code
Expand All @@ -100,27 +116,72 @@ def verify_user_authorisation(bearer_token, selected_role_id, nhs_number):

org_ods_code = oidc_service.fetch_user_org_code(userinfo, selected_role_id)
smartcard_role_code, _ = oidc_service.fetch_user_role_code(
userinfo, selected_role_id, "R"
userinfo,
selected_role_id,
"R",
)
except (OidcApiException, AuthorisationException) as e:
logger.error(f"Authorization error: {str(e)}")
raise GetFhirDocumentReferenceException(
403, LambdaError.DocumentReferenceUnauthorised
403,
LambdaError.DocumentReferenceUnauthorised,
)

try:
search_patient_service = SearchPatientDetailsService(
smartcard_role_code, org_ods_code
smartcard_role_code,
org_ods_code,
)
search_patient_service.handle_search_patient_request(nhs_number, False)
except SearchPatientException as e:
raise GetFhirDocumentReferenceException(e.status_code, e.error)


def get_id_and_snomed_from_path_parameters(path_parameters):
"""Extract document ID and SNOMED code from path parameters"""
if path_parameters:
params = path_parameters.split("~")
if len(params) == 2:
return params[1], params[0]
return None, None
def get_id_from_path_parameters(path_parameters) -> Tuple[Optional[str], Optional[str]]:
"""Extract uuid from path parameters.

Accepts:
- '1234~uuid'
- 'uuid'
"""
snomed_code = None
if not path_parameters:
return None, None

params = path_parameters.split("~")
if len(params) > 2:
raise GetFhirDocumentReferenceException(
400,
LambdaError.DocRefInvalidFiles,
)

if len(params) > 1:
snomed_code = params[0] if params[0] else None
doc_id = params[1]
else:
doc_id = params[-1]
if not is_uuid(doc_id):
raise GetFhirDocumentReferenceException(
400,
LambdaError.DocRefInvalidFiles,
)
return doc_id, snomed_code


def is_uuid(value: str) -> bool:
try:
uuid.UUID(value)
return True
except (ValueError, TypeError):
return False


def _determine_document_type(common_name: MtlsCommonNames | None) -> str:
if not common_name:
return SnomedCodes.LLOYD_GEORGE.value.code

if common_name not in MtlsCommonNames:
logger.error(f"mTLS common name {common_name} - is not supported")
raise GetFhirDocumentReferenceException(400, LambdaError.DocRefInvalidType)

return SnomedCodes.PATIENT_DATA.value.code
74 changes: 42 additions & 32 deletions lambdas/services/delete_fhir_document_reference_service.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import uuid
from datetime import datetime, timezone
from typing import Dict, Optional

from botocore.exceptions import ClientError
from pydantic import ValidationError

from enums.document_retention import DocumentRetentionDays
from enums.lambda_error import LambdaError
from enums.metadata_field_names import DocumentReferenceMetadataFields
from enums.mtls import MtlsCommonNames
from enums.snomed_codes import SnomedCode, SnomedCodes
from enums.supported_document_types import SupportedDocumentTypes
from models.document_reference import DocumentReference
from pydantic import ValidationError
from services.document_deletion_service import DocumentDeletionService
from services.document_service import DocumentService
from services.fhir_document_reference_service_base import (
FhirDocumentReferenceServiceBase,
)
from utils.audit_logging_setup import LoggingService
from utils.common_query_filters import NotDeleted
from utils.exceptions import DynamoServiceException, InvalidNhsNumberException
from utils.lambda_exceptions import (
DocumentDeletionServiceException,
Expand All @@ -38,7 +37,8 @@ def __init__(self):
super().__init__()

def process_fhir_document_reference(
self, event: dict = {}
self,
event: dict = {},
) -> list[DocumentReference]:
"""
Process a FHIR Document Reference DELETE request
Expand All @@ -52,7 +52,8 @@ def process_fhir_document_reference(
if any(v is None for v in deletion_identifiers):
logger.error("FHIR document validation error: NhsNumber/id")
raise DocumentRefException(
400, LambdaError.DocumentReferenceMissingParameters
400,
LambdaError.DocumentReferenceMissingParameters,
)

if len(deletion_identifiers) < 2:
Expand All @@ -61,15 +62,17 @@ def process_fhir_document_reference(
if not self.is_uuid(deletion_identifiers[0]):
logger.error("FHIR document validation error: Id")
raise DocumentRefException(
400, LambdaError.DocumentReferenceMissingParameters
400,
LambdaError.DocumentReferenceMissingParameters,
)

doc_type = self._determine_document_type_based_on_common_name(common_name)

if not validate_nhs_number(deletion_identifiers[1]):
logger.error("FHIR document validation error: NhsNumber")
raise DocumentRefException(
400, LambdaError.DocumentReferenceMissingParameters
400,
LambdaError.DocumentReferenceMissingParameters,
)

request_context.patient_nhs_no = deletion_identifiers[1]
Expand All @@ -84,12 +87,9 @@ def process_fhir_document_reference(
fhir=True,
)
else:
files_deleted = (
self.delete_fhir_document_reference_by_nhs_id_and_doc_id(
nhs_number=deletion_identifiers[1],
doc_id=deletion_identifiers[0],
doc_type=doc_type,
)
files_deleted = self.delete_fhir_document_reference_by_doc_id(
doc_id=deletion_identifiers[0],
doc_type=doc_type,
)
return files_deleted

Expand All @@ -100,14 +100,15 @@ def process_fhir_document_reference(
logger.error(f"AWS client error: {str(e)}")
raise DocumentRefException(500, LambdaError.InternalServerError)

def delete_fhir_document_reference_by_nhs_id_and_doc_id(
self, nhs_number: str, doc_id: str, doc_type: SnomedCode
def delete_fhir_document_reference_by_doc_id(
self,
doc_id: str,
doc_type: SnomedCode,
) -> DocumentReference | None:
dynamo_table = self._get_dynamo_table_for_doc_type(doc_type)
document_service = DocumentService()
document = document_service.get_item_agnostic(
partion_key={DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number},
sort_key={DocumentReferenceMetadataFields.ID.value: doc_id},
partition_key={DocumentReferenceMetadataFields.ID.value: doc_id},
table_name=dynamo_table,
)
if not document:
Expand All @@ -119,7 +120,6 @@ def delete_fhir_document_reference_by_nhs_id_and_doc_id(
document_ttl_days=DocumentRetentionDays.SOFT_DELETE,
key_pair={
DocumentReferenceMetadataFields.ID.value: doc_id,
DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number,
},
)
logger.info(
Expand All @@ -134,30 +134,38 @@ def delete_fhir_document_reference_by_nhs_id_and_doc_id(
)
raise DocumentDeletionServiceException(500, LambdaError.DocDelClient)

def delete_fhir_document_references_by_nhs_id(
self, nhs_number: str, doc_type: SnomedCode
) -> list[DocumentReference] | None:
def delete_fhir_document_reference_by_nhs_id_and_doc_id(
self,
nhs_number: str,
doc_id: str,
doc_type: SnomedCode,
) -> DocumentReference | None:
dynamo_table = self._get_dynamo_table_for_doc_type(doc_type)
document_service = DocumentService()
documents = document_service.fetch_documents_from_table(
search_condition=nhs_number,
search_key="NhsNumber",
document = document_service.get_item_agnostic(
partition_key={
DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number,
},
sort_key={DocumentReferenceMetadataFields.ID.value: doc_id},
table_name=dynamo_table,
query_filter=NotDeleted,
)
if not documents:
if not document:
return None
try:
document_service.delete_document_references(
document_service.delete_document_reference(
table_name=dynamo_table,
document_references=documents,
document_reference=document,
document_ttl_days=DocumentRetentionDays.SOFT_DELETE,
key_pair={
DocumentReferenceMetadataFields.ID.value: doc_id,
DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number,
},
)
logger.info(
f"Deleted document of type {doc_type.display_name}",
{"Result": "Successful deletion"},
)
return documents
return document
except (ClientError, DynamoServiceException) as e:
logger.error(
f"{LambdaError.DocDelClient.to_str()}: {str(e)}",
Expand All @@ -166,7 +174,8 @@ def delete_fhir_document_references_by_nhs_id(
raise DocumentDeletionServiceException(500, LambdaError.DocDelClient)

def _determine_document_type_based_on_common_name(
self, common_name: MtlsCommonNames | None
self,
common_name: MtlsCommonNames | None,
) -> SnomedCode:
if not common_name:
"""Determine the document type based on common_name"""
Expand All @@ -186,7 +195,7 @@ def is_uuid(self, value: str) -> bool:

def extract_parameters(self, event) -> list[str]:
nhs_id, document_reference_id = self.extract_document_query_parameters(
event.get("queryStringParameters") or {}
event.get("queryStringParameters") or {},
)

if not nhs_id or not document_reference_id:
Expand All @@ -201,7 +210,8 @@ def extract_document_path_parameters(self, event):
return doc_ref_id

def extract_document_query_parameters(
self, query_string: Dict[str, str]
self,
query_string: Dict[str, str],
) -> tuple[Optional[str], Optional[str]]:
nhs_number = None
document_reference_id = None
Expand Down
Loading
Loading