diff --git a/api/consumer/searchDocumentReference/search_document_reference.py b/api/consumer/searchDocumentReference/search_document_reference.py index b313e11b1..b420c7132 100644 --- a/api/consumer/searchDocumentReference/search_document_reference.py +++ b/api/consumer/searchDocumentReference/search_document_reference.py @@ -9,7 +9,7 @@ from nrlf.core.logger import LogReference, logger from nrlf.core.model import ConnectionMetadata, ConsumerRequestParams from nrlf.core.response import Response, SpineErrorResponse -from nrlf.core.validators import validate_type_system +from nrlf.core.validators import validate_category, validate_type_system @request_handler(params=ConsumerRequestParams) @@ -58,6 +58,16 @@ def handler( expression="type", ) + if not validate_category(params.category): + logger.log( + LogReference.CONSEARCH002b, + category=params.category, + ) # TODO - Should update error message once permissioning by category is implemented + return SpineErrorResponse.INVALID_CODE_SYSTEM( + diagnostics="Invalid query parameter (The provided category is not valid)", + expression="category", + ) + custodian_id = ( params.custodian_identifier.root.split("|", maxsplit=1)[1] if params.custodian_identifier @@ -70,6 +80,9 @@ def handler( if params.type: self_link += f"&type={params.type.root}" + if params.category: + self_link += f"&category={params.category.root}" + bundle = { "resourceType": "Bundle", "type": "searchset", @@ -89,6 +102,7 @@ def handler( nhs_number=params.nhs_number, custodian=custodian_id, pointer_types=pointer_types, + categories=[params.category.root] if params.category else [], ): try: document_reference = DocumentReference.model_validate_json(result.document) diff --git a/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py b/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py index 72d1b70b8..1b8cd23b1 100644 --- a/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py +++ b/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py @@ -135,6 +135,47 @@ def test_search_document_reference_happy_path_with_type( } +@mock_aws +@mock_repository +def test_search_document_reference_happy_path_with_category( + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + event = create_test_api_gateway_event( + headers=create_headers(), + query_string_parameters={ + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "category": "http://snomed.info/sct|734163000", + }, + ) + + 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": "searchset", + "link": [ + { + "relation": "self", + "url": "https://pytest.api.service.nhs.uk/record-locator/consumer/FHIR/R4/DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|6700028191&category=http://snomed.info/sct|734163000", + } + ], + "total": 1, + "entry": [{"resource": doc_ref.model_dump(exclude_none=True)}], + } + + @mock_aws @mock_repository def test_search_document_reference_happy_path_with_nicip_type( @@ -342,6 +383,51 @@ def test_search_document_reference_invalid_type(repository: DocumentPointerRepos } +@mock_aws +@mock_repository +def test_search_document_reference_invalid_category( + repository: DocumentPointerRepository, +): + event = create_test_api_gateway_event( + headers=create_headers(), + query_string_parameters={ + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "category": "https://fhir.nhs.uk/CodeSystem/Document-Type|invalid", + }, + ) + + 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": "code-invalid", + "details": { + "coding": [ + { + "code": "INVALID_CODE_SYSTEM", + "display": "Invalid code system", + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + } + ] + }, + "diagnostics": "Invalid query parameter (The provided category is not valid)", + "expression": ["category"], + } + ], + } + + @mock_aws @mock_repository def test_search_document_reference_invalid_json(repository: DocumentPointerRepository): diff --git a/api/consumer/searchPostDocumentReference/search_post_document_reference.py b/api/consumer/searchPostDocumentReference/search_post_document_reference.py index 353ebf5c5..18ac8e606 100644 --- a/api/consumer/searchPostDocumentReference/search_post_document_reference.py +++ b/api/consumer/searchPostDocumentReference/search_post_document_reference.py @@ -9,7 +9,7 @@ from nrlf.core.logger import LogReference, logger from nrlf.core.model import ConnectionMetadata, ConsumerRequestParams from nrlf.core.response import Response, SpineErrorResponse -from nrlf.core.validators import validate_type_system +from nrlf.core.validators import validate_category, validate_type_system @request_handler(body=ConsumerRequestParams) @@ -61,6 +61,16 @@ def handler( expression="type", ) + if not validate_category(body.category): + logger.log( + LogReference.CONPOSTSEARCH002b, + type=body.category, + ) # TODO - Should update error message once permissioning by category is implemented + return SpineErrorResponse.INVALID_CODE_SYSTEM( + diagnostics="The provided category is not valid", + expression="category", + ) + custodian_id = ( body.custodian_identifier.root.split("|", maxsplit=1)[1] if body.custodian_identifier @@ -73,6 +83,9 @@ def handler( if body.type: self_link += f"&type={body.type.root}" + if body.category: + self_link += f"&category={body.category.root}" + bundle = { "resourceType": "Bundle", "type": "searchset", @@ -89,7 +102,10 @@ def handler( ) for result in repository.search( - nhs_number=body.nhs_number, custodian=custodian_id, pointer_types=pointer_types + nhs_number=body.nhs_number, + custodian=custodian_id, + pointer_types=pointer_types, + categories=[body.category.root] if body.category else [], ): try: document_reference = DocumentReference.model_validate_json(result.document) diff --git a/api/consumer/searchPostDocumentReference/tests/test_search_post_document_reference_consumer.py b/api/consumer/searchPostDocumentReference/tests/test_search_post_document_reference_consumer.py index 016f6ebc9..3dc41ba45 100644 --- a/api/consumer/searchPostDocumentReference/tests/test_search_post_document_reference_consumer.py +++ b/api/consumer/searchPostDocumentReference/tests/test_search_post_document_reference_consumer.py @@ -144,6 +144,49 @@ def test_search_post_document_reference_happy_path_with_type( } +@mock_aws +@mock_repository +def test_search_post_document_reference_happy_path_with_category( + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=json.dumps( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "category": "http://snomed.info/sct|734163000", + }, + ), + ) + + 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": "searchset", + "link": [ + { + "relation": "self", + "url": "https://pytest.api.service.nhs.uk/record-locator/consumer/FHIR/R4/DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|6700028191&category=http://snomed.info/sct|734163000", + } + ], + "total": 1, + "entry": [{"resource": doc_ref.model_dump(exclude_none=True)}], + } + + @mock_aws @mock_repository def test_search_document_reference_no_results(repository: DocumentPointerRepository): @@ -310,6 +353,53 @@ def test_search_post_document_reference_invalid_type( } +@mock_aws +@mock_repository +def test_search_document_reference_invalid_category( + repository: DocumentPointerRepository, +): + event = create_test_api_gateway_event( + headers=create_headers(), + body=json.dumps( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "category": "https://fhir.nhs.uk/CodeSystem/Document-Type|invalid", + } + ), + ) + + 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": "code-invalid", + "details": { + "coding": [ + { + "code": "INVALID_CODE_SYSTEM", + "display": "Invalid code system", + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + } + ] + }, + "diagnostics": "The provided category is not valid", + "expression": ["category"], + } + ], + } + + @mock_aws @mock_repository def test_search_post_document_reference_invalid_json( diff --git a/api/consumer/swagger.yaml b/api/consumer/swagger.yaml index 6e831a1bd..6356607b3 100644 --- a/api/consumer/swagger.yaml +++ b/api/consumer/swagger.yaml @@ -257,6 +257,7 @@ paths: - $ref: "#/components/parameters/subject" - $ref: "#/components/parameters/custodian" - $ref: "#/components/parameters/type" + - $ref: "#/components/parameters/category" - $ref: "#/components/parameters/nextPageToken" - $ref: "#/components/parameters/odsCode" - $ref: "#/components/parameters/odsCodeExtension" @@ -1399,6 +1400,8 @@ components: $ref: "#/components/schemas/RequestQueryCustodian" type: $ref: "#/components/schemas/RequestQueryType" + category: + $ref: "#/components/schemas/RequestQueryCategory" next-page-token: $ref: "#/components/schemas/NextPageToken" required: @@ -1421,6 +1424,9 @@ components: RequestQueryType: type: string example: "http://snomed.info/sct|736253002" + RequestQueryCategory: + type: string + example: "http://snomed.info/sct|103693007" NextPageToken: type: string RequestHeaderOdsCode: @@ -1531,6 +1537,33 @@ components: invalid: summary: Unknown value: http://snomed.info/sct|410970009 + category: + name: category + in: query + schema: + $ref: "#/components/schemas/RequestQueryCategory" + examples: + none: + summary: None + value: "" + SNOMED_CODES_CARE_PLAN: + summary: Care plan + value: http://snomed.info/sct|734163000 + SNOMED_CODES_OBSERVATIONS: + summary: Observations + value: http://snomed.info/sct|1102421000000108 + SNOMED_CODES_CLINICAL_NOTE: + summary: Clinical note + value: http://snomed.info/sct|823651000000106 + SNOMED_CODES_DIAGNOSTIC_STUDIES_REPORT: + summary: Diagnostic studies report + value: http://snomed.info/sct|721981007 + SNOMED_CODES_DIAGNOSTIC_PROCEDURE: + summary: Diagnostic procedure + value: http://snomed.info/sct|103693007 + invalid: + summary: Unknown + value: http://snomed.info/sct|410970009 nextPageToken: name: next-page-token description: | diff --git a/api/producer/searchDocumentReference/search_document_reference.py b/api/producer/searchDocumentReference/search_document_reference.py index 133d47fd1..d2dad8fd5 100644 --- a/api/producer/searchDocumentReference/search_document_reference.py +++ b/api/producer/searchDocumentReference/search_document_reference.py @@ -6,7 +6,7 @@ from nrlf.core.logger import LogReference, logger from nrlf.core.model import ConnectionMetadata, ProducerRequestParams from nrlf.core.response import Response, SpineErrorResponse -from nrlf.core.validators import validate_type_system +from nrlf.core.validators import validate_category, validate_type_system from nrlf.producer.fhir.r4.model import Bundle, DocumentReference @@ -59,6 +59,16 @@ def handler( expression="type", ) + if not validate_category(params.category): + logger.log( + LogReference.PROSEARCH002b, + type=params.category, + ) # TODO - Should update error message once permissioning by category is implemented + return SpineErrorResponse.INVALID_CODE_SYSTEM( + diagnostics="Invalid query parameter (The provided category is not valid)", + expression="category", + ) + pointer_types = [params.type.root] if params.type else metadata.pointer_types bundle = {"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []} @@ -68,6 +78,7 @@ def handler( custodian_suffix=metadata.ods_code_extension, nhs_number=params.nhs_number, pointer_types=pointer_types, + categories=[params.category.root] if params.category else [], ) for result in repository.search( @@ -75,6 +86,7 @@ def handler( custodian_suffix=metadata.ods_code_extension, nhs_number=params.nhs_number, pointer_types=pointer_types, + categories=[params.category.root] if params.category else [], ): try: document_reference = DocumentReference.model_validate_json(result.document) diff --git a/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py b/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py index e571bedef..7f3b89165 100644 --- a/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py +++ b/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py @@ -3,7 +3,7 @@ from moto import mock_aws from api.producer.searchDocumentReference.search_document_reference import handler -from nrlf.core.constants import PointerTypes +from nrlf.core.constants import Categories, PointerTypes from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository from nrlf.tests.data import load_document_reference from nrlf.tests.dynamodb import mock_repository @@ -203,6 +203,51 @@ def test_search_document_reference_invalid_type(repository: DocumentPointerRepos } +@mock_aws +@mock_repository +def test_search_document_reference_invalid_category( + repository: DocumentPointerRepository, +): + event = create_test_api_gateway_event( + headers=create_headers(), + query_string_parameters={ + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "category": "https://fhir.nhs.uk/CodeSystem/Document-Type|invalid", + }, + ) + + 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": "code-invalid", + "details": { + "coding": [ + { + "code": "INVALID_CODE_SYSTEM", + "display": "Invalid code system", + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + } + ] + }, + "diagnostics": "Invalid query parameter (The provided category is not valid)", + "expression": ["category"], + } + ], + } + + @mock_aws @mock_repository def test_search_document_reference_only_returns_custodian_pointers( @@ -272,6 +317,41 @@ def test_search_document_reference_filters_by_type( } +@mock_aws +@mock_repository +def test_search_document_reference_filters_by_category( + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + event = create_test_api_gateway_event( + headers=create_headers(), + query_string_parameters={ + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "category": Categories.CARE_PLAN.value, + }, + ) + + 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": "searchset", + "total": 1, + "entry": [{"resource": doc_ref.model_dump(exclude_none=True)}], + } + + @mock_aws @mock_repository def test_search_document_reference_filters_by_pointer_types( diff --git a/api/producer/searchPostDocumentReference/search_post_document_reference.py b/api/producer/searchPostDocumentReference/search_post_document_reference.py index 35291de6b..12c1144e1 100644 --- a/api/producer/searchPostDocumentReference/search_post_document_reference.py +++ b/api/producer/searchPostDocumentReference/search_post_document_reference.py @@ -6,7 +6,7 @@ from nrlf.core.logger import LogReference, logger from nrlf.core.model import ConnectionMetadata, ProducerRequestParams from nrlf.core.response import Response, SpineErrorResponse -from nrlf.core.validators import validate_type_system +from nrlf.core.validators import validate_category, validate_type_system from nrlf.producer.fhir.r4.model import Bundle, DocumentReference @@ -53,6 +53,16 @@ def handler( expression="type", ) + if not validate_category(body.category): + logger.log( + LogReference.PROPOSTSEARCH002b, + type=body.category, + ) # TODO - Should update error message once permissioning by category is implemented + return SpineErrorResponse.INVALID_CODE_SYSTEM( + diagnostics="The provided category is not valid", + expression="category", + ) + pointer_types = [body.type.root] if body.type else metadata.pointer_types bundle = {"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []} @@ -62,6 +72,7 @@ def handler( custodian_suffix=metadata.ods_code_extension, nhs_number=body.nhs_number, pointer_types=pointer_types, + categories=[body.category.root] if body.category else [], ) for result in repository.search( @@ -69,6 +80,7 @@ def handler( custodian_suffix=metadata.ods_code_extension, nhs_number=body.nhs_number, pointer_types=pointer_types, + categories=[body.category.root] if body.category else [], ): try: document_reference = DocumentReference.model_validate_json(result.document) diff --git a/api/producer/searchPostDocumentReference/tests/test_search_post_document_reference_producer.py b/api/producer/searchPostDocumentReference/tests/test_search_post_document_reference_producer.py index 9fe1b7db1..66bd579a8 100644 --- a/api/producer/searchPostDocumentReference/tests/test_search_post_document_reference_producer.py +++ b/api/producer/searchPostDocumentReference/tests/test_search_post_document_reference_producer.py @@ -5,7 +5,7 @@ from api.producer.searchPostDocumentReference.search_post_document_reference import ( handler, ) -from nrlf.core.constants import PointerTypes +from nrlf.core.constants import Categories, PointerTypes from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository from nrlf.tests.data import load_document_reference from nrlf.tests.dynamodb import mock_repository @@ -208,6 +208,53 @@ def test_search_document_reference_invalid_type(repository: DocumentPointerRepos } +@mock_aws +@mock_repository +def test_search_document_reference_invalid_category( + repository: DocumentPointerRepository, +): + event = create_test_api_gateway_event( + headers=create_headers(), + body=json.dumps( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "category": "https://fhir.nhs.uk/CodeSystem/Document-Type|invalid", + } + ), + ) + + 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": "code-invalid", + "details": { + "coding": [ + { + "code": "INVALID_CODE_SYSTEM", + "display": "Invalid code system", + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + } + ] + }, + "diagnostics": "The provided category is not valid", + "expression": ["category"], + } + ], + } + + @mock_aws @mock_repository def test_search_document_reference_only_returns_custodian_pointers( @@ -281,6 +328,43 @@ def test_search_document_reference_filters_by_type( } +@mock_aws +@mock_repository +def test_search_document_reference_filters_by_category( + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=json.dumps( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "category": Categories.CARE_PLAN.value, + } + ), + ) + + 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": "searchset", + "total": 1, + "entry": [{"resource": doc_ref.model_dump(exclude_none=True)}], + } + + @mock_aws @mock_repository def test_search_document_reference_filters_by_pointer_types( diff --git a/api/producer/swagger.yaml b/api/producer/swagger.yaml index bea387d33..b3316d199 100644 --- a/api/producer/swagger.yaml +++ b/api/producer/swagger.yaml @@ -396,6 +396,7 @@ paths: parameters: - $ref: "#/components/parameters/subject" - $ref: "#/components/parameters/type" + - $ref: "#/components/parameters/category" - $ref: "#/components/parameters/nextPageToken" - $ref: "#/components/parameters/odsCode" - $ref: "#/components/parameters/odsCodeExtension" @@ -1935,6 +1936,8 @@ components: $ref: "#/components/schemas/RequestQuerySubject" type: $ref: "#/components/schemas/RequestQueryType" + category: + $ref: "#/components/schemas/RequestQueryCategory" next-page-token: $ref: "#/components/schemas/NextPageToken" RequestQuerySubject: @@ -1944,6 +1947,9 @@ components: RequestQueryType: type: string example: "http://snomed.info/sct|736253002" + RequestQueryCategory: + type: string + example: "http://snomed.info/sct|103693007" NextPageToken: type: string RequestHeaderOdsCode: @@ -2038,6 +2044,33 @@ components: invalid: summary: Unknown value: http://snomed.info/sct|410970009 + category: + name: category + in: query + schema: + $ref: "#/components/schemas/RequestQueryCategory" + examples: + none: + summary: None + value: "" + SNOMED_CODES_CARE_PLAN: + summary: Care plan + value: http://snomed.info/sct|734163000 + SNOMED_CODES_OBSERVATIONS: + summary: Observations + value: http://snomed.info/sct|1102421000000108 + SNOMED_CODES_CLINICAL_NOTE: + summary: Clinical note + value: http://snomed.info/sct|823651000000106 + SNOMED_CODES_DIAGNOSTIC_STUDIES_REPORT: + summary: Diagnostic studies report + value: http://snomed.info/sct|721981007 + SNOMED_CODES_DIAGNOSTIC_PROCEDURE: + summary: Diagnostic procedure + value: http://snomed.info/sct|103693007 + invalid: + summary: Unknown + value: http://snomed.info/sct|410970009 nextPageToken: name: next-page-token in: query diff --git a/layer/nrlf/consumer/fhir/r4/model.py b/layer/nrlf/consumer/fhir/r4/model.py index 4af54348d..061754db2 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-04T11:43:16+00:00 +# timestamp: 2024-11-10T19:12:31+00:00 from __future__ import annotations @@ -397,6 +397,10 @@ class RequestQueryType(RootModel[str]): root: Annotated[str, Field(examples=["http://snomed.info/sct|736253002"])] +class RequestQueryCategory(RootModel[str]): + root: Annotated[str, Field(examples=["http://snomed.info/sct|103693007"])] + + class NextPageToken(RootModel[str]): root: str @@ -435,6 +439,7 @@ class RequestParams(BaseModel): Optional[RequestQueryCustodian], Field(alias="custodian:identifier") ] = None type: Optional[RequestQueryType] = None + category: Optional[RequestQueryCategory] = None next_page_token: Annotated[ Optional[NextPageToken], Field(alias="next-page-token") ] = None diff --git a/layer/nrlf/core/dynamodb/repository.py b/layer/nrlf/core/dynamodb/repository.py index 8b1026bbb..a65931ccb 100644 --- a/layer/nrlf/core/dynamodb/repository.py +++ b/layer/nrlf/core/dynamodb/repository.py @@ -218,6 +218,7 @@ def search( custodian: Optional[str] = None, custodian_suffix: Optional[str] = None, pointer_types: Optional[List[str]] = [], + categories: Optional[List[str]] = [], ) -> Iterator[DocumentPointer]: """""" logger.log( @@ -238,9 +239,28 @@ def search( patient_sort = f"C#{category_id}#T#{type_id}" key_conditions.append("begins_with(patient_sort, :patient_sort)") expression_values[":patient_sort"] = patient_sort + else: + # Handle single/multiple categories and pointer types with filter expressions + if len(categories) == 1: + split_category = categories[0].split("|") + category_id = ( + SYSTEM_SHORT_IDS[split_category[0]] + "-" + split_category[1] + ) + patient_sort = f"C#{category_id}" + key_conditions.append("begins_with(patient_sort, :patient_sort)") + expression_values[":patient_sort"] = patient_sort + + if len(categories) > 1: + expression_names["#category"] = "category" + category_filters = [ + f"#category = :category_{i}" for i in range(len(categories)) + ] + category_filter_values = { + f":category_{i}": categories[i] for i in range(len(categories)) + } + filter_expressions.append(f"({' OR '.join(category_filters)})") + expression_values.update(category_filter_values) - # Handle multiple categories and pointer types with filter expressions - if len(pointer_types) > 1: expression_names["#pointer_type"] = "type" types_filters = [ f"#pointer_type = :type_{i}" for i in range(len(pointer_types)) diff --git a/layer/nrlf/core/log_references.py b/layer/nrlf/core/log_references.py index 095a1e704..a140b7164 100644 --- a/layer/nrlf/core/log_references.py +++ b/layer/nrlf/core/log_references.py @@ -175,6 +175,9 @@ class LogReference(Enum): CONSEARCH002 = _Reference( "INFO", "Invalid document type provided in the query parameters" ) + CONSEARCH002b = _Reference( + "INFO", "Invalid document category provided in the query parameters" + ) CONSEARCH003 = _Reference("DEBUG", "Performing search by NHS number") CONSEARCH004 = _Reference( "DEBUG", "Parsed DocumentReference and added to search results" @@ -196,6 +199,9 @@ class LogReference(Enum): CONPOSTSEARCH002 = _Reference( "INFO", "Invalid document type provided in the request body" ) + CONPOSTSEARCH002b = _Reference( + "INFO", "Invalid document category provided in the request body" + ) CONPOSTSEARCH003 = _Reference("DEBUG", "Performing search by NHS number") CONPOSTSEARCH004 = _Reference( "DEBUG", "Parsed DocumentReference and added to search results" @@ -350,12 +356,15 @@ class LogReference(Enum): PROSEARCH002 = _Reference( "INFO", "Invalid document type provided in the query parameters" ) + PROSEARCH002b = _Reference( + "INFO", "Invalid document category provided in the query parameters" + ) PROSEARCH003 = _Reference("DEBUG", "Performing search by custodian") PROSEARCH004 = _Reference( "DEBUG", "Parsed DocumentReference and added to search results" ) PROSEARCH005 = _Reference( - "EXCEPTION", "The DocumentReference esource could not be parsed" + "EXCEPTION", "The DocumentReference resource could not be parsed" ) PROSEARCH999 = _Reference( "INFO", "Successfully completed producer searchDocumentReference" @@ -371,6 +380,9 @@ class LogReference(Enum): PROPOSTSEARCH002 = _Reference( "INFO", "Invalid document type provided in the request body" ) + PROPOSTSEARCH002b = _Reference( + "INFO", "Invalid document category provided in the request body" + ) PROPOSTSEARCH003 = _Reference("DEBUG", "Performing search by custodian") PROPOSTSEARCH004 = _Reference( "DEBUG", "Parsed DocumentReference and added to search results" diff --git a/layer/nrlf/core/model.py b/layer/nrlf/core/model.py index 85c6b4f1f..05b3e5245 100644 --- a/layer/nrlf/core/model.py +++ b/layer/nrlf/core/model.py @@ -22,7 +22,7 @@ def nhs_number(self) -> Union[str, None]: class ProducerRequestParams(producer_model.RequestParams, _NhsNumberMixin): - pass + model_config = {"extra": "forbid"} class ConsumerRequestParams(consumer_model.RequestParams, _NhsNumberMixin): diff --git a/layer/nrlf/core/tests/test_model.py b/layer/nrlf/core/tests/test_model.py index ac254c405..07641989e 100644 --- a/layer/nrlf/core/tests/test_model.py +++ b/layer/nrlf/core/tests/test_model.py @@ -1,3 +1,6 @@ +import pytest +from pydantic import ValidationError + from nrlf.core.model import ( ConnectionMetadata, ConsumerRequestParams, @@ -64,6 +67,7 @@ def test_consumer_request_params(): "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|9999999999", "custodian:identifier": "https://fhir.nhs.uk/Id/ods-organization-code|test", "type": "test-type", + "category": "test-category", "next-page-token": "page-token", } ) @@ -90,6 +94,28 @@ def test_consumer_request_params(): assert params.nhs_number == "9999999999" +def test_producer_request_params_extra_fields(): + with pytest.raises(ValidationError): + ProducerRequestParams.model_validate( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|9999999999", + "type": "test-type", + "extra_field": "extra_value", + } + ) + + +def test_consumer_request_params_extra_fields(): + with pytest.raises(ValidationError): + ConsumerRequestParams.model_validate( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|9999999999", + "type": "test-type", + "extra_field": "extra_value", + } + ) + + def test_count_request_params(): params = CountRequestParams.model_validate( { diff --git a/layer/nrlf/core/validators.py b/layer/nrlf/core/validators.py index 0974e403e..45627ccc7 100644 --- a/layer/nrlf/core/validators.py +++ b/layer/nrlf/core/validators.py @@ -4,8 +4,14 @@ from pydantic import ValidationError +from nrlf.consumer.fhir.r4.model import RequestQueryCategory from nrlf.core.codes import SpineErrorConcept -from nrlf.core.constants import CATEGORY_ATTRIBUTES, ODS_SYSTEM, REQUIRED_CREATE_FIELDS +from nrlf.core.constants import ( + CATEGORY_ATTRIBUTES, + ODS_SYSTEM, + REQUIRED_CREATE_FIELDS, + Categories, +) from nrlf.core.errors import ParseError from nrlf.core.logger import LogReference, logger from nrlf.core.types import DocumentReference, OperationOutcomeIssue, RequestQueryType @@ -29,6 +35,17 @@ def validate_type_system( return type_system in pointer_type_systems +# TODO - Validate category is in set permissions once permissioning by category is done. +def validate_category(category_: Optional[RequestQueryCategory]) -> bool: + """ + Validates if the given category is valid. + """ + if not category_: + return True + + return category_.root in Categories.list() + + @dataclass class ValidationResult: resource: DocumentReference diff --git a/layer/nrlf/producer/fhir/r4/model.py b/layer/nrlf/producer/fhir/r4/model.py index ecb14c05c..d8ad9f87f 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-11-04T11:43:12+00:00 +# timestamp: 2024-11-10T19:12:31+00:00 from __future__ import annotations @@ -387,6 +387,10 @@ class RequestQueryType(RootModel[str]): root: Annotated[str, Field(examples=["http://snomed.info/sct|736253002"])] +class RequestQueryCategory(RootModel[str]): + root: Annotated[str, Field(examples=["http://snomed.info/sct|103693007"])] + + class NextPageToken(RootModel[str]): root: str @@ -422,6 +426,7 @@ class RequestParams(BaseModel): Optional[RequestQuerySubject], Field(alias="subject:identifier") ] = None type: Optional[RequestQueryType] = None + category: Optional[RequestQueryCategory] = None next_page_token: Annotated[ Optional[NextPageToken], Field(alias="next-page-token") ] = None diff --git a/layer/nrlf/producer/fhir/r4/strict_model.py b/layer/nrlf/producer/fhir/r4/strict_model.py index 91bdcfe36..c74b28ab5 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-11-04T11:43:14+00:00 +# timestamp: 2024-11-10T19:12:31+00:00 from __future__ import annotations @@ -342,6 +342,10 @@ class RequestQueryType(RootModel[StrictStr]): root: Annotated[StrictStr, Field(examples=["http://snomed.info/sct|736253002"])] +class RequestQueryCategory(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(examples=["http://snomed.info/sct|103693007"])] + + class NextPageToken(RootModel[StrictStr]): root: StrictStr @@ -371,6 +375,7 @@ class RequestParams(BaseModel): Optional[RequestQuerySubject], Field(alias="subject:identifier") ] = None type: Optional[RequestQueryType] = None + category: Optional[RequestQueryCategory] = None next_page_token: Annotated[ Optional[NextPageToken], Field(alias="next-page-token") ] = None diff --git a/swagger/consumer-static/components.yaml b/swagger/consumer-static/components.yaml index 8201e93fc..be736a02d 100644 --- a/swagger/consumer-static/components.yaml +++ b/swagger/consumer-static/components.yaml @@ -36,6 +36,11 @@ components: in: query schema: $ref: "#/components/schemas/RequestQueryType" + category: + name: category + in: query + schema: + $ref: "#/components/schemas/RequestQueryCategory" nextPageToken: name: next-page-token description: | @@ -131,6 +136,8 @@ components: $ref: "#/components/schemas/RequestQueryCustodian" type: $ref: "#/components/schemas/RequestQueryType" + category: + $ref: "#/components/schemas/RequestQueryCategory" next-page-token: $ref: "#/components/schemas/NextPageToken" required: @@ -151,6 +158,8 @@ components: example: "https://fhir.nhs.uk/Id/ods-organization-code|Y05868" RequestQueryType: type: string + RequestQueryCategory: + type: string NextPageToken: type: string RequestHeaderOdsCode: diff --git a/swagger/consumer-static/narrative.yaml b/swagger/consumer-static/narrative.yaml index 0250bd219..a349758ea 100644 --- a/swagger/consumer-static/narrative.yaml +++ b/swagger/consumer-static/narrative.yaml @@ -624,6 +624,8 @@ components: example: "https://fhir.nhs.uk/Id/ods-organization-code|Y05868" RequestQueryType: example: "http://snomed.info/sct|736253002" + RequestQueryCategory: + example: "http://snomed.info/sct|734163000" parameters: id: diff --git a/swagger/producer-static/components.yaml b/swagger/producer-static/components.yaml index 80e027ecb..22a096326 100644 --- a/swagger/producer-static/components.yaml +++ b/swagger/producer-static/components.yaml @@ -18,6 +18,11 @@ components: in: query schema: $ref: "#/components/schemas/RequestQueryType" + category: + name: type + in: query + schema: + $ref: "#/components/schemas/RequestQueryCategory" nextPageToken: name: next-page-token in: query @@ -181,6 +186,8 @@ components: $ref: "#/components/schemas/RequestQuerySubject" type: $ref: "#/components/schemas/RequestQueryType" + category: + $ref: "#/components/schemas/RequestQueryCategory" next-page-token: $ref: "#/components/schemas/NextPageToken" RequestQuerySubject: @@ -188,6 +195,8 @@ components: pattern: ^https\:\/\/fhir\.nhs\.uk\/Id\/nhs-number\|(\d+)$ RequestQueryType: type: string + RequestQueryCategory: + type: string NextPageToken: type: string RequestHeaderOdsCode: diff --git a/swagger/producer-static/narrative.yaml b/swagger/producer-static/narrative.yaml index 6316c581c..4341faf86 100644 --- a/swagger/producer-static/narrative.yaml +++ b/swagger/producer-static/narrative.yaml @@ -1195,6 +1195,8 @@ components: example: "https://fhir.nhs.uk/Id/nhs-number|4409815415" RequestQueryType: example: "http://snomed.info/sct|736253002" + RequestQueryCategory: + example: "http://snomed.info/sct|734163000" RequestHeaderRequestId: example: 60E0B220-8136-4CA5-AE46-1D97EF59D068 RequestHeaderCorrelationId: diff --git a/tests/features/consumer/searchDocumentReference-failure.feature b/tests/features/consumer/searchDocumentReference-failure.feature index 9865bc226..8ed4300e6 100644 --- a/tests/features/consumer/searchDocumentReference-failure.feature +++ b/tests/features/consumer/searchDocumentReference-failure.feature @@ -191,3 +191,31 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios "diagnostics": "Your organisation 'RX898' does not have permission to access this resource. Contact the onboarding team." } """ + + Scenario: Search rejects request with invalid category system + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'RX898' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + When consumer 'RX898' searches for DocumentReferences with parameters: + | parameter | value | + | subject | 9278693472 | + | category | http://incorrect.info/sct\|736253002 | + Then the response status code is 400 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "code-invalid", + "details": { + "coding": [{ + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "INVALID_CODE_SYSTEM", + "display": "Invalid code system" + }] + }, + "diagnostics": "Invalid query parameter (The provided category is not valid)", + "expression": ["category"] + } + """ diff --git a/tests/features/consumer/searchDocumentReference-success.feature b/tests/features/consumer/searchDocumentReference-success.feature index 287e91d20..960dfb823 100644 --- a/tests/features/consumer/searchDocumentReference-success.feature +++ b/tests/features/consumer/searchDocumentReference-success.feature @@ -271,6 +271,78 @@ Feature: Consumer - searchDocumentReference - Success Scenarios And the Bundle has a total of 0 And the Bundle has 0 entry + Scenario: Search for multiple DocumentReferences by NHS number and Category + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'RX898' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + | http://snomed.info/sct | 1363501000000100 | + And a DocumentReference resource exists with values: + | property | value | + | id | 02V-1111111111-SearchMultipleRefTest1 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-1.pdf | + | custodian | 02V | + | author | 02V | + And a DocumentReference resource exists with values: + | property | value | + | id | 02V-1111111111-SearchMultipleRefTest2 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-2.pdf | + | custodian | 02V | + | author | 02V | + And a DocumentReference resource exists with values: + | property | value | + | id | 02V-1111111111-SearchMultipleRefTest3 | + | subject | 9278693472 | + | status | current | + | type | 1363501000000100 | + | category | 1102421000000108 | + | contentType | application/pdf | + | url | https://example.org/my-doc-3.pdf | + | custodian | 02V | + | author | 02V | + When consumer 'RX898' searches for DocumentReferences with parameters: + | parameter | value | + | subject | 9278693472 | + | category | http://snomed.info/sct\|734163000 | + Then the response status code is 200 + And the response is a searchset Bundle + And the Bundle has a self link matching 'DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9278693472&category=http://snomed.info/sct|734163000' + And the Bundle has a total of 2 + And the Bundle has 2 entries + And the Bundle contains an DocumentReference with values + | property | value | + | id | 02V-1111111111-SearchMultipleRefTest1 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-1.pdf | + | custodian | 02V | + | author | 02V | + And the Bundle contains an DocumentReference with values + | property | value | + | id | 02V-1111111111-SearchMultipleRefTest2 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-2.pdf | + | custodian | 02V | + | author | 02V | + And the Bundle does not contain a DocumentReference with ID '02V-1111111111-SearchMultipleRefTest3' + # No pointers found - done # Pointers exist but no permissions - covered in failure scenarios # Search by custodian - done diff --git a/tests/features/consumer/searchPostDocumentReference-failure.feature b/tests/features/consumer/searchPostDocumentReference-failure.feature index e4754e67c..8f5b507bd 100644 --- a/tests/features/consumer/searchPostDocumentReference-failure.feature +++ b/tests/features/consumer/searchPostDocumentReference-failure.feature @@ -191,3 +191,32 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios "diagnostics": "Your organisation 'RX898' does not have permission to access this resource. Contact the onboarding team." } """ + + Scenario: Search rejects request with category system they are not allowed to use + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'RX898' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + | http://snomed.info/sct | 1363501000000100 | + When consumer 'RX898' searches for DocumentReferences using POST with request body: + | key | value | + | subject | 9278693472 | + | category | http://incorrect.info/sct\|736253002 | + Then the response status code is 400 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "code-invalid", + "details": { + "coding": [{ + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "INVALID_CODE_SYSTEM", + "display": "Invalid code system" + }] + }, + "diagnostics": "The provided category is not valid", + "expression": ["category"] + } + """ diff --git a/tests/features/consumer/searchPostDocumentReference-success.feature b/tests/features/consumer/searchPostDocumentReference-success.feature index 529f27df5..27d3fff18 100644 --- a/tests/features/consumer/searchPostDocumentReference-success.feature +++ b/tests/features/consumer/searchPostDocumentReference-success.feature @@ -242,3 +242,74 @@ Feature: Consumer - searchDocumentReference - Success Scenarios And the Bundle has a self link matching 'DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9278693472&custodian:identifier=https://fhir.nhs.uk/Id/ods-organization-code|RX898' And the Bundle has a total of 0 And the Bundle has 0 entry + + Scenario: Search for multiple DocumentReferences by NHS number and Category + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'RX898' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + | http://snomed.info/sct | 1363501000000100 | + And a DocumentReference resource exists with values: + | property | value | + | id | X26-1111111111-SearchMultipleRefTest1 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-1.pdf | + | custodian | X26 | + | author | X26 | + And a DocumentReference resource exists with values: + | property | value | + | id | X26-1111111111-SearchMultipleRefTest2 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-2.pdf | + | custodian | X26 | + | author | X26 | + And a DocumentReference resource exists with values: + | property | value | + | id | x26-1111111111-SearchMultipleRefTest3 | + | subject | 9278693472 | + | status | current | + | type | 1363501000000100 | + | category | 1102421000000108 | + | contentType | application/pdf | + | url | https://example.org/my-doc-3.pdf | + | custodian | x26 | + | author | x26 | + When consumer 'RX898' searches for DocumentReferences using POST with request body: + | key | value | + | subject | 9278693472 | + | category | http://snomed.info/sct\|734163000 | + Then the response status code is 200 + And the response is a searchset Bundle + And the Bundle has a total of 2 + And the Bundle has 2 entries + And the Bundle contains an DocumentReference with values + | property | value | + | id | X26-1111111111-SearchMultipleRefTest1 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-1.pdf | + | custodian | X26 | + | author | X26 | + And the Bundle contains an DocumentReference with values + | property | value | + | id | X26-1111111111-SearchMultipleRefTest2 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-2.pdf | + | custodian | X26 | + | author | X26 | + And the Bundle does not contain a DocumentReference with ID 'x26-1111111111-SearchMultipleRefTest3' diff --git a/tests/performance/constants.js b/tests/performance/constants.js index 3cd571e76..668cc9fb3 100644 --- a/tests/performance/constants.js +++ b/tests/performance/constants.js @@ -25,3 +25,10 @@ export const POINTER_TYPES = [ "824321000000109", "2181441000000107", ]; +export const CATEGORIES = [ + "734163000", + "1102421000000108", + "823651000000106", + "721981007", + "103693007", +]; diff --git a/tests/performance/consumer/baseline.js b/tests/performance/consumer/baseline.js index cc05b0204..ffa06c58a 100644 --- a/tests/performance/consumer/baseline.js +++ b/tests/performance/consumer/baseline.js @@ -41,6 +41,17 @@ export const options = { { target: 5, duration: "1m" }, ], }, + searchDocumentReferenceByCategory: { + exec: "searchDocumentReferenceByCategory", + executor: "ramping-arrival-rate", + startRate: 1, + timeUnit: "1s", + preAllocatedVUs: 5, + stages: [ + { target: 5, duration: "30s" }, + { target: 5, duration: "1m" }, + ], + }, searchPostDocumentReference: { exec: "searchPostDocumentReference", executor: "ramping-arrival-rate", @@ -52,5 +63,16 @@ export const options = { { target: 5, duration: "1m" }, ], }, + searchPostDocumentReferenceByCategory: { + exec: "searchPostDocumentReferenceByCategory", + executor: "ramping-arrival-rate", + startRate: 1, + timeUnit: "1s", + preAllocatedVUs: 5, + stages: [ + { target: 5, duration: "30s" }, + { target: 5, duration: "1m" }, + ], + }, }, }; diff --git a/tests/performance/consumer/client.js b/tests/performance/consumer/client.js index 64e22d799..7f5ac6031 100644 --- a/tests/performance/consumer/client.js +++ b/tests/performance/consumer/client.js @@ -3,6 +3,7 @@ import { POINTER_IDS, POINTER_TYPES, ODS_CODE, + CATEGORIES, } from "../constants.js"; import http from "k6/http"; import { check } from "k6"; @@ -82,6 +83,27 @@ export function searchDocumentReference() { checkResponse(res); } +export function searchDocumentReferenceByCategory() { + const nhsNumber = NHS_NUMBERS[Math.floor(Math.random() * NHS_NUMBERS.length)]; + const randomCategory = + CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)]; + + const identifier = encodeURIComponent( + `https://fhir.nhs.uk/Id/nhs-number|${nhsNumber}` + ); + const category = encodeURIComponent( + `http://snomed.info/sct|${randomCategory}` + ); + + const res = http.get( + `https://${__ENV.HOST}/consumer/DocumentReference?subject:identifier=${identifier}&category=${category}`, + { + headers: getHeaders(), + } + ); + checkResponse(res); +} + export function searchPostDocumentReference() { const nhsNumber = NHS_NUMBERS[Math.floor(Math.random() * NHS_NUMBERS.length)]; const pointer_type = @@ -101,3 +123,22 @@ export function searchPostDocumentReference() { ); checkResponse(res); } + +export function searchPostDocumentReferenceByCategory() { + const nhsNumber = NHS_NUMBERS[Math.floor(Math.random() * NHS_NUMBERS.length)]; + const category = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)]; + + const body = JSON.stringify({ + "subject:identifier": `https://fhir.nhs.uk/Id/nhs-number|${nhsNumber}`, + category: `http://snomed.info/sct|${category}`, + }); + + const res = http.post( + `https://${__ENV.HOST}/consumer/DocumentReference/_search`, + body, + { + headers: getHeaders(), + } + ); + checkResponse(res); +} diff --git a/tests/performance/consumer/soak.js b/tests/performance/consumer/soak.js index fad1af82d..34495d153 100644 --- a/tests/performance/consumer/soak.js +++ b/tests/performance/consumer/soak.js @@ -44,6 +44,18 @@ export const options = { { target: 0, duration: "1m" }, ], }, + searchDocumentReferenceByCategory: { + exec: "searchDocumentReferenceByCategory", + executor: "ramping-arrival-rate", + startRate: 0, + timeUnit: "1s", + preAllocatedVUs: 5, + stages: [ + { target: 10, duration: "5m" }, + { target: 10, duration: "30m" }, + { target: 0, duration: "1m" }, + ], + }, searchPostDocumentReference: { exec: "searchPostDocumentReference", executor: "ramping-arrival-rate", @@ -56,5 +68,17 @@ export const options = { { target: 0, duration: "1m" }, ], }, + searchPostDocumentReferenceByCategory: { + exec: "searchPostDocumentReferenceByCategory", + executor: "ramping-arrival-rate", + startRate: 0, + timeUnit: "1s", + preAllocatedVUs: 5, + stages: [ + { target: 10, duration: "5m" }, + { target: 10, duration: "30m" }, + { target: 0, duration: "1m" }, + ], + }, }, }; diff --git a/tests/performance/consumer/stress.js b/tests/performance/consumer/stress.js index 5df6e69e4..1ee5a7df0 100644 --- a/tests/performance/consumer/stress.js +++ b/tests/performance/consumer/stress.js @@ -35,6 +35,15 @@ export const options = { { target: 10, duration: "1m" }, ], }, + searchDocumentReferenceByCategory: { + exec: "searchDocumentReferenceByCategory", + executor: "ramping-vus", + startVUs: 1, + stages: [ + { target: 10, duration: "30s" }, + { target: 10, duration: "1m" }, + ], + }, searchPostDocumentReference: { exec: "searchPostDocumentReference", executor: "ramping-vus", @@ -44,5 +53,14 @@ export const options = { { target: 10, duration: "1m" }, ], }, + searchPostDocumentReferenceByCategory: { + exec: "searchPostDocumentReferenceByCategory", + executor: "ramping-vus", + startVUs: 1, + stages: [ + { target: 10, duration: "30s" }, + { target: 10, duration: "1m" }, + ], + }, }, }; diff --git a/tests/performance/process_results.py b/tests/performance/process_results.py index e8a1a8d08..440f65da6 100644 --- a/tests/performance/process_results.py +++ b/tests/performance/process_results.py @@ -80,7 +80,7 @@ def _create_response_count_figure(data: dict, title: str): fig_labels.add(f"Failure - Status Code {failure.status}") for index, scenario in enumerate(data.keys()): - axes = fig.add_subplot(2, 2, index + 1) + axes = fig.add_subplot(3, 3, index + 1) scenario_data = data[scenario] timestamps = [] @@ -149,7 +149,7 @@ def _create_response_time_figure(data: dict, title: str): fig.suptitle(title) for index, scenario in enumerate(data.keys()): - axes = fig.add_subplot(2, 2, index + 1) + axes = fig.add_subplot(3, 3, index + 1) scenario_data = data[scenario] timestamps = [] diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index 3a3225135..1d06bf2a8 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -3,7 +3,7 @@ import requests from pydantic import BaseModel -from nrlf.core.constants import PointerTypes +from nrlf.core.constants import Categories, PointerTypes from nrlf.core.model import ConnectionMetadata @@ -80,6 +80,7 @@ def search( nhs_number: str | None = None, custodian: str | None = None, pointer_type: PointerTypes | None = None, + category: Categories | None = None, extra_params: dict[str, str] | None = None, ): params = {**(extra_params or {})} @@ -100,6 +101,12 @@ def search( else: params["type"] = f"http://snomed.info/sct|{pointer_type}" + if category: + if "|" in category: + params["category"] = category + else: + params["category"] = f"http://snomed.info/sct|{category}" + return requests.get( f"{self.api_url}/DocumentReference", params=params, @@ -112,6 +119,7 @@ def search_post( nhs_number: str | None = None, custodian: str | None = None, pointer_type: PointerTypes | None = None, + category: Categories | None = None, extra_fields: dict[str, str] | None = None, ): body = {**(extra_fields or {})} @@ -132,6 +140,12 @@ def search_post( else: body["type"] = f"http://snomed.info/sct|{pointer_type}" + if category: + if "|" in category: + body["category"] = category + else: + body["category"] = f"http://snomed.info/sct|{category}" + return requests.post( f"{self.api_url}/DocumentReference/_search", json=body,