diff --git a/.github/workflows/base-lambdas-reusable-deploy-all.yml b/.github/workflows/base-lambdas-reusable-deploy-all.yml index 04793ab16..2d937cccc 100644 --- a/.github/workflows/base-lambdas-reusable-deploy-all.yml +++ b/.github/workflows/base-lambdas-reusable-deploy-all.yml @@ -682,3 +682,17 @@ jobs: lambda_layer_names: "core_lambda_layer" secrets: AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }} + + deploy_get_document_reference_by_id_lambda: + name: Deploy get_document_reference_lambda + uses: ./.github/workflows/base-lambdas-reusable-deploy.yml + with: + environment: ${{ inputs.environment}} + python_version: ${{ inputs.python_version }} + build_branch: ${{ inputs.build_branch}} + sandbox: ${{ inputs.sandbox }} + lambda_handler_name: get_document_reference_handler + lambda_aws_name: GetDocRefLambda + lambda_layer_names: "core_lambda_layer" + secrets: + AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }} diff --git a/lambdas/handlers/get_document_reference_handler.py b/lambdas/handlers/get_document_reference_handler.py new file mode 100644 index 000000000..08c985693 --- /dev/null +++ b/lambdas/handlers/get_document_reference_handler.py @@ -0,0 +1,55 @@ +from utils.decorators.handle_lambda_exceptions import handle_lambda_exceptions +from utils.decorators.override_error_check import override_error_check +from services.feature_flags_service import FeatureFlagService +from enums.feature_flags import FeatureFlags +from services.get_document_reference_service import GetDocumentReferenceService +from utils.decorators.validate_patient_id import validate_patient_id +from utils.lambda_exceptions import FeatureFlagsException +from enums.lambda_error import LambdaError +from utils.lambda_exceptions import GetDocumentRefException +from utils.lambda_response import ApiGatewayResponse +from utils.decorators.ensure_env_var import ensure_environment_variables +from utils.decorators.set_audit_arg import set_request_context_for_logging +from enums.logging_app_interaction import LoggingAppInteraction +from utils.audit_logging_setup import LoggingService +from utils.request_context import request_context +import json + +logger = LoggingService(__name__) + +@validate_patient_id +@handle_lambda_exceptions +@set_request_context_for_logging +@ensure_environment_variables( + names=[ + "LLOYD_GEORGE_DYNAMODB_NAME", + "PRESIGNED_ASSUME_ROLE", + "APPCONFIG_APPLICATION", + "APPCONFIG_ENVIRONMENT", + "APPCONFIG_CONFIGURATION", + "EDGE_REFERENCE_TABLE", + "CLOUDFRONT_URL", + ] +) +@override_error_check +def lambda_handler(event: dict[str, any], context): + request_context.app_interaction = LoggingAppInteraction.VIEW_LG_RECORD.value + + feature_flag_service = FeatureFlagService() + feature_flag_service.validate_feature_flag(FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED) + + logger.info("Starting document fetch by ID process") + + try: + document_id = event["pathParameters"]["id"] + nhs_number = event["queryStringParameters"]["patientId"] + except KeyError: + raise GetDocumentRefException(400, LambdaError.DocumentReferenceMissingParameters) + + service = GetDocumentReferenceService() + + document_info = service.get_document_url_by_id(document_id, nhs_number) + + return ApiGatewayResponse( + status_code=200, body=json.dumps(document_info), methods="GET" + ).create_api_gateway_response() \ No newline at end of file diff --git a/lambdas/services/feature_flags_service.py b/lambdas/services/feature_flags_service.py index 01413c41b..8e750992e 100644 --- a/lambdas/services/feature_flags_service.py +++ b/lambdas/services/feature_flags_service.py @@ -155,4 +155,4 @@ def validate_feature_flag(self, flag_name: str): if not flag_object.get(flag_name, False): logger.info(f"Feature flag '{flag_name}' not enabled, event will not be processed") - raise FeatureFlagsException(404, LambdaError.FeatureFlagDisabled) \ No newline at end of file + raise FeatureFlagsException(404, LambdaError.FeatureFlagDisabled) diff --git a/lambdas/services/get_document_reference_service.py b/lambdas/services/get_document_reference_service.py new file mode 100644 index 000000000..56fd700fd --- /dev/null +++ b/lambdas/services/get_document_reference_service.py @@ -0,0 +1,83 @@ +import os +import uuid +from datetime import datetime, timezone +from services.get_fhir_document_reference_service import ( + GetFhirDocumentReferenceService, +) +from utils.audit_logging_setup import LoggingService +from utils.lambda_exceptions import GetDocumentRefException +from enums.lambda_error import LambdaError +from utils.utilities import format_cloudfront_url +from models.document_reference import DocumentReference +from utils.dynamo_query_filter_builder import DynamoQueryFilterBuilder +from enums.dynamo_filter import AttributeOperator +from utils.common_query_filters import NotDeleted + +logger = LoggingService(__name__) + +class GetDocumentReferenceService: + def __init__(self): + self.fhir_doc_service = GetFhirDocumentReferenceService() + self.document_service = self.fhir_doc_service.document_service + self.dynamo_service = self.document_service.dynamo_service + self.s3_service = self.fhir_doc_service.s3_service + self.lg_table = os.environ.get("LLOYD_GEORGE_DYNAMODB_NAME") + self.cloudfront_table_name = os.environ.get("EDGE_REFERENCE_TABLE") + self.cloudfront_url = os.environ.get("CLOUDFRONT_URL") + + def get_document_url_by_id(self, document_id: str, nhs_number: str): + document_reference = self.get_document_reference(document_id, nhs_number) + + presigned_s3_url = self.create_document_presigned_url( + document_reference.s3_bucket_name, + document_reference.s3_file_key + ) + + return { + "url": presigned_s3_url, + "contentType": document_reference.content_type + } + + def create_document_presigned_url(self, bucket_name, file_location): + presigned_url_response = self.s3_service.create_download_presigned_url( + s3_bucket_name=bucket_name, + file_key=file_location, + ) + + presigned_id = str(uuid.uuid4()) + deletion_date = datetime.now(timezone.utc) + + ttl_half_an_hour_in_seconds = self.s3_service.presigned_url_expiry + dynamo_item_ttl = int(deletion_date.timestamp() + ttl_half_an_hour_in_seconds) + self.dynamo_service.create_item( + self.cloudfront_table_name, + { + "ID": presigned_id, + "presignedUrl": presigned_url_response, + "TTL": dynamo_item_ttl, + }, + ) + return format_cloudfront_url(presigned_id, self.cloudfront_url) + + def get_document_reference(self, document_id: str, nhs_number: str) -> DocumentReference: + filter_builder = DynamoQueryFilterBuilder() + filter_builder.add_condition("DocStatus", AttributeOperator.EQUAL, "final") + filter_builder.add_condition("NhsNumber", AttributeOperator.EQUAL, nhs_number) + + table_filter = filter_builder.build() + + table_filter = table_filter & NotDeleted + + documents = self.document_service.fetch_documents_from_table( + table_name=self.lg_table, + search_condition=document_id, + search_key="ID", + query_filter=table_filter, + ) + if len(documents) > 0: + logger.info("Document found for given id") + return documents[0] + else: + raise GetDocumentRefException( + 404, LambdaError.DocumentReferenceNotFound + ) \ No newline at end of file diff --git a/lambdas/tests/unit/conftest.py b/lambdas/tests/unit/conftest.py index aa6a762ca..505c503d3 100644 --- a/lambdas/tests/unit/conftest.py +++ b/lambdas/tests/unit/conftest.py @@ -140,6 +140,8 @@ MOCK_DOCUMENT_REVIEW_BUCKET = "test_document_review_bucket" MOCK_EDGE_TABLE = "test_edge_reference_table" +MOCK_EDGE_REFERENCE_TABLE = "test_edge_reference_table" + @pytest.fixture def set_env(monkeypatch): monkeypatch.setenv("AWS_DEFAULT_REGION", REGION_NAME) @@ -232,6 +234,7 @@ def set_env(monkeypatch): monkeypatch.setenv("EDGE_REFERENCE_TABLE", MOCK_EDGE_TABLE) monkeypatch.setenv("STAGING_STORE_BUCKET_NAME", MOCK_STAGING_STORE_BUCKET) monkeypatch.setenv("METADATA_SQS_QUEUE_URL", MOCK_LG_METADATA_SQS_QUEUE) + monkeypatch.setenv("EDGE_REFERENCE_TABLE", MOCK_EDGE_REFERENCE_TABLE) EXPECTED_PARSED_PATIENT_BASE_CASE = PatientDetails( givenName=["Jane"], diff --git a/lambdas/tests/unit/handlers/test_get_document_reference_handler.py b/lambdas/tests/unit/handlers/test_get_document_reference_handler.py new file mode 100644 index 000000000..d75fb3dc4 --- /dev/null +++ b/lambdas/tests/unit/handlers/test_get_document_reference_handler.py @@ -0,0 +1,154 @@ +import pytest +import json +import os +from unittest.mock import patch +from handlers.get_document_reference_handler import lambda_handler +from utils.lambda_exceptions import GetDocumentRefException +from enums.feature_flags import FeatureFlags +from utils.lambda_response import ApiGatewayResponse +from utils.lambda_exceptions import FeatureFlagsException +from enums.lambda_error import LambdaError +from utils.error_response import ErrorResponse + + +@pytest.fixture +def mock_feature_flag_service(mocker): + yield mocker.patch("handlers.get_document_reference_handler.FeatureFlagService").return_value + +@pytest.fixture +def mock_get_document_service(mocker): + yield mocker.patch( + "handlers.get_document_reference_handler.GetDocumentReferenceService" + ).return_value + +@pytest.fixture +def mock_valid_nhs_number(): + yield "4407064188" + +@pytest.fixture +def feature_flag(): + yield FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED + +@pytest.fixture +def mock_interaction_id(): + yield "88888888-4444-4444-4444-121212121212" + +@pytest.fixture +def mocked_bad_env_vars(): + env_vars = { + #"LLOYD_GEORGE_DYNAMODB_NAME": "mock_dynamodb_name", + "PRESIGNED_ASSUME_ROLE": "mock_presigned_role", + "APPCONFIG_APPLICATION": "mock_value", + "APPCONFIG_ENVIRONMENT": "mock_value", + "APPCONFIG_CONFIGURATION": "mock_value", + "EDGE_REFERENCE_TABLE": "mock_value", + "CLOUDFRONT_URL": "mock_value", + } + + with patch.dict(os.environ, env_vars): + yield "LLOYD_GEORGE_DYNAMODB_NAME" + + + +def test_handler_valid_request_returns_200( + valid_id_event_with_auth_header, + mock_feature_flag_service, + mock_get_document_service, + mock_valid_nhs_number, + context, + set_env, + feature_flag + ): + mock_document_id = "1" + valid_id_event_with_auth_header["pathParameters"] = {"id": mock_document_id} + valid_id_event_with_auth_header["queryStringParameters"]["patientId"] = mock_valid_nhs_number + mock_presigned_s3_url = "https://mock.url/" + mock_content_type = "application/pdf" + + expected_body = { + "url": mock_presigned_s3_url, + "contentType": mock_content_type + } + + expected_result = ApiGatewayResponse( + status_code=200, body=json.dumps(expected_body), methods="GET" + ).create_api_gateway_response() + + mock_get_document_service.get_document_url_by_id.return_value = expected_body + + result = lambda_handler(valid_id_event_with_auth_header, context) + + assert result == expected_result + assert result["body"] == json.dumps(expected_body) + + mock_feature_flag_service.validate_feature_flag.assert_called_once_with( + feature_flag + ) + mock_get_document_service.get_document_url_by_id.assert_called_once_with( + mock_document_id, + mock_valid_nhs_number) + +def test_missing_nhs_number_errors( + valid_id_event_with_auth_header, + mock_feature_flag_service, + context, + set_env, + feature_flag, + ): + valid_id_event_with_auth_header["pathParameters"] = {"id": "1"} + valid_id_event_with_auth_header["queryStringParameters"].pop("patientId") + + expected_result = ApiGatewayResponse( + status_code=400, + body=LambdaError.PatientIdNoKey.create_error_body(), + methods="GET", + ).create_api_gateway_response() + + result = lambda_handler(valid_id_event_with_auth_header, context) + + assert result == expected_result + +def test_missing_document_id_errors( + valid_id_event_with_auth_header, + mock_feature_flag_service, + mock_valid_nhs_number, + context, + set_env, + feature_flag, + mock_interaction_id + ): + valid_id_event_with_auth_header["pathParameters"] = {} + valid_id_event_with_auth_header["queryStringParameters"]["patientId"] = mock_valid_nhs_number + + expected_error = GetDocumentRefException(400, LambdaError.DocumentReferenceMissingParameters) + + expected_result = ApiGatewayResponse( + status_code=400, + body=ErrorResponse( + err_code=expected_error.err_code, + message=expected_error.message, + interaction_id=mock_interaction_id + ).create(), + methods="GET" + ).create_api_gateway_response() + + mock_feature_flag_service.get_feature_flags_by_flag.return_value = {feature_flag: True} + + result = lambda_handler(valid_id_event_with_auth_header, context) + + assert result == expected_result + +def test_env_vars_not_set_errors( + valid_id_event_with_auth_header, + context, + mocked_bad_env_vars + ): + expected_result = ApiGatewayResponse( + status_code=500, + body=LambdaError.EnvMissing.create_error_body({"name": mocked_bad_env_vars}), + methods="GET" + ).create_api_gateway_response() + + result = lambda_handler(valid_id_event_with_auth_header, context) + + assert result == expected_result \ No newline at end of file diff --git a/lambdas/tests/unit/services/test_get_document_reference_service.py b/lambdas/tests/unit/services/test_get_document_reference_service.py new file mode 100644 index 000000000..bffdc9a90 --- /dev/null +++ b/lambdas/tests/unit/services/test_get_document_reference_service.py @@ -0,0 +1,110 @@ +import pytest +import uuid +from services.get_document_reference_service import GetDocumentReferenceService +from utils.lambda_exceptions import GetDocumentRefException +from tests.unit.helpers.data.test_documents import create_test_doc_store_refs +from enums.lambda_error import LambdaError +from utils.dynamo_query_filter_builder import DynamoQueryFilterBuilder +from enums.dynamo_filter import AttributeOperator +from utils.common_query_filters import NotDeleted + + +@pytest.fixture +def mock_service(mocker): + mocker.patch("services.get_document_reference_service.GetFhirDocumentReferenceService") + service = GetDocumentReferenceService() + + yield service + +@pytest.fixture +def mock_nhs_number(): + yield "9000000009" + +@pytest.fixture +def mock_presigned_s3_url(): + yield "https://example.com" + +@pytest.fixture +def mock_s3_bucket_name(): + yield "mock_bucket_name" + +@pytest.fixture +def mock_s3_file_key(): + yield "mock_file_key" + +@pytest.fixture +def mock_document_reference( + mock_s3_bucket_name, + mock_s3_file_key, + mock_nhs_number +): + doc_ref = create_test_doc_store_refs()[0] + + doc_ref.nhs_number = mock_nhs_number + doc_ref.s3_bucket_name = mock_s3_bucket_name + doc_ref.s3_file_key = mock_s3_file_key + + yield doc_ref + + +def test_valid_input_returns_presigned_url( + mocker, + mock_service, + mock_nhs_number, + mock_presigned_s3_url, + mock_s3_bucket_name, + mock_s3_file_key, + mock_document_reference + ): + mocked_uuid = mocker.patch("services.get_document_reference_service.uuid") + expected_uuid = uuid.uuid4() + mocked_uuid.uuid4.return_value = expected_uuid + + filter_builder = DynamoQueryFilterBuilder() + filter_builder.add_condition("DocStatus", AttributeOperator.EQUAL, "final") + filter_builder.add_condition("NhsNumber", AttributeOperator.EQUAL, mock_nhs_number) + mock_filter = filter_builder.build() + mock_filter = mock_filter & NotDeleted + + mock_cloudfront_url = "cloudfront.com" + + mock_service.fhir_doc_service.document_service.fetch_documents_from_table.return_value = [mock_document_reference] + mock_service.fhir_doc_service.s3_service.create_download_presigned_url.return_value = mock_presigned_s3_url + + mock_service.cloudfront_url = mock_cloudfront_url + + expected_result = { + "url": "https://" + mock_cloudfront_url + "/" + str(expected_uuid), + "contentType": mock_document_reference.content_type + } + + result = mock_service.get_document_url_by_id( + mock_document_reference.id, + mock_nhs_number + ) + + mock_service.fhir_doc_service.document_service.fetch_documents_from_table.assert_called_once_with( + table_name=mock_service.lg_table, + search_condition=mock_document_reference.id, + search_key="ID", + query_filter=mock_filter + ) + mock_service.fhir_doc_service.s3_service.create_download_presigned_url.assert_called_once_with( + s3_bucket_name=mock_s3_bucket_name, + file_key=mock_s3_file_key + ) + + assert result == expected_result + +def test_no_document_reference_found_errors( + mock_service, + mock_document_reference, + mock_nhs_number +): + mock_service.fhir_doc_service.document_service.fetch_documents_from_table.return_value = [] + + with pytest.raises(GetDocumentRefException) as excinfo: + mock_service.get_document_url_by_id(mock_document_reference.id, mock_nhs_number) + + assert excinfo.value.status_code == 404 + assert excinfo.value.error == LambdaError.DocumentReferenceNotFound \ No newline at end of file diff --git a/lambdas/utils/lambda_exceptions.py b/lambdas/utils/lambda_exceptions.py index cc55d6c10..562f0e298 100644 --- a/lambdas/utils/lambda_exceptions.py +++ b/lambdas/utils/lambda_exceptions.py @@ -25,6 +25,10 @@ class CreateDocumentRefException(LambdaException): pass +class GetDocumentRefException(LambdaException): + pass + + class SearchPatientException(LambdaException): pass