From 6dffea4d92c0ae8b880d11c574ecb7f94366c64e Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Tue, 26 Nov 2024 17:23:57 +0000 Subject: [PATCH 01/12] [NRL-1051] Add initial impl for MHDS transaction API --- Makefile | 2 +- .../processTransaction/process_transaction.py | 338 ++++++++++++++++++ terraform/infrastructure/api_gateway.tf | 1 + terraform/infrastructure/lambda.tf | 30 ++ 4 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 api/mhds-recipient/processTransaction/process_transaction.py diff --git a/Makefile b/Makefile index bf2003ca6..d8026e2a7 100644 --- a/Makefile +++ b/Makefile @@ -66,7 +66,7 @@ build-layers: ./layer/* ./scripts/build-lambda-layer.sh $${layer} $(DIST_PATH); \ done -build-api-packages: ./api/consumer/* ./api/producer/* +build-api-packages: ./api/consumer/* ./api/producer/* ./api/mhds-recipient/* @echo "Building API packages" @mkdir -p $(DIST_PATH) for api in $^; do \ diff --git a/api/mhds-recipient/processTransaction/process_transaction.py b/api/mhds-recipient/processTransaction/process_transaction.py new file mode 100644 index 000000000..1a47850f6 --- /dev/null +++ b/api/mhds-recipient/processTransaction/process_transaction.py @@ -0,0 +1,338 @@ +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, + DocumentReference, + DocumentReferenceRelatesTo, + ExpressionItem, + Meta, + OperationOutcomeIssue, +) + + +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, + document_reference: 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) + + +@request_handler(body=Bundle) +def handler( + metadata: ConnectionMetadata, + repository: DocumentPointerRepository, + body: Bundle, +) -> Response: + """ + Handles an MHDS transaction bundle request. + + Currently limited to register requests only. + + 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. + """ + if not body.meta.profile[0].endswith( + "profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ): + return SpineErrorResponse.BAD_REQUEST( + diagnostics="Only IHE.MHD.UnContained.Comprehensive.ProvideBundle profiles are supported", + expression="meta.profile", + ) + + if body.type != "transaction": + return SpineErrorResponse.BAD_REQUEST( + diagnostics="Only transaction bundles are supported", + expression="type", + ) + + if body.entry is None: + return SpineErrorResponse.BAD_REQUEST( + diagnostics="The bundle must contain at least one entry", expression="entry" + ) + + document_references: list[DocumentReference] = [] + + # TODO - Handle this better + issues: list[BaseModel] = [] + + for entry in body.entry: + if not entry.resource or entry.resource.resourceType != "DocumentReference": + issues.append( + OperationOutcomeIssue( + severity="error", + code="exception", + diagnostics="Only DocumentReference resources are supported", + expression=[ExpressionItem("entry.resource.resourceType")], + details=SpineErrorConcept.from_code("BAD_REQUEST"), + ) + ) + + document_references.append(DocumentReference.model_validate(entry.resource)) + + if issues: + return Response.from_issues(issues, statusCode="400") + + responses: list[Response] = [] + for document_reference in document_references: + try: + create_response = create_document_reference( + metadata, repository, document_reference + ) + responses.append(create_response) + except OperationOutcomeError as e: + responses.append(e.response) + + return NRLResponse.BUNDLE_CREATED(responses) diff --git a/terraform/infrastructure/api_gateway.tf b/terraform/infrastructure/api_gateway.tf index 2e6a42fbd..8ead46c2d 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_mhdsProcessTransation = "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--mhdsRecipient--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..aa87c6825 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/mhds-recipient" + 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}/*/GET/_status"] + kms_key_id = module.kms__cloudwatch.kms_arn + environment_variables = { + PREFIX = "${local.prefix}--" + ENVIRONMENT = local.environment + AUTH_STORE = local.auth_store_id + POWERTOOLS_LOG_LEVEL = local.log_level + SPLUNK_INDEX = module.firehose__processor.splunk.index + DYNAMODB_TIMEOUT = local.dynamodb_timeout_seconds + TABLE_NAME = local.pointers_table_name + } + additional_policies = [ + 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 +} From 1733a36bfef595135c42f140b470b6b427ff7612 Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Fri, 29 Nov 2024 17:19:06 +0000 Subject: [PATCH 02/12] [NRL-1051] WIP some more renaming --- Makefile | 2 +- .../process_transaction_bundle.py} | 33 +++++++++++++++---- terraform/infrastructure/api_gateway.tf | 2 +- terraform/infrastructure/lambda.tf | 4 +-- 4 files changed, 31 insertions(+), 10 deletions(-) rename api/{mhds-recipient/processTransaction/process_transaction.py => producer/processTransaction/process_transaction_bundle.py} (91%) diff --git a/Makefile b/Makefile index d8026e2a7..bf2003ca6 100644 --- a/Makefile +++ b/Makefile @@ -66,7 +66,7 @@ build-layers: ./layer/* ./scripts/build-lambda-layer.sh $${layer} $(DIST_PATH); \ done -build-api-packages: ./api/consumer/* ./api/producer/* ./api/mhds-recipient/* +build-api-packages: ./api/consumer/* ./api/producer/* @echo "Building API packages" @mkdir -p $(DIST_PATH) for api in $^; do \ diff --git a/api/mhds-recipient/processTransaction/process_transaction.py b/api/producer/processTransaction/process_transaction_bundle.py similarity index 91% rename from api/mhds-recipient/processTransaction/process_transaction.py rename to api/producer/processTransaction/process_transaction_bundle.py index 1a47850f6..5148ae7ce 100644 --- a/api/mhds-recipient/processTransaction/process_transaction.py +++ b/api/producer/processTransaction/process_transaction_bundle.py @@ -284,8 +284,14 @@ def handler( Returns: Response: The response indicating the result of the operation. """ - if not body.meta.profile[0].endswith( - "profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + # TODO - Add logging + # TODO - Add profile for NRLF too + if ( + body.meta + and body.meta.profile + and not body.meta.profile[0].endswith( + "profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ) ): return SpineErrorResponse.BAD_REQUEST( diagnostics="Only IHE.MHD.UnContained.Comprehensive.ProvideBundle profiles are supported", @@ -299,13 +305,13 @@ def handler( ) if body.entry is None: - return SpineErrorResponse.BAD_REQUEST( - diagnostics="The bundle must contain at least one entry", expression="entry" + # TODO - Log that there was not entry + return Response.from_resource( + resource=Bundle(resourceType="Bundle", type="transaction-response") ) document_references: list[DocumentReference] = [] - # TODO - Handle this better issues: list[BaseModel] = [] for entry in body.entry: @@ -320,6 +326,17 @@ def handler( ) ) + if entry.request.method != "POST": + 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"), + ) + ) + document_references.append(DocumentReference.model_validate(entry.resource)) if issues: @@ -335,4 +352,8 @@ def handler( except OperationOutcomeError as e: responses.append(e.response) - return NRLResponse.BUNDLE_CREATED(responses) + return Response.from_resource( + resource=Bundle( + resourceType="Bundle", type="transaction-response", entry=responses + ) + ) diff --git a/terraform/infrastructure/api_gateway.tf b/terraform/infrastructure/api_gateway.tf index 8ead46c2d..9b74c7214 100644 --- a/terraform/infrastructure/api_gateway.tf +++ b/terraform/infrastructure/api_gateway.tf @@ -34,7 +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_mhdsProcessTransation = "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--mhdsRecipient--processTransaction", 0, 64)}/invocations" + method_processTransation = "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 aa87c6825..514acba77 100644 --- a/terraform/infrastructure/lambda.tf +++ b/terraform/infrastructure/lambda.tf @@ -384,12 +384,12 @@ module "producer__status" { module "mhdsReceiver__processTransactionBundle" { source = "./modules/lambda" - parent_path = "api/mhds-recipient" + 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}/*/GET/_status"] + 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}--" From 60410cddea808981b7ac5866588fac4c5de047a9 Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Mon, 2 Dec 2024 09:28:08 +0000 Subject: [PATCH 03/12] [NRL-1051] Add transaction API to APIGW. Fix TF response state issue --- .../process_transaction_bundle.py | 37 +++++++++++++------ api/producer/swagger.yaml | 27 ++++++++++++++ terraform/infrastructure/api_gateway.tf | 2 +- .../modules/api_gateway/api_gateway.tf | 2 + 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/api/producer/processTransaction/process_transaction_bundle.py b/api/producer/processTransaction/process_transaction_bundle.py index 5148ae7ce..a63bf7d86 100644 --- a/api/producer/processTransaction/process_transaction_bundle.py +++ b/api/producer/processTransaction/process_transaction_bundle.py @@ -265,6 +265,16 @@ def create_document_reference( return NRLResponse.RESOURCE_CREATED(resource_id=result.resource.id) +def _convert_document_reference( + document_reference: DocumentReference, requested_profile: str +) -> DocumentReference: + """ + Convert the DocumentReference to the requested profile + """ + # TODO - Implement conversion logic from MHDS profile to NRLF FHIR profile + return document_reference + + @request_handler(body=Bundle) def handler( metadata: ConnectionMetadata, @@ -272,9 +282,9 @@ def handler( body: Bundle, ) -> Response: """ - Handles an MHDS transaction bundle request. + Handles an FHIR transaction bundle request. - Currently limited to register requests only. + Currently limited to create requests only and only supports either the MHDS profile or the NRLF profile. Args: metadata (ConnectionMetadata): The connection metadata. @@ -285,17 +295,17 @@ def handler( Response: The response indicating the result of the operation. """ # TODO - Add logging - # TODO - Add profile for NRLF too - if ( - body.meta - and body.meta.profile - and not body.meta.profile[0].endswith( - "profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" - ) + requested_profile = ( + body.meta.profile[0].root if body.meta and body.meta.profile else None + ) + + # TODO - Add profile for NRLF too (assume NRLF profile if not provided) + if requested_profile and not requested_profile.endswith( + "profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" ): return SpineErrorResponse.BAD_REQUEST( diagnostics="Only IHE.MHD.UnContained.Comprehensive.ProvideBundle profiles are supported", - expression="meta.profile", + expression="meta.profile[0]", ) if body.type != "transaction": @@ -305,7 +315,7 @@ def handler( ) if body.entry is None: - # TODO - Log that there was not entry + # TODO - Log that there was no entry return Response.from_resource( resource=Bundle(resourceType="Bundle", type="transaction-response") ) @@ -345,6 +355,11 @@ def handler( responses: list[Response] = [] for document_reference in document_references: try: + if requested_profile: + document_reference = _convert_document_reference( + document_reference, requested_profile + ) + create_response = create_document_reference( metadata, repository, document_reference ) diff --git a/api/producer/swagger.yaml b/api/producer/swagger.yaml index bdd0127b8..c2ab92756 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: diff --git a/terraform/infrastructure/api_gateway.tf b/terraform/infrastructure/api_gateway.tf index 9b74c7214..038baf90f 100644 --- a/terraform/infrastructure/api_gateway.tf +++ b/terraform/infrastructure/api_gateway.tf @@ -34,7 +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_processTransation = "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" + 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/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", From 73fb108dff050d2c5050567f54149b03e7f86018 Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Mon, 2 Dec 2024 13:21:35 +0000 Subject: [PATCH 04/12] [NRL-1051] Fix up transaction response structure. Add unit first unit test for NRLF transaction create --- .../process_transaction_bundle.py | 15 +++- .../tests/test_process_transaction_bundle.py | 88 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 api/producer/processTransaction/tests/test_process_transaction_bundle.py diff --git a/api/producer/processTransaction/process_transaction_bundle.py b/api/producer/processTransaction/process_transaction_bundle.py index a63bf7d86..0ccf1a9d6 100644 --- a/api/producer/processTransaction/process_transaction_bundle.py +++ b/api/producer/processTransaction/process_transaction_bundle.py @@ -16,6 +16,8 @@ from nrlf.producer.fhir.r4.model import ( BaseModel, Bundle, + BundleEntry, + BundleEntryResponse, DocumentReference, DocumentReferenceRelatesTo, ExpressionItem, @@ -223,7 +225,7 @@ def _raise_operation_outcome_error(diagnostics, idx): def create_document_reference( metadata: ConnectionMetadata, repository: DocumentPointerRepository, - document_reference: DocumentReference, + body: DocumentReference, ) -> Response: logger.log(LogReference.PROCREATE000) @@ -367,8 +369,17 @@ def handler( except OperationOutcomeError as e: responses.append(e.response) + response_entries = [ + BundleEntry( + response=BundleEntryResponse( + status=response.statusCode, location=response.headers["Location"] + ) + ) + for response in responses + ] + return Response.from_resource( resource=Bundle( - resourceType="Bundle", type="transaction-response", entry=responses + 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..579c128a5 --- /dev/null +++ b/api/producer/processTransaction/tests/test_process_transaction_bundle.py @@ -0,0 +1,88 @@ +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 handler +from nrlf.core.dynamodb.repository import DocumentPointerRepository +from nrlf.producer.fhir.r4.model import ( + Bundle, + BundleEntry, + BundleEntryRequest, + DocumentReference, +) +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_document_reference_with_transaction_happy_path( + repository: DocumentPointerRepository, +): + doc_ref: DocumentReference = load_document_reference("Y05868-736253002-Valid") + + 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", + }, + }, + ], + } + + 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.model_dump(exclude_none=True)), + "meta": { + "lastUpdated": "2024-03-21T12:34:56.789Z", + }, + "date": "2024-03-21T12:34:56.789Z", + "id": "Y05868-00000000-0000-0000-0000-000000000001", + } From 28629a0fe3b64089e73bab222e22a5c2d01fe700 Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Tue, 3 Dec 2024 11:00:03 +0000 Subject: [PATCH 05/12] [NRL-1051] Allow non-conformant docref in a Bundle resource --- .../process_transaction_bundle.py | 61 ++++++++++-- .../tests/test_process_transaction_bundle.py | 95 ++++++++++++++++++- api/producer/swagger.yaml | 2 +- layer/nrlf/consumer/fhir/r4/model.py | 2 +- layer/nrlf/producer/fhir/r4/model.py | 6 +- layer/nrlf/producer/fhir/r4/strict_model.py | 6 +- 6 files changed, 150 insertions(+), 22 deletions(-) diff --git a/api/producer/processTransaction/process_transaction_bundle.py b/api/producer/processTransaction/process_transaction_bundle.py index 0ccf1a9d6..30caa6a1b 100644 --- a/api/producer/processTransaction/process_transaction_bundle.py +++ b/api/producer/processTransaction/process_transaction_bundle.py @@ -1,3 +1,4 @@ +from typing import Any, Dict from uuid import uuid4 from nrlf.core.codes import SpineErrorConcept @@ -25,6 +26,24 @@ OperationOutcomeIssue, ) +# TODO - Figure out sensible defaults +# 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] @@ -268,13 +287,27 @@ def create_document_reference( def _convert_document_reference( - document_reference: DocumentReference, requested_profile: str + raw_resource: Dict[str, Any], requested_profile: str ) -> DocumentReference: """ Convert the DocumentReference to the requested profile """ - # TODO - Implement conversion logic from MHDS profile to NRLF FHIR profile - return document_reference + if requested_profile.endswith( + "profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" + ): + 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) @@ -322,12 +355,11 @@ def handler( resource=Bundle(resourceType="Bundle", type="transaction-response") ) - document_references: list[DocumentReference] = [] - + entries: list[BundleEntry] = [] issues: list[BaseModel] = [] for entry in body.entry: - if not entry.resource or entry.resource.resourceType != "DocumentReference": + if not entry.resource or entry.resource["resourceType"] != "DocumentReference": issues.append( OperationOutcomeIssue( severity="error", @@ -349,18 +381,29 @@ def handler( ) ) - document_references.append(DocumentReference.model_validate(entry.resource)) + entries.append(entry) if issues: return Response.from_issues(issues, statusCode="400") responses: list[Response] = [] - for document_reference in document_references: + for entry in entries: try: + if not entry.resource: + raise OperationOutcomeError( + severity="error", + code="exception", + diagnostics="No resource provided", + expression=["entry.resource"], + details=SpineErrorConcept.from_code("BAD_REQUEST"), + ) + if requested_profile: document_reference = _convert_document_reference( - document_reference, requested_profile + entry.resource, requested_profile ) + else: + document_reference = DocumentReference(**(entry.resource)) create_response = create_document_reference( metadata, repository, document_reference diff --git a/api/producer/processTransaction/tests/test_process_transaction_bundle.py b/api/producer/processTransaction/tests/test_process_transaction_bundle.py index 579c128a5..56accb28d 100644 --- a/api/producer/processTransaction/tests/test_process_transaction_bundle.py +++ b/api/producer/processTransaction/tests/test_process_transaction_bundle.py @@ -4,13 +4,17 @@ from freezegun import freeze_time from moto import mock_aws -from api.producer.processTransaction.process_transaction_bundle import handler +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, - DocumentReference, + Meta, + ProfileItem, ) from nrlf.tests.data import load_document_reference from nrlf.tests.dynamodb import mock_repository @@ -26,10 +30,12 @@ @mock_repository @freeze_time("2024-03-21T12:34:56.789") @freeze_uuid("00000000-0000-0000-0000-000000000001") -def test_create_single_document_reference_with_transaction_happy_path( +def test_create_single_nrl_document_reference_with_transaction_happy_path( repository: DocumentPointerRepository, ): - doc_ref: DocumentReference = load_document_reference("Y05868-736253002-Valid") + doc_ref = load_document_reference("Y05868-736253002-Valid").model_dump( + exclude_none=True + ) request_bundle = Bundle( entry=[ @@ -79,7 +85,86 @@ def test_create_single_document_reference_with_transaction_happy_path( 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.model_dump(exclude_none=True)), + **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", + }, + }, + ], + } + + 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", }, diff --git a/api/producer/swagger.yaml b/api/producer/swagger.yaml index c2ab92756..0bb64e48d 100644 --- a/api/producer/swagger.yaml +++ b/api/producer/swagger.yaml @@ -1505,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" diff --git a/layer/nrlf/consumer/fhir/r4/model.py b/layer/nrlf/consumer/fhir/r4/model.py index 4665533b5..663ab870c 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-02T21:57:23+00:00 from __future__ import annotations diff --git a/layer/nrlf/producer/fhir/r4/model.py b/layer/nrlf/producer/fhir/r4/model.py index d96b7ce73..b51820be1 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-02T21:57:21+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." ), diff --git a/layer/nrlf/producer/fhir/r4/strict_model.py b/layer/nrlf/producer/fhir/r4/strict_model.py index e4edefc58..1abd38fdc 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-02T21:57:22+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." ), From 7379f8b241d27aaea2c54dcf19f3bd4bffe1fdb4 Mon Sep 17 00:00:00 2001 From: eesa456 Date: Wed, 4 Dec 2024 12:48:15 +0000 Subject: [PATCH 06/12] NRL-1051 remove location header --- .../processTransaction/process_transaction_bundle.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/api/producer/processTransaction/process_transaction_bundle.py b/api/producer/processTransaction/process_transaction_bundle.py index 30caa6a1b..82d439deb 100644 --- a/api/producer/processTransaction/process_transaction_bundle.py +++ b/api/producer/processTransaction/process_transaction_bundle.py @@ -413,11 +413,7 @@ def handler( responses.append(e.response) response_entries = [ - BundleEntry( - response=BundleEntryResponse( - status=response.statusCode, location=response.headers["Location"] - ) - ) + BundleEntry(response=BundleEntryResponse(status=response.statusCode)) for response in responses ] From f626370b87d3455bcd1480d4eee8d1ab5863bcf2 Mon Sep 17 00:00:00 2001 From: eesa456 Date: Wed, 4 Dec 2024 13:06:37 +0000 Subject: [PATCH 07/12] NRL-1051 change tests --- .../tests/test_process_transaction_bundle.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/api/producer/processTransaction/tests/test_process_transaction_bundle.py b/api/producer/processTransaction/tests/test_process_transaction_bundle.py index 56accb28d..6d76469dc 100644 --- a/api/producer/processTransaction/tests/test_process_transaction_bundle.py +++ b/api/producer/processTransaction/tests/test_process_transaction_bundle.py @@ -69,10 +69,7 @@ def test_create_single_nrl_document_reference_with_transaction_happy_path( "type": "transaction-response", "entry": [ { - "response": { - "status": "201", - "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", - }, + "response": {"status": "201"}, }, ], } @@ -149,7 +146,6 @@ def test_create_single_mhds_document_reference_with_transaction_happy_path( { "response": { "status": "201", - "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", }, }, ], From 40d1915fb1bd2ce95db414411f29d99a73e1c3e3 Mon Sep 17 00:00:00 2001 From: eesa456 Date: Wed, 4 Dec 2024 22:11:20 +0000 Subject: [PATCH 08/12] NRL-1051 add location header back --- .../processTransaction/process_transaction_bundle.py | 6 +++++- .../tests/test_process_transaction_bundle.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/api/producer/processTransaction/process_transaction_bundle.py b/api/producer/processTransaction/process_transaction_bundle.py index 82d439deb..ac2087c52 100644 --- a/api/producer/processTransaction/process_transaction_bundle.py +++ b/api/producer/processTransaction/process_transaction_bundle.py @@ -413,7 +413,11 @@ def handler( responses.append(e.response) response_entries = [ - BundleEntry(response=BundleEntryResponse(status=response.statusCode)) + BundleEntry( + response=BundleEntryResponse( + status=response.statusCode, location=response.headers.get("Location") + ) + ) for response in responses ] diff --git a/api/producer/processTransaction/tests/test_process_transaction_bundle.py b/api/producer/processTransaction/tests/test_process_transaction_bundle.py index 6d76469dc..56accb28d 100644 --- a/api/producer/processTransaction/tests/test_process_transaction_bundle.py +++ b/api/producer/processTransaction/tests/test_process_transaction_bundle.py @@ -69,7 +69,10 @@ def test_create_single_nrl_document_reference_with_transaction_happy_path( "type": "transaction-response", "entry": [ { - "response": {"status": "201"}, + "response": { + "status": "201", + "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", + }, }, ], } @@ -146,6 +149,7 @@ def test_create_single_mhds_document_reference_with_transaction_happy_path( { "response": { "status": "201", + "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", }, }, ], From 968117e37bc58cc0740987ffd59e86f441dac122 Mon Sep 17 00:00:00 2001 From: eesa456 Date: Wed, 11 Dec 2024 03:33:27 +0000 Subject: [PATCH 09/12] NRL-1051 tests, permissions, logs, other stuff --- .../process_transaction_bundle.py | 22 +- .../tests/test_process_transaction_bundle.py | 520 ++++++++++++++++++ layer/nrlf/consumer/fhir/r4/model.py | 2 +- layer/nrlf/core/log_references.py | 17 + terraform/infrastructure/lambda.tf | 4 +- 5 files changed, 549 insertions(+), 16 deletions(-) diff --git a/api/producer/processTransaction/process_transaction_bundle.py b/api/producer/processTransaction/process_transaction_bundle.py index ac2087c52..424cf69eb 100644 --- a/api/producer/processTransaction/process_transaction_bundle.py +++ b/api/producer/processTransaction/process_transaction_bundle.py @@ -26,7 +26,6 @@ OperationOutcomeIssue, ) -# TODO - Figure out sensible defaults # NOTE: while type, category and custodian are not required in MHDS profile, they will be required by NRLF DEFAULT_MHDS_AUTHOR = { "identifier": { @@ -295,6 +294,7 @@ def _convert_document_reference( 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) @@ -329,28 +329,30 @@ def handler( Returns: Response: The response indicating the result of the operation. """ - # TODO - Add logging + logger.log(LogReference.PROTRAN000) + requested_profile = ( body.meta.profile[0].root if body.meta and body.meta.profile else None ) - # TODO - Add profile for NRLF too (assume NRLF profile if not provided) 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: - # TODO - Log that there was no entry + logger.log(LogReference.PROTRAN003) return Response.from_resource( resource=Bundle(resourceType="Bundle", type="transaction-response") ) @@ -360,6 +362,7 @@ def handler( for entry in body.entry: if not entry.resource or entry.resource["resourceType"] != "DocumentReference": + logger.log(LogReference.PROTRAN004) issues.append( OperationOutcomeIssue( severity="error", @@ -371,6 +374,7 @@ def handler( ) if entry.request.method != "POST": + logger.log(LogReference.PROTRAN005) issues.append( OperationOutcomeIssue( severity="error", @@ -389,15 +393,6 @@ def handler( responses: list[Response] = [] for entry in entries: try: - if not entry.resource: - raise OperationOutcomeError( - severity="error", - code="exception", - diagnostics="No resource provided", - expression=["entry.resource"], - details=SpineErrorConcept.from_code("BAD_REQUEST"), - ) - if requested_profile: document_reference = _convert_document_reference( entry.resource, requested_profile @@ -421,6 +416,7 @@ def handler( 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 index 56accb28d..6af23d8e7 100644 --- a/api/producer/processTransaction/tests/test_process_transaction_bundle.py +++ b/api/producer/processTransaction/tests/test_process_transaction_bundle.py @@ -171,3 +171,523 @@ def test_create_single_mhds_document_reference_with_transaction_happy_path( "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", + }, + }, + { + "response": { + "status": "201", + "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000002", + }, + }, + ], + } + + 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", + }, + }, + { + "response": { + "status": "201", + "location": "/producer/FHIR/R4/DocumentReference/Y05868-00000000-0000-0000-0000-000000000002", + }, + }, + ], + } + + 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/layer/nrlf/consumer/fhir/r4/model.py b/layer/nrlf/consumer/fhir/r4/model.py index 663ab870c..4665533b5 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-12-02T21:57:23+00:00 +# timestamp: 2024-11-20T09:43:58+00:00 from __future__ import annotations 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/terraform/infrastructure/lambda.tf b/terraform/infrastructure/lambda.tf index 514acba77..9eaa8378e 100644 --- a/terraform/infrastructure/lambda.tf +++ b/terraform/infrastructure/lambda.tf @@ -395,12 +395,12 @@ module "mhdsReceiver__processTransactionBundle" { PREFIX = "${local.prefix}--" ENVIRONMENT = local.environment AUTH_STORE = local.auth_store_id - POWERTOOLS_LOG_LEVEL = local.log_level SPLUNK_INDEX = module.firehose__processor.splunk.index - DYNAMODB_TIMEOUT = local.dynamodb_timeout_seconds + 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 From b3a0dadad4e66cbb44c0a3eb5bde0212cec17080 Mon Sep 17 00:00:00 2001 From: eesa456 Date: Wed, 11 Dec 2024 04:18:57 +0000 Subject: [PATCH 10/12] NRL-1051 fix int tests --- tests/features/steps/3_assert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/features/steps/3_assert.py b/tests/features/steps/3_assert.py index 1ce9c82f8..112a2f492 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) From 8c627ee0d965d74e6b28bfb8f2bae9bbc63e4993 Mon Sep 17 00:00:00 2001 From: eesa456 Date: Wed, 11 Dec 2024 04:32:28 +0000 Subject: [PATCH 11/12] NRL-1051 fix int tests --- tests/features/steps/3_assert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/features/steps/3_assert.py b/tests/features/steps/3_assert.py index 112a2f492..7f86c7a3e 100644 --- a/tests/features/steps/3_assert.py +++ b/tests/features/steps/3_assert.py @@ -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" From 62e08d649900eb4199347e3584d0f63113157a15 Mon Sep 17 00:00:00 2001 From: eesa456 Date: Thu, 12 Dec 2024 12:59:55 +0000 Subject: [PATCH 12/12] NRL-1051 have response message in outcome field for each entry --- api/consumer/swagger.yaml | 2 +- .../process_transaction_bundle.py | 5 +- .../tests/test_process_transaction_bundle.py | 115 ++++++++++++++++++ api/producer/swagger.yaml | 2 +- layer/nrlf/consumer/fhir/r4/model.py | 4 +- layer/nrlf/producer/fhir/r4/model.py | 4 +- layer/nrlf/producer/fhir/r4/strict_model.py | 4 +- 7 files changed, 127 insertions(+), 9 deletions(-) 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 index 424cf69eb..f569074b2 100644 --- a/api/producer/processTransaction/process_transaction_bundle.py +++ b/api/producer/processTransaction/process_transaction_bundle.py @@ -23,6 +23,7 @@ DocumentReferenceRelatesTo, ExpressionItem, Meta, + OperationOutcome, OperationOutcomeIssue, ) @@ -410,7 +411,9 @@ def handler( response_entries = [ BundleEntry( response=BundleEntryResponse( - status=response.statusCode, location=response.headers.get("Location") + status=response.statusCode, + location=response.headers.get("Location"), + outcome=OperationOutcome.model_validate_json(response.body), ) ) for response in responses diff --git a/api/producer/processTransaction/tests/test_process_transaction_bundle.py b/api/producer/processTransaction/tests/test_process_transaction_bundle.py index 6af23d8e7..47b9478c0 100644 --- a/api/producer/processTransaction/tests/test_process_transaction_bundle.py +++ b/api/producer/processTransaction/tests/test_process_transaction_bundle.py @@ -72,6 +72,25 @@ def test_create_single_nrl_document_reference_with_transaction_happy_path( "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", + } + ], + }, }, }, ], @@ -150,6 +169,25 @@ def test_create_single_mhds_document_reference_with_transaction_happy_path( "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", + } + ], + }, }, }, ], @@ -241,12 +279,50 @@ def test_create_multiple_mhds_document_reference_with_transaction_happy_path( "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", + } + ], + }, }, }, ], @@ -354,12 +430,51 @@ def test_create_multiple_mhds_document_reference_with_transaction_wrong_ods_retu { "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", + } + ], + }, }, }, ], diff --git a/api/producer/swagger.yaml b/api/producer/swagger.yaml index 0bb64e48d..e2f544aa4 100644 --- a/api/producer/swagger.yaml +++ b/api/producer/swagger.yaml @@ -1540,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/producer/fhir/r4/model.py b/layer/nrlf/producer/fhir/r4/model.py index b51820be1..ad83ca572 100644 --- a/layer/nrlf/producer/fhir/r4/model.py +++ b/layer/nrlf/producer/fhir/r4/model.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: swagger.yaml -# timestamp: 2024-12-02T21:57:21+00:00 +# timestamp: 2024-12-12T12:52:09+00:00 from __future__ import annotations @@ -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 1abd38fdc..94480f44e 100644 --- a/layer/nrlf/producer/fhir/r4/strict_model.py +++ b/layer/nrlf/producer/fhir/r4/strict_model.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: swagger.yaml -# timestamp: 2024-12-02T21:57:22+00:00 +# timestamp: 2024-12-12T12:52:11+00:00 from __future__ import annotations @@ -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." ),