Skip to content

Commit 6dffea4

Browse files
committed
[NRL-1051] Add initial impl for MHDS transaction API
1 parent d562cd2 commit 6dffea4

File tree

4 files changed

+370
-1
lines changed

4 files changed

+370
-1
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ build-layers: ./layer/*
6666
./scripts/build-lambda-layer.sh $${layer} $(DIST_PATH); \
6767
done
6868

69-
build-api-packages: ./api/consumer/* ./api/producer/*
69+
build-api-packages: ./api/consumer/* ./api/producer/* ./api/mhds-recipient/*
7070
@echo "Building API packages"
7171
@mkdir -p $(DIST_PATH)
7272
for api in $^; do \
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
from uuid import uuid4
2+
3+
from nrlf.core.codes import SpineErrorConcept
4+
from nrlf.core.constants import (
5+
PERMISSION_AUDIT_DATES_FROM_PAYLOAD,
6+
PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL,
7+
)
8+
from nrlf.core.decorators import request_handler
9+
from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository
10+
from nrlf.core.errors import OperationOutcomeError
11+
from nrlf.core.logger import LogReference, logger
12+
from nrlf.core.model import ConnectionMetadata
13+
from nrlf.core.response import NRLResponse, Response, SpineErrorResponse
14+
from nrlf.core.utils import create_fhir_instant
15+
from nrlf.core.validators import DocumentReferenceValidator
16+
from nrlf.producer.fhir.r4.model import (
17+
BaseModel,
18+
Bundle,
19+
DocumentReference,
20+
DocumentReferenceRelatesTo,
21+
ExpressionItem,
22+
Meta,
23+
OperationOutcomeIssue,
24+
)
25+
26+
27+
def _set_create_time_fields(
28+
create_time: str, document_reference: DocumentReference, nrl_permissions: list[str]
29+
) -> DocumentReference:
30+
"""
31+
Set the date and lastUpdated timestamps on the provided DocumentReference
32+
"""
33+
if not document_reference.meta:
34+
document_reference.meta = Meta()
35+
document_reference.meta.lastUpdated = create_time
36+
37+
if (
38+
document_reference.date
39+
and PERMISSION_AUDIT_DATES_FROM_PAYLOAD in nrl_permissions
40+
):
41+
# Perserving the original date if it exists and the permission is set
42+
logger.log(
43+
LogReference.PROCREATE011,
44+
id=document_reference.id,
45+
date=document_reference.date,
46+
)
47+
else:
48+
document_reference.date = create_time
49+
50+
return document_reference
51+
52+
53+
def _create_core_model(resource: DocumentReference, metadata: ConnectionMetadata):
54+
"""
55+
Create the DocumentPointer model from the provided DocumentReference
56+
"""
57+
creation_time = create_fhir_instant()
58+
document_reference = _set_create_time_fields(
59+
creation_time,
60+
document_reference=resource,
61+
nrl_permissions=metadata.nrl_permissions,
62+
)
63+
64+
return DocumentPointer.from_document_reference(
65+
document_reference, created_on=creation_time
66+
)
67+
68+
69+
def _check_permissions(
70+
core_model: DocumentPointer, metadata: ConnectionMetadata
71+
) -> Response | None:
72+
"""
73+
Check the requester has permissions to create the DocumentReference
74+
"""
75+
custodian_parts = tuple(
76+
filter(None, (core_model.custodian, core_model.custodian_suffix))
77+
)
78+
if metadata.ods_code_parts != custodian_parts:
79+
logger.log(
80+
LogReference.PROCREATE004,
81+
ods_code_parts=metadata.ods_code_parts,
82+
custodian_parts=custodian_parts,
83+
)
84+
return SpineErrorResponse.BAD_REQUEST(
85+
diagnostics="The custodian of the provided DocumentReference does not match the expected ODS code for this organisation",
86+
expression="custodian.identifier.value",
87+
)
88+
89+
if core_model.type not in metadata.pointer_types:
90+
logger.log(
91+
LogReference.PROCREATE005,
92+
ods_code=metadata.ods_code,
93+
type=core_model.type,
94+
pointer_types=metadata.pointer_types,
95+
)
96+
return SpineErrorResponse.AUTHOR_CREDENTIALS_ERROR(
97+
diagnostics="The type of the provided DocumentReference is not in the list of allowed types for this organisation",
98+
expression="type.coding[0].code",
99+
)
100+
101+
return None
102+
103+
104+
def _get_document_ids_to_supersede(
105+
resource: DocumentReference,
106+
core_model: DocumentPointer,
107+
metadata: ConnectionMetadata,
108+
repository: DocumentPointerRepository,
109+
can_ignore_delete_fail: bool,
110+
) -> list[str]:
111+
"""
112+
Get the list of document IDs to supersede based on the relatesTo field
113+
"""
114+
if not resource.relatesTo:
115+
return []
116+
117+
logger.log(LogReference.PROCREATE006, relatesTo=resource.relatesTo)
118+
ids_to_delete: list[str] = []
119+
120+
for idx, relates_to in enumerate(resource.relatesTo):
121+
identifier = _validate_identifier(relates_to, idx)
122+
_validate_producer_id(identifier, metadata, idx)
123+
124+
if not can_ignore_delete_fail:
125+
existing_pointer = _check_existing_pointer(identifier, repository, idx)
126+
_validate_pointer_details(existing_pointer, core_model, identifier, idx)
127+
128+
_append_id_if_replaces(relates_to, ids_to_delete, identifier)
129+
130+
return ids_to_delete
131+
132+
133+
def _validate_identifier(
134+
relates_to: DocumentReferenceRelatesTo, idx: str
135+
) -> str | None:
136+
"""
137+
Validate that there is a identifier in relatesTo target
138+
"""
139+
identifier = getattr(relates_to.target.identifier, "value", None)
140+
if not identifier:
141+
logger.log(LogReference.PROCREATE007a)
142+
_raise_operation_outcome_error(
143+
"No identifier value provided for relatesTo target", idx
144+
)
145+
return identifier
146+
147+
148+
def _validate_producer_id(identifier, metadata, idx):
149+
"""
150+
Validate that there is an ODS code in the relatesTo target identifier
151+
"""
152+
producer_id = identifier.split("-", 1)[0]
153+
if metadata.ods_code_parts != tuple(producer_id.split("|")):
154+
logger.log(
155+
LogReference.PROCREATE007b,
156+
related_identifier=identifier,
157+
ods_code_parts=metadata.ods_code_parts,
158+
)
159+
_raise_operation_outcome_error(
160+
"The relatesTo target identifier value does not include the expected ODS code for this organisation",
161+
idx,
162+
)
163+
164+
165+
def _check_existing_pointer(identifier, repository, idx):
166+
"""
167+
Check that there is an existing pointer that will be deleted when superseding
168+
"""
169+
existing_pointer = repository.get_by_id(identifier)
170+
if not existing_pointer:
171+
logger.log(LogReference.PROCREATE007c, related_identifier=identifier)
172+
_raise_operation_outcome_error(
173+
"The relatesTo target document does not exist", idx
174+
)
175+
return existing_pointer
176+
177+
178+
def _validate_pointer_details(existing_pointer, core_model, identifier, idx):
179+
"""
180+
Validate that the nhs numbers and type matches between the existing pointer and the requested one.
181+
"""
182+
if existing_pointer.nhs_number != core_model.nhs_number:
183+
logger.log(LogReference.PROCREATE007d, related_identifier=identifier)
184+
_raise_operation_outcome_error(
185+
"The relatesTo target document NHS number does not match the NHS number in the request",
186+
idx,
187+
)
188+
189+
if existing_pointer.type != core_model.type:
190+
logger.log(LogReference.PROCREATE007e, related_identifier=identifier)
191+
_raise_operation_outcome_error(
192+
"The relatesTo target document type does not match the type in the request",
193+
idx,
194+
)
195+
196+
197+
def _append_id_if_replaces(relates_to, ids_to_delete, identifier):
198+
"""
199+
Append pointer ID if the if the relatesTo code is 'replaces'
200+
"""
201+
if relates_to.code == "replaces":
202+
logger.log(
203+
LogReference.PROCREATE008,
204+
relates_to_code=relates_to.code,
205+
identifier=identifier,
206+
)
207+
ids_to_delete.append(identifier)
208+
209+
210+
def _raise_operation_outcome_error(diagnostics, idx):
211+
"""
212+
General function to raise an operation outcome error
213+
"""
214+
raise OperationOutcomeError(
215+
severity="error",
216+
code="invalid",
217+
details=SpineErrorConcept.from_code("BAD_REQUEST"),
218+
diagnostics=diagnostics,
219+
expression=[f"relatesTo[{idx}].target.identifier.value"],
220+
)
221+
222+
223+
def create_document_reference(
224+
metadata: ConnectionMetadata,
225+
repository: DocumentPointerRepository,
226+
document_reference: DocumentReference,
227+
) -> Response:
228+
229+
logger.log(LogReference.PROCREATE000)
230+
logger.log(LogReference.PROCREATE001, resource=body)
231+
232+
id_prefix = "|".join(metadata.ods_code_parts)
233+
body.id = f"{id_prefix}-{uuid4()}"
234+
235+
validator = DocumentReferenceValidator()
236+
result = validator.validate(body)
237+
238+
if not result.is_valid:
239+
logger.log(LogReference.PROCREATE002)
240+
return Response.from_issues(issues=result.issues, statusCode="400")
241+
242+
core_model = _create_core_model(result.resource, metadata)
243+
if error_response := _check_permissions(core_model, metadata):
244+
return error_response
245+
246+
can_ignore_delete_fail = (
247+
PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL in metadata.nrl_permissions
248+
)
249+
250+
if ids_to_delete := _get_document_ids_to_supersede(
251+
result.resource, core_model, metadata, repository, can_ignore_delete_fail
252+
):
253+
logger.log(
254+
LogReference.PROCREATE010,
255+
pointer_id=result.resource.id,
256+
ids_to_delete=ids_to_delete,
257+
)
258+
repository.supersede(core_model, ids_to_delete, can_ignore_delete_fail)
259+
logger.log(LogReference.PROCREATE999)
260+
return NRLResponse.RESOURCE_SUPERSEDED(resource_id=result.resource.id)
261+
262+
logger.log(LogReference.PROCREATE009, pointer_id=result.resource.id)
263+
repository.create(core_model)
264+
logger.log(LogReference.PROCREATE999)
265+
return NRLResponse.RESOURCE_CREATED(resource_id=result.resource.id)
266+
267+
268+
@request_handler(body=Bundle)
269+
def handler(
270+
metadata: ConnectionMetadata,
271+
repository: DocumentPointerRepository,
272+
body: Bundle,
273+
) -> Response:
274+
"""
275+
Handles an MHDS transaction bundle request.
276+
277+
Currently limited to register requests only.
278+
279+
Args:
280+
metadata (ConnectionMetadata): The connection metadata.
281+
repository (DocumentPointerRepository): The document pointer repository.
282+
body (Bundle): The bundle containing the resources to process.
283+
284+
Returns:
285+
Response: The response indicating the result of the operation.
286+
"""
287+
if not body.meta.profile[0].endswith(
288+
"profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle"
289+
):
290+
return SpineErrorResponse.BAD_REQUEST(
291+
diagnostics="Only IHE.MHD.UnContained.Comprehensive.ProvideBundle profiles are supported",
292+
expression="meta.profile",
293+
)
294+
295+
if body.type != "transaction":
296+
return SpineErrorResponse.BAD_REQUEST(
297+
diagnostics="Only transaction bundles are supported",
298+
expression="type",
299+
)
300+
301+
if body.entry is None:
302+
return SpineErrorResponse.BAD_REQUEST(
303+
diagnostics="The bundle must contain at least one entry", expression="entry"
304+
)
305+
306+
document_references: list[DocumentReference] = []
307+
308+
# TODO - Handle this better
309+
issues: list[BaseModel] = []
310+
311+
for entry in body.entry:
312+
if not entry.resource or entry.resource.resourceType != "DocumentReference":
313+
issues.append(
314+
OperationOutcomeIssue(
315+
severity="error",
316+
code="exception",
317+
diagnostics="Only DocumentReference resources are supported",
318+
expression=[ExpressionItem("entry.resource.resourceType")],
319+
details=SpineErrorConcept.from_code("BAD_REQUEST"),
320+
)
321+
)
322+
323+
document_references.append(DocumentReference.model_validate(entry.resource))
324+
325+
if issues:
326+
return Response.from_issues(issues, statusCode="400")
327+
328+
responses: list[Response] = []
329+
for document_reference in document_references:
330+
try:
331+
create_response = create_document_reference(
332+
metadata, repository, document_reference
333+
)
334+
responses.append(create_response)
335+
except OperationOutcomeError as e:
336+
responses.append(e.response)
337+
338+
return NRLResponse.BUNDLE_CREATED(responses)

terraform/infrastructure/api_gateway.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ module "producer__gateway" {
3434
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"
3535
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"
3636
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"
37+
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"
3738
}
3839

3940
kms_key_id = module.kms__cloudwatch.kms_arn

terraform/infrastructure/lambda.tf

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,33 @@ module "producer__status" {
381381
handler = "status.handler"
382382
retention = var.log_retention_period
383383
}
384+
385+
module "mhdsReceiver__processTransactionBundle" {
386+
source = "./modules/lambda"
387+
parent_path = "api/mhds-recipient"
388+
name = "processTransaction"
389+
region = local.region
390+
prefix = local.prefix
391+
layers = [module.nrlf.layer_arn, module.third_party.layer_arn, module.nrlf_permissions.layer_arn]
392+
api_gateway_source_arn = ["arn:aws:execute-api:${local.region}:${local.aws_account_id}:${module.producer__gateway.api_gateway_id}/*/GET/_status"]
393+
kms_key_id = module.kms__cloudwatch.kms_arn
394+
environment_variables = {
395+
PREFIX = "${local.prefix}--"
396+
ENVIRONMENT = local.environment
397+
AUTH_STORE = local.auth_store_id
398+
POWERTOOLS_LOG_LEVEL = local.log_level
399+
SPLUNK_INDEX = module.firehose__processor.splunk.index
400+
DYNAMODB_TIMEOUT = local.dynamodb_timeout_seconds
401+
TABLE_NAME = local.pointers_table_name
402+
}
403+
additional_policies = [
404+
local.pointers_table_read_policy_arn,
405+
local.pointers_kms_read_write_arn,
406+
local.auth_store_read_policy_arn
407+
]
408+
firehose_subscriptions = [
409+
module.firehose__processor.firehose_subscription
410+
]
411+
handler = "process_transaction_bundle.handler"
412+
retention = var.log_retention_period
413+
}

0 commit comments

Comments
 (0)