diff --git a/api/consumer/swagger.yaml b/api/consumer/swagger.yaml index 47c1a072c..3e8514b2b 100644 --- a/api/consumer/swagger.yaml +++ b/api/consumer/swagger.yaml @@ -948,7 +948,7 @@ components: pattern: ([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)) description: The date/time that the resource was modified on the server. outcome: - $ref: "#/components/schemas/DocumentReference" + $ref: "#/components/schemas/OperationOutcome" description: An OperationOutcome containing hints and warnings produced as part of processing this entry in a batch or transaction. required: - status diff --git a/api/producer/processTransaction/process_transaction_bundle.py b/api/producer/processTransaction/process_transaction_bundle.py new file mode 100644 index 000000000..f569074b2 --- /dev/null +++ b/api/producer/processTransaction/process_transaction_bundle.py @@ -0,0 +1,427 @@ +from typing import Any, Dict +from uuid import uuid4 + +from nrlf.core.codes import SpineErrorConcept +from nrlf.core.constants import ( + PERMISSION_AUDIT_DATES_FROM_PAYLOAD, + PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL, +) +from nrlf.core.decorators import request_handler +from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository +from nrlf.core.errors import OperationOutcomeError +from nrlf.core.logger import LogReference, logger +from nrlf.core.model import ConnectionMetadata +from nrlf.core.response import NRLResponse, Response, SpineErrorResponse +from nrlf.core.utils import create_fhir_instant +from nrlf.core.validators import DocumentReferenceValidator +from nrlf.producer.fhir.r4.model import ( + BaseModel, + Bundle, + BundleEntry, + BundleEntryResponse, + DocumentReference, + DocumentReferenceRelatesTo, + ExpressionItem, + Meta, + OperationOutcome, + OperationOutcomeIssue, +) + +# NOTE: while type, category and custodian are not required in MHDS profile, they will be required by NRLF +DEFAULT_MHDS_AUTHOR = { + "identifier": { + "value": "X26", + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + } +} +DEFAULT_MHDS_PRACTICE_SETTING_CODING = { + "system": "http://snomed.info/sct", + "code": "394802000", + "display": "General medical practice", +} +DEFAULT_MHDS_PROPERTIES: dict[str, Any] = { + "author": [DEFAULT_MHDS_AUTHOR], + "context": {"practiceSetting": {"coding": [DEFAULT_MHDS_PRACTICE_SETTING_CODING]}}, +} + + +def _set_create_time_fields( + create_time: str, document_reference: DocumentReference, nrl_permissions: list[str] +) -> DocumentReference: + """ + Set the date and lastUpdated timestamps on the provided DocumentReference + """ + if not document_reference.meta: + document_reference.meta = Meta() + document_reference.meta.lastUpdated = create_time + + if ( + document_reference.date + and PERMISSION_AUDIT_DATES_FROM_PAYLOAD in nrl_permissions + ): + # Perserving the original date if it exists and the permission is set + logger.log( + LogReference.PROCREATE011, + id=document_reference.id, + date=document_reference.date, + ) + else: + document_reference.date = create_time + + return document_reference + + +def _create_core_model(resource: DocumentReference, metadata: ConnectionMetadata): + """ + Create the DocumentPointer model from the provided DocumentReference + """ + creation_time = create_fhir_instant() + document_reference = _set_create_time_fields( + creation_time, + document_reference=resource, + nrl_permissions=metadata.nrl_permissions, + ) + + return DocumentPointer.from_document_reference( + document_reference, created_on=creation_time + ) + + +def _check_permissions( + core_model: DocumentPointer, metadata: ConnectionMetadata +) -> Response | None: + """ + Check the requester has permissions to create the DocumentReference + """ + custodian_parts = tuple( + filter(None, (core_model.custodian, core_model.custodian_suffix)) + ) + if metadata.ods_code_parts != custodian_parts: + logger.log( + LogReference.PROCREATE004, + ods_code_parts=metadata.ods_code_parts, + custodian_parts=custodian_parts, + ) + return SpineErrorResponse.BAD_REQUEST( + diagnostics="The custodian of the provided DocumentReference does not match the expected ODS code for this organisation", + expression="custodian.identifier.value", + ) + + if core_model.type not in metadata.pointer_types: + logger.log( + LogReference.PROCREATE005, + ods_code=metadata.ods_code, + type=core_model.type, + pointer_types=metadata.pointer_types, + ) + return SpineErrorResponse.AUTHOR_CREDENTIALS_ERROR( + diagnostics="The type of the provided DocumentReference is not in the list of allowed types for this organisation", + expression="type.coding[0].code", + ) + + return None + + +def _get_document_ids_to_supersede( + resource: DocumentReference, + core_model: DocumentPointer, + metadata: ConnectionMetadata, + repository: DocumentPointerRepository, + can_ignore_delete_fail: bool, +) -> list[str]: + """ + Get the list of document IDs to supersede based on the relatesTo field + """ + if not resource.relatesTo: + return [] + + logger.log(LogReference.PROCREATE006, relatesTo=resource.relatesTo) + ids_to_delete: list[str] = [] + + for idx, relates_to in enumerate(resource.relatesTo): + identifier = _validate_identifier(relates_to, idx) + _validate_producer_id(identifier, metadata, idx) + + if not can_ignore_delete_fail: + existing_pointer = _check_existing_pointer(identifier, repository, idx) + _validate_pointer_details(existing_pointer, core_model, identifier, idx) + + _append_id_if_replaces(relates_to, ids_to_delete, identifier) + + return ids_to_delete + + +def _validate_identifier( + relates_to: DocumentReferenceRelatesTo, idx: str +) -> str | None: + """ + Validate that there is a identifier in relatesTo target + """ + identifier = getattr(relates_to.target.identifier, "value", None) + if not identifier: + logger.log(LogReference.PROCREATE007a) + _raise_operation_outcome_error( + "No identifier value provided for relatesTo target", idx + ) + return identifier + + +def _validate_producer_id(identifier, metadata, idx): + """ + Validate that there is an ODS code in the relatesTo target identifier + """ + producer_id = identifier.split("-", 1)[0] + if metadata.ods_code_parts != tuple(producer_id.split("|")): + logger.log( + LogReference.PROCREATE007b, + related_identifier=identifier, + ods_code_parts=metadata.ods_code_parts, + ) + _raise_operation_outcome_error( + "The relatesTo target identifier value does not include the expected ODS code for this organisation", + idx, + ) + + +def _check_existing_pointer(identifier, repository, idx): + """ + Check that there is an existing pointer that will be deleted when superseding + """ + existing_pointer = repository.get_by_id(identifier) + if not existing_pointer: + logger.log(LogReference.PROCREATE007c, related_identifier=identifier) + _raise_operation_outcome_error( + "The relatesTo target document does not exist", idx + ) + return existing_pointer + + +def _validate_pointer_details(existing_pointer, core_model, identifier, idx): + """ + Validate that the nhs numbers and type matches between the existing pointer and the requested one. + """ + if existing_pointer.nhs_number != core_model.nhs_number: + logger.log(LogReference.PROCREATE007d, related_identifier=identifier) + _raise_operation_outcome_error( + "The relatesTo target document NHS number does not match the NHS number in the request", + idx, + ) + + if existing_pointer.type != core_model.type: + logger.log(LogReference.PROCREATE007e, related_identifier=identifier) + _raise_operation_outcome_error( + "The relatesTo target document type does not match the type in the request", + idx, + ) + + +def _append_id_if_replaces(relates_to, ids_to_delete, identifier): + """ + Append pointer ID if the if the relatesTo code is 'replaces' + """ + if relates_to.code == "replaces": + logger.log( + LogReference.PROCREATE008, + relates_to_code=relates_to.code, + identifier=identifier, + ) + ids_to_delete.append(identifier) + + +def _raise_operation_outcome_error(diagnostics, idx): + """ + General function to raise an operation outcome error + """ + raise OperationOutcomeError( + severity="error", + code="invalid", + details=SpineErrorConcept.from_code("BAD_REQUEST"), + diagnostics=diagnostics, + expression=[f"relatesTo[{idx}].target.identifier.value"], + ) + + +def create_document_reference( + metadata: ConnectionMetadata, + repository: DocumentPointerRepository, + body: DocumentReference, +) -> Response: + + logger.log(LogReference.PROCREATE000) + logger.log(LogReference.PROCREATE001, resource=body) + + id_prefix = "|".join(metadata.ods_code_parts) + body.id = f"{id_prefix}-{uuid4()}" + + validator = DocumentReferenceValidator() + result = validator.validate(body) + + if not result.is_valid: + logger.log(LogReference.PROCREATE002) + return Response.from_issues(issues=result.issues, statusCode="400") + + core_model = _create_core_model(result.resource, metadata) + if error_response := _check_permissions(core_model, metadata): + return error_response + + can_ignore_delete_fail = ( + PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL in metadata.nrl_permissions + ) + + if ids_to_delete := _get_document_ids_to_supersede( + result.resource, core_model, metadata, repository, can_ignore_delete_fail + ): + logger.log( + LogReference.PROCREATE010, + pointer_id=result.resource.id, + ids_to_delete=ids_to_delete, + ) + repository.supersede(core_model, ids_to_delete, can_ignore_delete_fail) + logger.log(LogReference.PROCREATE999) + return NRLResponse.RESOURCE_SUPERSEDED(resource_id=result.resource.id) + + logger.log(LogReference.PROCREATE009, pointer_id=result.resource.id) + repository.create(core_model) + logger.log(LogReference.PROCREATE999) + return NRLResponse.RESOURCE_CREATED(resource_id=result.resource.id) + + +def _convert_document_reference( + raw_resource: Dict[str, Any], requested_profile: str +) -> DocumentReference: + """ + Convert the DocumentReference to the requested profile + """ + if requested_profile.endswith( + "profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ): + logger.log(LogReference.PROTRAN006, requested_profile=requested_profile) + docref_properties: dict[str, Any] = {} + docref_properties.update(DEFAULT_MHDS_PROPERTIES) + docref_properties.update(raw_resource) + docref_properties["status"] = "current" + return DocumentReference(**docref_properties) + + raise OperationOutcomeError( + severity="error", + code="exception", + diagnostics="Unable to parse DocumentReference. Only IHE.MHD.UnContained.Comprehensive.ProvideBundle profile is supported", + expression=["meta.profile[0]"], + details=SpineErrorConcept.from_code("BAD_REQUEST"), + ) + + +@request_handler(body=Bundle) +def handler( + metadata: ConnectionMetadata, + repository: DocumentPointerRepository, + body: Bundle, +) -> Response: + """ + Handles an FHIR transaction bundle request. + + Currently limited to create requests only and only supports either the MHDS profile or the NRLF profile. + + Args: + metadata (ConnectionMetadata): The connection metadata. + repository (DocumentPointerRepository): The document pointer repository. + body (Bundle): The bundle containing the resources to process. + + Returns: + Response: The response indicating the result of the operation. + """ + logger.log(LogReference.PROTRAN000) + + requested_profile = ( + body.meta.profile[0].root if body.meta and body.meta.profile else None + ) + + if requested_profile and not requested_profile.endswith( + "profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ): + logger.log(LogReference.PROTRAN001, requested_profile=requested_profile) + return SpineErrorResponse.BAD_REQUEST( + diagnostics="Only IHE.MHD.UnContained.Comprehensive.ProvideBundle profiles are supported", + expression="meta.profile[0]", + ) + + if body.type != "transaction": + logger.log(LogReference.PROTRAN002) + return SpineErrorResponse.BAD_REQUEST( + diagnostics="Only transaction bundles are supported", + expression="type", + ) + + if body.entry is None: + logger.log(LogReference.PROTRAN003) + return Response.from_resource( + resource=Bundle(resourceType="Bundle", type="transaction-response") + ) + + entries: list[BundleEntry] = [] + issues: list[BaseModel] = [] + + for entry in body.entry: + if not entry.resource or entry.resource["resourceType"] != "DocumentReference": + logger.log(LogReference.PROTRAN004) + issues.append( + OperationOutcomeIssue( + severity="error", + code="exception", + diagnostics="Only DocumentReference resources are supported", + expression=[ExpressionItem("entry.resource.resourceType")], + details=SpineErrorConcept.from_code("BAD_REQUEST"), + ) + ) + + if entry.request.method != "POST": + logger.log(LogReference.PROTRAN005) + issues.append( + OperationOutcomeIssue( + severity="error", + code="exception", + diagnostics="Only create using POST method is supported", + expression=[ExpressionItem("entry.request.method")], + details=SpineErrorConcept.from_code("BAD_REQUEST"), + ) + ) + + entries.append(entry) + + if issues: + return Response.from_issues(issues, statusCode="400") + + responses: list[Response] = [] + for entry in entries: + try: + if requested_profile: + document_reference = _convert_document_reference( + entry.resource, requested_profile + ) + else: + document_reference = DocumentReference(**(entry.resource)) + + create_response = create_document_reference( + metadata, repository, document_reference + ) + responses.append(create_response) + except OperationOutcomeError as e: + responses.append(e.response) + + response_entries = [ + BundleEntry( + response=BundleEntryResponse( + status=response.statusCode, + location=response.headers.get("Location"), + outcome=OperationOutcome.model_validate_json(response.body), + ) + ) + for response in responses + ] + + logger.log(LogReference.PROTRAN999) + return Response.from_resource( + resource=Bundle( + resourceType="Bundle", type="transaction-response", entry=response_entries + ) + ) diff --git a/api/producer/processTransaction/tests/test_process_transaction_bundle.py b/api/producer/processTransaction/tests/test_process_transaction_bundle.py new file mode 100644 index 000000000..47b9478c0 --- /dev/null +++ b/api/producer/processTransaction/tests/test_process_transaction_bundle.py @@ -0,0 +1,808 @@ +import json + +from freeze_uuid import freeze_uuid +from freezegun import freeze_time +from moto import mock_aws + +from api.producer.processTransaction.process_transaction_bundle import ( + DEFAULT_MHDS_PROPERTIES, + handler, +) +from nrlf.core.dynamodb.repository import DocumentPointerRepository +from nrlf.producer.fhir.r4.model import ( + Bundle, + BundleEntry, + BundleEntryRequest, + Meta, + ProfileItem, +) +from nrlf.tests.data import load_document_reference +from nrlf.tests.dynamodb import mock_repository +from nrlf.tests.events import ( + create_headers, + create_mock_context, + create_test_api_gateway_event, + default_response_headers, +) + + +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@freeze_uuid("00000000-0000-0000-0000-000000000001") +def test_create_single_nrl_document_reference_with_transaction_happy_path( + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid").model_dump( + exclude_none=True + ) + + request_bundle = Bundle( + entry=[ + BundleEntry( + resource=doc_ref, request=BundleEntryRequest(url="/", method="POST") + ) + ], + resourceType="Bundle", + type="transaction", + ) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=request_bundle.model_dump_json(), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": { + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "Bundle", + "type": "transaction-response", + "entry": [ + { + "response": { + "status": "201", + "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", + "outcome": { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + "code": "RESOURCE_CREATED", + "display": "Resource created", + } + ] + }, + "diagnostics": "The document has been created", + } + ], + }, + }, + }, + ], + } + + created_doc_pointer = repository.get_by_id( + "Y05868-00000000-0000-0000-0000-000000000001" + ) + + assert created_doc_pointer is not None + assert created_doc_pointer.created_on == "2024-03-21T12:34:56.789Z" + assert created_doc_pointer.updated_on is None + assert json.loads(created_doc_pointer.document) == { + **doc_ref, + "meta": { + "lastUpdated": "2024-03-21T12:34:56.789Z", + }, + "date": "2024-03-21T12:34:56.789Z", + "id": "Y05868-00000000-0000-0000-0000-000000000001", + } + + +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@freeze_uuid("00000000-0000-0000-0000-000000000001") +def test_create_single_mhds_document_reference_with_transaction_happy_path( + repository: DocumentPointerRepository, +): + raw_doc_ref = load_document_reference("Y05868-736253002-Valid").model_dump( + exclude_none=True + ) + + raw_doc_ref.pop("author") + raw_doc_ref.pop("context") + + request_bundle = Bundle( + meta=Meta( + profile=[ + ProfileItem( + "http://hl7.org/fhir/profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ) + ] + ), + entry=[ + BundleEntry( + resource=raw_doc_ref, request=BundleEntryRequest(url="/", method="POST") + ) + ], + resourceType="Bundle", + type="transaction", + ) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=request_bundle.model_dump_json(), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": { + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "Bundle", + "type": "transaction-response", + "entry": [ + { + "response": { + "status": "201", + "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", + "outcome": { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + "code": "RESOURCE_CREATED", + "display": "Resource created", + } + ] + }, + "diagnostics": "The document has been created", + } + ], + }, + }, + }, + ], + } + + created_doc_pointer = repository.get_by_id( + "Y05868-00000000-0000-0000-0000-000000000001" + ) + + assert created_doc_pointer is not None + assert created_doc_pointer.created_on == "2024-03-21T12:34:56.789Z" + assert created_doc_pointer.updated_on is None + assert json.loads(created_doc_pointer.document) == { + **raw_doc_ref, + **DEFAULT_MHDS_PROPERTIES, + "meta": { + "lastUpdated": "2024-03-21T12:34:56.789Z", + }, + "date": "2024-03-21T12:34:56.789Z", + "id": "Y05868-00000000-0000-0000-0000-000000000001", + } + + +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@freeze_uuid( + ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] +) +def test_create_multiple_mhds_document_reference_with_transaction_happy_path( + repository: DocumentPointerRepository, +): + raw_doc_ref = load_document_reference("Y05868-736253002-Valid").model_dump( + exclude_none=True + ) + + raw_doc_ref.pop("author") + raw_doc_ref.pop("context") + + raw_doc_ref2 = load_document_reference( + "Y05868-736253002-Valid-with-date" + ).model_dump(exclude_none=True) + raw_doc_ref2.pop("author") + raw_doc_ref2.pop("context") + + request_bundle = Bundle( + meta=Meta( + profile=[ + ProfileItem( + "http://hl7.org/fhir/profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ) + ] + ), + entry=[ + BundleEntry( + resource=raw_doc_ref, request=BundleEntryRequest(url="/", method="POST") + ), + BundleEntry( + resource=raw_doc_ref2, + request=BundleEntryRequest(url="/", method="POST"), + ), + ], + resourceType="Bundle", + type="transaction", + ) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=request_bundle.model_dump_json(), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": { + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "Bundle", + "type": "transaction-response", + "entry": [ + { + "response": { + "status": "201", + "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", + "outcome": { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + "code": "RESOURCE_CREATED", + "display": "Resource created", + } + ] + }, + "diagnostics": "The document has been created", + } + ], + }, + }, + }, + { + "response": { + "status": "201", + "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000002", + "outcome": { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + "code": "RESOURCE_CREATED", + "display": "Resource created", + } + ] + }, + "diagnostics": "The document has been created", + } + ], + }, + }, + }, + ], + } + + created_doc_pointer = repository.get_by_id( + "Y05868-00000000-0000-0000-0000-000000000001" + ) + + assert created_doc_pointer is not None + assert created_doc_pointer.created_on == "2024-03-21T12:34:56.789Z" + assert created_doc_pointer.updated_on is None + assert json.loads(created_doc_pointer.document) == { + **raw_doc_ref, + **DEFAULT_MHDS_PROPERTIES, + "meta": { + "lastUpdated": "2024-03-21T12:34:56.789Z", + }, + "date": "2024-03-21T12:34:56.789Z", + "id": "Y05868-00000000-0000-0000-0000-000000000001", + } + + created_doc_pointer = repository.get_by_id( + "Y05868-00000000-0000-0000-0000-000000000002" + ) + + assert created_doc_pointer is not None + assert created_doc_pointer.created_on == "2024-03-21T12:34:56.789Z" + assert created_doc_pointer.updated_on is None + assert json.loads(created_doc_pointer.document) == { + **raw_doc_ref2, + **DEFAULT_MHDS_PROPERTIES, + "meta": { + "lastUpdated": "2024-03-21T12:34:56.789Z", + }, + "date": "2024-03-21T12:34:56.789Z", + "id": "Y05868-00000000-0000-0000-0000-000000000002", + } + + +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@freeze_uuid( + ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] +) +def test_create_multiple_mhds_document_reference_with_transaction_wrong_ods_returns_multiple_responses( + repository: DocumentPointerRepository, +): + raw_doc_ref = load_document_reference("RQI-736253002-Valid").model_dump( + exclude_none=True + ) + + raw_doc_ref.pop("author") + raw_doc_ref.pop("context") + + raw_doc_ref2 = load_document_reference( + "Y05868-736253002-Valid-with-date" + ).model_dump(exclude_none=True) + raw_doc_ref2.pop("author") + raw_doc_ref2.pop("context") + + request_bundle = Bundle( + meta=Meta( + profile=[ + ProfileItem( + "http://hl7.org/fhir/profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ) + ] + ), + entry=[ + BundleEntry( + resource=raw_doc_ref, request=BundleEntryRequest(url="/", method="POST") + ), + BundleEntry( + resource=raw_doc_ref2, + request=BundleEntryRequest(url="/", method="POST"), + ), + ], + resourceType="Bundle", + type="transaction", + ) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=request_bundle.model_dump_json(), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": { + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "Bundle", + "type": "transaction-response", + "entry": [ + { + "response": { + "status": "400", + "outcome": { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "BAD_REQUEST", + "display": "Bad request", + } + ] + }, + "diagnostics": "The custodian of the provided DocumentReference does not match the expected ODS code for this organisation", + "expression": ["custodian.identifier.value"], + } + ], + }, + }, + }, + { + "response": { + "status": "201", + "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000002", + "outcome": { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + "code": "RESOURCE_CREATED", + "display": "Resource created", + } + ] + }, + "diagnostics": "The document has been created", + } + ], + }, + }, + }, + ], + } + + created_doc_pointer = repository.get_by_id( + "Y05868-00000000-0000-0000-0000-000000000002" + ) + + assert created_doc_pointer is not None + assert created_doc_pointer.created_on == "2024-03-21T12:34:56.789Z" + assert created_doc_pointer.updated_on is None + assert json.loads(created_doc_pointer.document) == { + **raw_doc_ref2, + **DEFAULT_MHDS_PROPERTIES, + "meta": { + "lastUpdated": "2024-03-21T12:34:56.789Z", + }, + "date": "2024-03-21T12:34:56.789Z", + "id": "Y05868-00000000-0000-0000-0000-000000000002", + } + + created_doc_pointer = repository.get_by_id( + "RQI-00000000-0000-0000-0000-000000000001" + ) + + assert created_doc_pointer is None + + +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@freeze_uuid( + ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] +) +def test_create_multiple_mhds_document_reference_with_transaction_invalid_profile( + repository: DocumentPointerRepository, +): + raw_doc_ref = load_document_reference("Y05868-736253002-Valid").model_dump( + exclude_none=True + ) + + raw_doc_ref.pop("author") + raw_doc_ref.pop("context") + + raw_doc_ref2 = load_document_reference( + "Y05868-736253002-Valid-with-date" + ).model_dump(exclude_none=True) + raw_doc_ref2.pop("author") + raw_doc_ref2.pop("context") + + request_bundle = Bundle( + meta=Meta(profile=[ProfileItem("someRandomProfile")]), + entry=[ + BundleEntry( + resource=raw_doc_ref, request=BundleEntryRequest(url="/", method="POST") + ), + BundleEntry( + resource=raw_doc_ref2, + request=BundleEntryRequest(url="/", method="POST"), + ), + ], + resourceType="Bundle", + type="transaction", + ) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=request_bundle.model_dump_json(), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "400", + "headers": { + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "code": "BAD_REQUEST", + "display": "Bad request", + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + } + ] + }, + "diagnostics": "Only IHE.MHD.UnContained.Comprehensive.ProvideBundle profiles are supported", + "expression": ["meta.profile[0]"], + } + ], + } + + +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@freeze_uuid( + ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] +) +def test_create_multiple_mhds_document_reference_with_transaction_invalid_request_method( + repository: DocumentPointerRepository, +): + raw_doc_ref = load_document_reference("Y05868-736253002-Valid").model_dump( + exclude_none=True + ) + + raw_doc_ref.pop("author") + raw_doc_ref.pop("context") + + raw_doc_ref2 = load_document_reference( + "Y05868-736253002-Valid-with-date" + ).model_dump(exclude_none=True) + raw_doc_ref2.pop("author") + raw_doc_ref2.pop("context") + + request_bundle = Bundle( + meta=Meta( + profile=[ + ProfileItem( + "http://hl7.org/fhir/profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ) + ] + ), + entry=[ + BundleEntry( + resource=raw_doc_ref, request=BundleEntryRequest(url="/", method="POST") + ), + BundleEntry( + resource=raw_doc_ref2, request=BundleEntryRequest(url="/", method="GET") + ), + ], + resourceType="Bundle", + type="transaction", + ) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=request_bundle.model_dump_json(), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "400", + "headers": { + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "details": { + "coding": [ + { + "code": "BAD_REQUEST", + "display": "Bad request", + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + } + ] + }, + "diagnostics": "Only create using POST method is supported", + "expression": ["entry.request.method"], + } + ], + } + + +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@freeze_uuid( + ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] +) +def test_create_multiple_mhds_document_reference_with_transaction_invalid_request_type( + repository: DocumentPointerRepository, +): + raw_doc_ref = load_document_reference("Y05868-736253002-Valid").model_dump( + exclude_none=True + ) + + raw_doc_ref.pop("author") + raw_doc_ref.pop("context") + + raw_doc_ref2 = load_document_reference( + "Y05868-736253002-Valid-with-date" + ).model_dump(exclude_none=True) + raw_doc_ref2.pop("author") + raw_doc_ref2.pop("context") + + request_bundle = Bundle( + meta=Meta( + profile=[ + ProfileItem( + "http://hl7.org/fhir/profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ) + ] + ), + entry=[ + BundleEntry( + resource=raw_doc_ref, request=BundleEntryRequest(url="/", method="POST") + ), + BundleEntry( + resource=raw_doc_ref2, + request=BundleEntryRequest(url="/", method="POST"), + ), + ], + resourceType="Bundle", + type="invalid", + ) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=request_bundle.model_dump_json(), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "400", + "headers": { + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "code": "BAD_REQUEST", + "display": "Bad request", + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + } + ] + }, + "diagnostics": "Only transaction bundles are supported", + "expression": ["type"], + } + ], + } + + +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@freeze_uuid("00000000-0000-0000-0000-000000000001") +def test_create_single_mhds_document_reference_with_no_entry_resource( + repository: DocumentPointerRepository, +): + raw_doc_ref = {} + + request_bundle = Bundle( + meta=Meta( + profile=[ + ProfileItem( + "http://hl7.org/fhir/profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ) + ] + ), + entry=[ + BundleEntry( + resource=raw_doc_ref, request=BundleEntryRequest(url="/", method="POST") + ) + ], + resourceType="Bundle", + type="transaction", + ) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=request_bundle.model_dump_json(), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "400", + "headers": { + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "details": { + "coding": [ + { + "code": "BAD_REQUEST", + "display": "Bad request", + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + } + ] + }, + "diagnostics": "Only DocumentReference resources are supported", + "expression": ["entry.resource.resourceType"], + } + ], + } diff --git a/api/producer/swagger.yaml b/api/producer/swagger.yaml index bdd0127b8..e2f544aa4 100644 --- a/api/producer/swagger.yaml +++ b/api/producer/swagger.yaml @@ -237,6 +237,33 @@ servers: description: Production environment. tags: paths: + /: + post: + tags: + summary: Process a FHIR transaction + operationId: processTransaction + responses: + "200": + description: Transaction successful response + $ref: "#/components/responses/Success" + content: + application/fhir+json: + example: {} # TODO - Add example response for processTransaction + requestBody: + $ref: "#/components/schemas/Bundle" + parameters: {} + x-amazon-apigateway-integration: + type: aws_proxy + httpMethod: POST + uri: ${method_processTransaction} + responses: + default: + statusCode: "200" + passthroughBehavior: when_no_match + contentHandling: CONVERT_TO_TEXT + description: | + TODO - Add description for processTransaction + /DocumentReference: post: tags: @@ -1478,7 +1505,7 @@ components: pattern: \S* description: "The Absolute URL for the resource. The fullUrl SHALL NOT disagree with the id in the resource – i.e. if the fullUrl is not a urn:uuid, the URL shall be version–independent URL consistent with the Resource.id. The fullUrl is a version independent reference to the resource. The fullUrl element SHALL have a value except that: \n* fullUrl can be empty on a POST (although it does not need to when specifying a temporary id for reference in the bundle)\n* Results from operations might involve resources that are not identified." resource: - $ref: "#/components/schemas/DocumentReference" + type: object description: The Resource for the entry. The purpose/meaning of the resource is determined by the Bundle.type. search: $ref: "#/components/schemas/BundleEntrySearch" @@ -1513,7 +1540,7 @@ components: pattern: ([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)) description: The date/time that the resource was modified on the server. outcome: - $ref: "#/components/schemas/DocumentReference" + $ref: "#/components/schemas/OperationOutcome" description: An OperationOutcome containing hints and warnings produced as part of processing this entry in a batch or transaction. required: - status diff --git a/layer/nrlf/consumer/fhir/r4/model.py b/layer/nrlf/consumer/fhir/r4/model.py index 4665533b5..5b7210743 100644 --- a/layer/nrlf/consumer/fhir/r4/model.py +++ b/layer/nrlf/consumer/fhir/r4/model.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: swagger.yaml -# timestamp: 2024-11-20T09:43:58+00:00 +# timestamp: 2024-12-12T12:52:13+00:00 from __future__ import annotations @@ -776,7 +776,7 @@ class BundleEntryResponse(BaseModel): ), ] = None outcome: Annotated[ - Optional[DocumentReference], + Optional[OperationOutcome], Field( description="An OperationOutcome containing hints and warnings produced as part of processing this entry in a batch or transaction." ), diff --git a/layer/nrlf/core/log_references.py b/layer/nrlf/core/log_references.py index 295cea4ac..916800072 100644 --- a/layer/nrlf/core/log_references.py +++ b/layer/nrlf/core/log_references.py @@ -213,6 +213,23 @@ class LogReference(Enum): "INFO", "Successfully completed consumer searchPostDocumentReference" ) + # Producer - processTransaction + PROTRAN000 = _Reference("INFO", "Starting to process producer transaction Request") + PROTRAN001 = _Reference("WARN", "Invalid profile specified in request") + PROTRAN002 = _Reference( + "WARN", + "Invalid type specified in request, only transaction bundles are supported", + ) + PROTRAN003 = _Reference("WARN", "No entry provided in body") + PROTRAN004 = _Reference("WARN", "Entry resource is not of type DocumentReference") + PROTRAN005 = _Reference("WARN", "Entry request method is not POST") + PROTRAN006 = _Reference( + "INFO", "Converting document reference to specified profile" + ) + PROTRAN999 = _Reference( + "INFO", "Successfully completed producer processTransaction" + ) + # Producer - CreateDocumentReference PROCREATE000 = _Reference( "INFO", "Starting to process producer createDocumentReference" diff --git a/layer/nrlf/producer/fhir/r4/model.py b/layer/nrlf/producer/fhir/r4/model.py index d96b7ce73..ad83ca572 100644 --- a/layer/nrlf/producer/fhir/r4/model.py +++ b/layer/nrlf/producer/fhir/r4/model.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: swagger.yaml -# timestamp: 2024-11-20T10:10:52+00:00 +# timestamp: 2024-12-12T12:52:09+00:00 from __future__ import annotations -from typing import Annotated, List, Literal, Optional +from typing import Annotated, Any, Dict, List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, RootModel @@ -698,7 +698,7 @@ class BundleEntry(BaseModel): ), ] = None resource: Annotated[ - Optional[DocumentReference], + Optional[Dict[str, Any]], Field( description="The Resource for the entry. The purpose/meaning of the resource is determined by the Bundle.type." ), @@ -760,7 +760,7 @@ class BundleEntryResponse(BaseModel): ), ] = None outcome: Annotated[ - Optional[DocumentReference], + Optional[OperationOutcome], Field( description="An OperationOutcome containing hints and warnings produced as part of processing this entry in a batch or transaction." ), diff --git a/layer/nrlf/producer/fhir/r4/strict_model.py b/layer/nrlf/producer/fhir/r4/strict_model.py index e4edefc58..94480f44e 100644 --- a/layer/nrlf/producer/fhir/r4/strict_model.py +++ b/layer/nrlf/producer/fhir/r4/strict_model.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: swagger.yaml -# timestamp: 2024-11-20T10:10:54+00:00 +# timestamp: 2024-12-12T12:52:11+00:00 from __future__ import annotations -from typing import Annotated, List, Literal, Optional +from typing import Annotated, Any, Dict, List, Literal, Optional from pydantic import ( BaseModel, @@ -609,7 +609,7 @@ class BundleEntry(BaseModel): ), ] = None resource: Annotated[ - Optional[DocumentReference], + Optional[Dict[str, Any]], Field( description="The Resource for the entry. The purpose/meaning of the resource is determined by the Bundle.type." ), @@ -666,7 +666,7 @@ class BundleEntryResponse(BaseModel): ), ] = None outcome: Annotated[ - Optional[DocumentReference], + Optional[OperationOutcome], Field( description="An OperationOutcome containing hints and warnings produced as part of processing this entry in a batch or transaction." ), diff --git a/terraform/infrastructure/api_gateway.tf b/terraform/infrastructure/api_gateway.tf index 2e6a42fbd..038baf90f 100644 --- a/terraform/infrastructure/api_gateway.tf +++ b/terraform/infrastructure/api_gateway.tf @@ -34,6 +34,7 @@ module "producer__gateway" { method_upsertDocumentReference = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--producer--upsertDocumentReference", 0, 64)}/invocations" method_deleteDocumentReference = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--producer--deleteDocumentReference", 0, 64)}/invocations" method_status = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--producer--status", 0, 64)}/invocations" + method_processTransaction = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--producer--processTransaction", 0, 64)}/invocations" } kms_key_id = module.kms__cloudwatch.kms_arn diff --git a/terraform/infrastructure/lambda.tf b/terraform/infrastructure/lambda.tf index 64b05c6bc..9eaa8378e 100644 --- a/terraform/infrastructure/lambda.tf +++ b/terraform/infrastructure/lambda.tf @@ -381,3 +381,33 @@ module "producer__status" { handler = "status.handler" retention = var.log_retention_period } + +module "mhdsReceiver__processTransactionBundle" { + source = "./modules/lambda" + parent_path = "api/producer" + name = "processTransaction" + region = local.region + prefix = local.prefix + layers = [module.nrlf.layer_arn, module.third_party.layer_arn, module.nrlf_permissions.layer_arn] + api_gateway_source_arn = ["arn:aws:execute-api:${local.region}:${local.aws_account_id}:${module.producer__gateway.api_gateway_id}/*/POST/"] + kms_key_id = module.kms__cloudwatch.kms_arn + environment_variables = { + PREFIX = "${local.prefix}--" + ENVIRONMENT = local.environment + AUTH_STORE = local.auth_store_id + SPLUNK_INDEX = module.firehose__processor.splunk.index + POWERTOOLS_LOG_LEVEL = local.log_level + TABLE_NAME = local.pointers_table_name + } + additional_policies = [ + local.pointers_table_write_policy_arn, + local.pointers_table_read_policy_arn, + local.pointers_kms_read_write_arn, + local.auth_store_read_policy_arn + ] + firehose_subscriptions = [ + module.firehose__processor.firehose_subscription + ] + handler = "process_transaction_bundle.handler" + retention = var.log_retention_period +} diff --git a/terraform/infrastructure/modules/api_gateway/api_gateway.tf b/terraform/infrastructure/modules/api_gateway/api_gateway.tf index b59636f69..f2c7d3ce7 100644 --- a/terraform/infrastructure/modules/api_gateway/api_gateway.tf +++ b/terraform/infrastructure/modules/api_gateway/api_gateway.tf @@ -113,6 +113,8 @@ resource "aws_api_gateway_method_settings" "api_gateway_method_settings" { resource "aws_api_gateway_gateway_response" "api_access_denied" { rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id response_type = "ACCESS_DENIED" + status_code = "403" + response_templates = { "application/json" = jsonencode({ resourceType : "OperationOutcome", diff --git a/tests/features/steps/3_assert.py b/tests/features/steps/3_assert.py index 1ce9c82f8..7f86c7a3e 100644 --- a/tests/features/steps/3_assert.py +++ b/tests/features/steps/3_assert.py @@ -229,7 +229,7 @@ def assert_bundle_contains_documentreference_values_step(context: Context): raise ValueError("No id provided in the table") for entry in context.bundle.entry: - if entry.resource.id != items["id"]: + if entry.resource.get("id") != items["id"]: continue return assert_document_reference_matches_value(context, entry.resource, items) @@ -243,7 +243,7 @@ def assert_bundle_contains_documentreference_values_step(context: Context): def assert_bundle_does_not_contain_doc_ref_step(context: Context, doc_ref_id: str): for entry in context.bundle.entry: assert ( - entry.resource.id != doc_ref_id + entry.resource.get("id") != doc_ref_id ), f"DocumentReference with ID {doc_ref_id} found in the response"