From dffe0f42ca37daf6e4867b931c5ec1a83b3be63a Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:58:20 +0000 Subject: [PATCH 01/12] [CDAPI-118]: Add x-correlation-id --- pathology-api/lambda_handler.py | 9 +++ pathology-api/src/pathology_api/logging.py | 15 +++- .../src/pathology_api/request_context.py | 13 +++ .../src/pathology_api/test_logging.py | 54 +++++++++++++ .../src/pathology_api/test_request_context.py | 11 +++ pathology-api/test_lambda_handler.py | 81 ++++++++++++++++++- 6 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 pathology-api/src/pathology_api/request_context.py create mode 100644 pathology-api/src/pathology_api/test_logging.py create mode 100644 pathology-api/src/pathology_api/test_request_context.py diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index a769da89..6b82a9cb 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -13,6 +13,7 @@ from pathology_api.fhir.r4.resources import Bundle, OperationOutcome from pathology_api.handler import handle_request from pathology_api.logging import get_logger +from pathology_api.request_context import set_correlation_id _logger = get_logger(__name__) @@ -102,8 +103,16 @@ def status() -> Response[str]: return Response(status_code=200, body="OK", headers={"Content-Type": "text/plain"}) +_CORRELATION_ID_HEADER = "nhsd-correlation-id" + + @app.post("/FHIR/R4/Bundle") def post_result() -> Response[str]: + correlation_id = app.current_event.headers.get(_CORRELATION_ID_HEADER) + if not correlation_id: + raise ValidationError(f"Missing required header: {_CORRELATION_ID_HEADER}") + set_correlation_id(correlation_id) + _logger.debug("Post result endpoint called.") try: diff --git a/pathology-api/src/pathology_api/logging.py b/pathology-api/src/pathology_api/logging.py index d094698c..fc59087e 100644 --- a/pathology-api/src/pathology_api/logging.py +++ b/pathology-api/src/pathology_api/logging.py @@ -1,7 +1,18 @@ +import logging from typing import Any, Protocol from aws_lambda_powertools import Logger +from pathology_api.request_context import get_correlation_id + + +class _CorrelationIdFilter(logging.Filter): + """Injects the current correlation ID into every log record.""" + + def filter(self, record: logging.LogRecord) -> bool: + record.correlation_id = get_correlation_id() + return True + class LogProvider(Protocol): """Protocol defining required contract for a logger.""" @@ -19,4 +30,6 @@ def exception(self, msg: str, *args: Any, **kwargs: Any) -> None: ... def get_logger(service: str) -> LogProvider: """Get a configured logger instance.""" - return Logger(service=service, level="DEBUG", serialize_stacktrace=True) + logger = Logger(service=service, level="DEBUG", serialize_stacktrace=True) + logger.addFilter(_CorrelationIdFilter()) + return logger diff --git a/pathology-api/src/pathology_api/request_context.py b/pathology-api/src/pathology_api/request_context.py new file mode 100644 index 00000000..10422a3b --- /dev/null +++ b/pathology-api/src/pathology_api/request_context.py @@ -0,0 +1,13 @@ +from contextvars import ContextVar + +_correlation_id: ContextVar[str] = ContextVar("correlation_id", default="") + + +def set_correlation_id(value: str) -> None: + """Set the correlation ID for the current request context.""" + _correlation_id.set(value) + + +def get_correlation_id() -> str: + """Get the correlation ID for the current request context.""" + return _correlation_id.get() diff --git a/pathology-api/src/pathology_api/test_logging.py b/pathology-api/src/pathology_api/test_logging.py new file mode 100644 index 00000000..98b10714 --- /dev/null +++ b/pathology-api/src/pathology_api/test_logging.py @@ -0,0 +1,54 @@ +import logging + +from pathology_api.logging import ( + _CorrelationIdFilter, + get_logger, +) +from pathology_api.request_context import set_correlation_id + + +class TestCorrelationIdFilter: + def test_filter_injects_correlation_id_into_log_record(self) -> None: + set_correlation_id("test-abc-123") + + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="test message", + args=None, + exc_info=None, + ) + + f = _CorrelationIdFilter() + result = f.filter(record) + + assert result is True + assert record.correlation_id == "test-abc-123" # type: ignore[attr-defined] + + def test_filter_uses_empty_default_when_no_correlation_id_set(self) -> None: + set_correlation_id("") + + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="test message", + args=None, + exc_info=None, + ) + + f = _CorrelationIdFilter() + f.filter(record) + + assert record.correlation_id == "" # type: ignore[attr-defined] + + +class TestGetLogger: + def test_get_logger_attaches_correlation_id_filter(self) -> None: + logger = get_logger("test-service") + + filters = getattr(logger, "filters", []) + assert any(isinstance(f, _CorrelationIdFilter) for f in filters) diff --git a/pathology-api/src/pathology_api/test_request_context.py b/pathology-api/src/pathology_api/test_request_context.py new file mode 100644 index 00000000..9ccaf380 --- /dev/null +++ b/pathology-api/src/pathology_api/test_request_context.py @@ -0,0 +1,11 @@ +from pathology_api.request_context import get_correlation_id, set_correlation_id + + +class TestSetAndGetCorrelationId: + def test_set_and_get_correlation_id(self) -> None: + set_correlation_id("round-trip-test-123") + assert get_correlation_id() == "round-trip-test-123" + + def test_default_correlation_id_is_empty(self) -> None: + set_correlation_id("") + assert get_correlation_id() == "" diff --git a/pathology-api/test_lambda_handler.py b/pathology-api/test_lambda_handler.py index 7f867aea..ed8cd020 100644 --- a/pathology-api/test_lambda_handler.py +++ b/pathology-api/test_lambda_handler.py @@ -8,6 +8,7 @@ from pathology_api.exception import ValidationError from pathology_api.fhir.r4.elements import LogicalReference, PatientIdentifier from pathology_api.fhir.r4.resources import Bundle, Composition, OperationOutcome +from pathology_api.request_context import get_correlation_id class TestHandler: @@ -16,9 +17,11 @@ def _create_test_event( body: str | None = None, path_params: str | None = None, request_method: str | None = None, + headers: dict[str, str] | None = None, ) -> dict[str, Any]: return { "body": body, + "headers": headers or {}, "requestContext": { "http": { "path": f"/{path_params}", @@ -58,6 +61,7 @@ def test_create_test_result_success(self) -> None: body=bundle.model_dump_json(by_alias=True), path_params="FHIR/R4/Bundle", request_method="POST", + headers={"nhsd-correlation-id": "test-correlation-id"}, ) context = LambdaContext() @@ -76,9 +80,72 @@ def test_create_test_result_success(self) -> None: # A UUID value so can only check its presence. assert response_bundle.id is not None + def test_correlation_id_is_set_from_request_header(self) -> None: + correlation_id = "test-correlation-id-abc-123" + bundle = Bundle.create( + type="document", + entry=[ + Bundle.Entry( + fullUrl="composition", + resource=Composition.create( + subject=LogicalReference( + PatientIdentifier.from_nhs_number("nhs_number") + ) + ), + ) + ], + ) + event = self._create_test_event( + body=bundle.model_dump_json(by_alias=True), + path_params="FHIR/R4/Bundle", + request_method="POST", + headers={"nhsd-correlation-id": correlation_id}, + ) + context = LambdaContext() + + handler(event, context) + + assert get_correlation_id() == correlation_id + + def test_missing_correlation_id_header_returns_400(self) -> None: + bundle = Bundle.create( + type="document", + entry=[ + Bundle.Entry( + fullUrl="composition", + resource=Composition.create( + subject=LogicalReference( + PatientIdentifier.from_nhs_number("nhs_number") + ) + ), + ) + ], + ) + event = self._create_test_event( + body=bundle.model_dump_json(by_alias=True), + path_params="FHIR/R4/Bundle", + request_method="POST", + ) + context = LambdaContext() + + response = handler(event, context) + + assert response["statusCode"] == 400 + assert response["headers"] == {"Content-Type": "application/fhir+json"} + + returned_issue = self._parse_returned_issue(response["body"]) + assert returned_issue["severity"] == "error" + assert returned_issue["code"] == "invalid" + assert ( + returned_issue["diagnostics"] + == "Missing required header: nhsd-correlation-id" + ) + def test_create_test_result_no_payload(self) -> None: event = self._create_test_event( - path_params="FHIR/R4/Bundle", request_method="POST" + path_params="FHIR/R4/Bundle", + request_method="POST", + headers={"nhsd-correlation-id": "test-correlation-id"}, ) context = LambdaContext() @@ -98,7 +165,10 @@ def test_create_test_result_no_payload(self) -> None: def test_create_test_result_empty_payload(self) -> None: event = self._create_test_event( - body="{}", path_params="FHIR/R4/Bundle", request_method="POST" + body="{}", + path_params="FHIR/R4/Bundle", + request_method="POST", + headers={"nhsd-correlation-id": "test-correlation-id"}, ) context = LambdaContext() @@ -118,7 +188,10 @@ def test_create_test_result_empty_payload(self) -> None: def test_create_test_result_invalid_json(self) -> None: event = self._create_test_event( - body="invalid json", path_params="FHIR/R4/Bundle", request_method="POST" + body="invalid json", + path_params="FHIR/R4/Bundle", + request_method="POST", + headers={"nhsd-correlation-id": "test-correlation-id"}, ) context = LambdaContext() @@ -169,6 +242,7 @@ def test_create_test_result_processing_error( body=bundle.model_dump_json(by_alias=True), path_params="FHIR/R4/Bundle", request_method="POST", + headers={"nhsd-correlation-id": "test-correlation-id"}, ) context = LambdaContext() @@ -207,6 +281,7 @@ def test_create_test_result_model_validate_error( body=bundle.model_dump_json(by_alias=True), path_params="FHIR/R4/Bundle", request_method="POST", + headers={"nhsd-correlation-id": "test-correlation-id"}, ) context = LambdaContext() From 9f94a376e7d599de4ca5e44f6e3e4fa54bbe1bb9 Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:33:23 +0000 Subject: [PATCH 02/12] [CDAPI-118]: Remove Log filter --- pathology-api/lambda_handler.py | 11 +++++------ pathology-api/src/pathology_api/logging.py | 11 +---------- pathology-api/src/pathology_api/request_context.py | 9 ++++++++- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index 6b82a9cb..7106d0a7 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -108,11 +108,6 @@ def status() -> Response[str]: @app.post("/FHIR/R4/Bundle") def post_result() -> Response[str]: - correlation_id = app.current_event.headers.get(_CORRELATION_ID_HEADER) - if not correlation_id: - raise ValidationError(f"Missing required header: {_CORRELATION_ID_HEADER}") - set_correlation_id(correlation_id) - _logger.debug("Post result endpoint called.") try: @@ -138,4 +133,8 @@ def post_result() -> Response[str]: def handler(data: dict[str, Any], context: LambdaContext) -> dict[str, Any]: - return app.resolve(data, context) + correlation_id = app.current_event.headers.get(_CORRELATION_ID_HEADER) + if not correlation_id: + raise ValueError(f"Missing required header: {_CORRELATION_ID_HEADER}") + with set_correlation_id(correlation_id): + return app.resolve(data, context) diff --git a/pathology-api/src/pathology_api/logging.py b/pathology-api/src/pathology_api/logging.py index fc59087e..dc4466b3 100644 --- a/pathology-api/src/pathology_api/logging.py +++ b/pathology-api/src/pathology_api/logging.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Protocol from aws_lambda_powertools import Logger @@ -6,14 +5,6 @@ from pathology_api.request_context import get_correlation_id -class _CorrelationIdFilter(logging.Filter): - """Injects the current correlation ID into every log record.""" - - def filter(self, record: logging.LogRecord) -> bool: - record.correlation_id = get_correlation_id() - return True - - class LogProvider(Protocol): """Protocol defining required contract for a logger.""" @@ -31,5 +22,5 @@ def exception(self, msg: str, *args: Any, **kwargs: Any) -> None: ... def get_logger(service: str) -> LogProvider: """Get a configured logger instance.""" logger = Logger(service=service, level="DEBUG", serialize_stacktrace=True) - logger.addFilter(_CorrelationIdFilter()) + logger.set_correlation_id(get_correlation_id()) return logger diff --git a/pathology-api/src/pathology_api/request_context.py b/pathology-api/src/pathology_api/request_context.py index 10422a3b..df177fea 100644 --- a/pathology-api/src/pathology_api/request_context.py +++ b/pathology-api/src/pathology_api/request_context.py @@ -1,11 +1,18 @@ +from collections.abc import Generator +from contextlib import contextmanager from contextvars import ContextVar _correlation_id: ContextVar[str] = ContextVar("correlation_id", default="") -def set_correlation_id(value: str) -> None: +@contextmanager +def set_correlation_id(value: str) -> Generator[None, None, None]: """Set the correlation ID for the current request context.""" _correlation_id.set(value) + try: + yield None + finally: + _correlation_id.set("") def get_correlation_id() -> str: From 5d474e4d64fa0a69c3955e99b084e50130ad2260 Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:55:32 +0000 Subject: [PATCH 03/12] [CDAPI-118]: Refactor --- .../api-gateway-mock/resources/server.py | 1 + pathology-api/lambda_handler.py | 18 +++++- .../src/pathology_api/test_logging.py | 54 ----------------- .../src/pathology_api/test_request_context.py | 12 ++-- pathology-api/test_lambda_handler.py | 58 ++++++++----------- pathology-api/tests/conftest.py | 53 ++++++++++++++--- .../tests/integration/test_endpoints.py | 5 ++ .../tests/schema/test_openapi_schema.py | 6 ++ 8 files changed, 103 insertions(+), 104 deletions(-) delete mode 100644 pathology-api/src/pathology_api/test_logging.py diff --git a/infrastructure/images/api-gateway-mock/resources/server.py b/infrastructure/images/api-gateway-mock/resources/server.py index 6208cad9..6cb09f4b 100644 --- a/infrastructure/images/api-gateway-mock/resources/server.py +++ b/infrastructure/images/api-gateway-mock/resources/server.py @@ -42,6 +42,7 @@ def forward_request(path_params): "/functions/function/invocations", json={ "body": request.get_data(as_text=True).replace("\n", "").replace(" ", ""), + "headers": {k.lower(): v for k, v in request.headers.items()}, "requestContext": { "http": { "path": f"/{path_params}", diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index 7106d0a7..4d87762f 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -133,8 +133,20 @@ def post_result() -> Response[str]: def handler(data: dict[str, Any], context: LambdaContext) -> dict[str, Any]: - correlation_id = app.current_event.headers.get(_CORRELATION_ID_HEADER) + headers = data.get("headers", {}) or {} + correlation_id = headers.get(_CORRELATION_ID_HEADER) if not correlation_id: - raise ValueError(f"Missing required header: {_CORRELATION_ID_HEADER}") - with set_correlation_id(correlation_id): + raw_path = (data.get("rawPath") or "").lstrip("/") + if raw_path != "_status": + return { + "statusCode": 400, + "headers": {"Content-Type": "application/fhir+json"}, + "body": OperationOutcome.create_validation_error( + f"Missing required header: {_CORRELATION_ID_HEADER}" + ).model_dump_json(by_alias=True, exclude_none=True), + } return app.resolve(data, context) + with set_correlation_id(correlation_id): + response = app.resolve(data, context) + response.setdefault("headers", {})[_CORRELATION_ID_HEADER] = correlation_id + return response diff --git a/pathology-api/src/pathology_api/test_logging.py b/pathology-api/src/pathology_api/test_logging.py deleted file mode 100644 index 98b10714..00000000 --- a/pathology-api/src/pathology_api/test_logging.py +++ /dev/null @@ -1,54 +0,0 @@ -import logging - -from pathology_api.logging import ( - _CorrelationIdFilter, - get_logger, -) -from pathology_api.request_context import set_correlation_id - - -class TestCorrelationIdFilter: - def test_filter_injects_correlation_id_into_log_record(self) -> None: - set_correlation_id("test-abc-123") - - record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname="test.py", - lineno=1, - msg="test message", - args=None, - exc_info=None, - ) - - f = _CorrelationIdFilter() - result = f.filter(record) - - assert result is True - assert record.correlation_id == "test-abc-123" # type: ignore[attr-defined] - - def test_filter_uses_empty_default_when_no_correlation_id_set(self) -> None: - set_correlation_id("") - - record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname="test.py", - lineno=1, - msg="test message", - args=None, - exc_info=None, - ) - - f = _CorrelationIdFilter() - f.filter(record) - - assert record.correlation_id == "" # type: ignore[attr-defined] - - -class TestGetLogger: - def test_get_logger_attaches_correlation_id_filter(self) -> None: - logger = get_logger("test-service") - - filters = getattr(logger, "filters", []) - assert any(isinstance(f, _CorrelationIdFilter) for f in filters) diff --git a/pathology-api/src/pathology_api/test_request_context.py b/pathology-api/src/pathology_api/test_request_context.py index 9ccaf380..ca5ef3bc 100644 --- a/pathology-api/src/pathology_api/test_request_context.py +++ b/pathology-api/src/pathology_api/test_request_context.py @@ -2,10 +2,12 @@ class TestSetAndGetCorrelationId: - def test_set_and_get_correlation_id(self) -> None: - set_correlation_id("round-trip-test-123") - assert get_correlation_id() == "round-trip-test-123" + def test_set_and_get_correlation_id_within_context(self) -> None: + with set_correlation_id("round-trip-test-123"): + assert get_correlation_id() == "round-trip-test-123" + + def test_correlation_id_is_cleared_after_context_exit(self) -> None: + with set_correlation_id("round-trip-test-123"): + assert get_correlation_id() == "round-trip-test-123" - def test_default_correlation_id_is_empty(self) -> None: - set_correlation_id("") assert get_correlation_id() == "" diff --git a/pathology-api/test_lambda_handler.py b/pathology-api/test_lambda_handler.py index ed8cd020..412d41c3 100644 --- a/pathology-api/test_lambda_handler.py +++ b/pathology-api/test_lambda_handler.py @@ -8,7 +8,6 @@ from pathology_api.exception import ValidationError from pathology_api.fhir.r4.elements import LogicalReference, PatientIdentifier from pathology_api.fhir.r4.resources import Bundle, Composition, OperationOutcome -from pathology_api.request_context import get_correlation_id class TestHandler: @@ -68,7 +67,10 @@ def test_create_test_result_success(self) -> None: response = handler(event, context) assert response["statusCode"] == 200 - assert response["headers"] == {"Content-Type": "application/fhir+json"} + assert response["headers"] == { + "Content-Type": "application/fhir+json", + "nhsd-correlation-id": "test-correlation-id", + } response_body = response["body"] assert isinstance(response_body, str) @@ -80,33 +82,6 @@ def test_create_test_result_success(self) -> None: # A UUID value so can only check its presence. assert response_bundle.id is not None - def test_correlation_id_is_set_from_request_header(self) -> None: - correlation_id = "test-correlation-id-abc-123" - bundle = Bundle.create( - type="document", - entry=[ - Bundle.Entry( - fullUrl="composition", - resource=Composition.create( - subject=LogicalReference( - PatientIdentifier.from_nhs_number("nhs_number") - ) - ), - ) - ], - ) - event = self._create_test_event( - body=bundle.model_dump_json(by_alias=True), - path_params="FHIR/R4/Bundle", - request_method="POST", - headers={"nhsd-correlation-id": correlation_id}, - ) - context = LambdaContext() - - handler(event, context) - - assert get_correlation_id() == correlation_id - def test_missing_correlation_id_header_returns_400(self) -> None: bundle = Bundle.create( type="document", @@ -152,7 +127,10 @@ def test_create_test_result_no_payload(self) -> None: response = handler(event, context) assert response["statusCode"] == 400 - assert response["headers"] == {"Content-Type": "application/fhir+json"} + assert response["headers"] == { + "Content-Type": "application/fhir+json", + "nhsd-correlation-id": "test-correlation-id", + } returned_issue = self._parse_returned_issue(response["body"]) @@ -175,7 +153,10 @@ def test_create_test_result_empty_payload(self) -> None: response = handler(event, context) assert response["statusCode"] == 400 - assert response["headers"] == {"Content-Type": "application/fhir+json"} + assert response["headers"] == { + "Content-Type": "application/fhir+json", + "nhsd-correlation-id": "test-correlation-id", + } returned_issue = self._parse_returned_issue(response["body"]) @@ -198,7 +179,10 @@ def test_create_test_result_invalid_json(self) -> None: response = handler(event, context) assert response["statusCode"] == 400 - assert response["headers"] == {"Content-Type": "application/fhir+json"} + assert response["headers"] == { + "Content-Type": "application/fhir+json", + "nhsd-correlation-id": "test-correlation-id", + } returned_issue = self._parse_returned_issue(response["body"]) assert returned_issue["severity"] == "error" @@ -250,7 +234,10 @@ def test_create_test_result_processing_error( response = handler(event, context) assert response["statusCode"] == expected_status_code - assert response["headers"] == {"Content-Type": "application/fhir+json"} + assert response["headers"] == { + "Content-Type": "application/fhir+json", + "nhsd-correlation-id": "test-correlation-id", + } returned_issue = self._parse_returned_issue(response["body"]) assert returned_issue == expected_issue @@ -292,7 +279,10 @@ def test_create_test_result_model_validate_error( response = handler(event, context) assert response["statusCode"] == 400 - assert response["headers"] == {"Content-Type": "application/fhir+json"} + assert response["headers"] == { + "Content-Type": "application/fhir+json", + "nhsd-correlation-id": "test-correlation-id", + } returned_issue = self._parse_returned_issue(response["body"]) assert returned_issue["severity"] == "error" diff --git a/pathology-api/tests/conftest.py b/pathology-api/tests/conftest.py index 191c21d6..e62cf485 100644 --- a/pathology-api/tests/conftest.py +++ b/pathology-api/tests/conftest.py @@ -17,7 +17,11 @@ class Client(Protocol): """Protocol defining the interface for HTTP clients.""" def send( - self, data: str, path: str, request_method: _RequestMethod + self, + data: str, + path: str, + request_method: _RequestMethod, + headers: dict[str, str] | None = None, ) -> requests.Response: """ Send a request to the APIs with some given parameters. @@ -31,7 +35,10 @@ def send( ... def send_without_payload( - self, path: str, request_method: _RequestMethod + self, + path: str, + request_method: _RequestMethod, + headers: dict[str, str] | None = None, ) -> requests.Response: """ Send a request to the APIs without a payload. @@ -47,24 +54,47 @@ def send_without_payload( class LocalClient: """HTTP client that sends requests to the Lambda via the RIE (no auth headers).""" - def __init__(self, lambda_url: str, timeout: timedelta = timedelta(seconds=1)): + def __init__( + self, + lambda_url: str, + headers: dict[str, str] | None = None, + timeout: timedelta = timedelta(seconds=1), + ): self._lambda_url = lambda_url + self._default_headers = {"Content-Type": "application/fhir+json"} | ( + headers or {} + ) self._timeout = timeout.total_seconds() def send( - self, data: str, path: str, request_method: _RequestMethod + self, + data: str, + path: str, + request_method: _RequestMethod, + headers: dict[str, str] | None = None, ) -> requests.Response: return self._send( - data=data, path=path, include_payload=True, request_method=request_method + data=data, + path=path, + include_payload=True, + request_method=request_method, + headers=headers, ) def send_without_payload( - self, path: str, request_method: _RequestMethod + self, + path: str, + request_method: _RequestMethod, + headers: dict[str, str] | None = None, ) -> requests.Response: return self._send( - data=None, path=path, include_payload=False, request_method=request_method + data=None, + path=path, + include_payload=False, + request_method=request_method, + headers=headers, ) def _send( @@ -73,20 +103,24 @@ def _send( path: str, include_payload: bool, request_method: _RequestMethod, + headers: dict[str, str] | None = None, ) -> requests.Response: url = f"{self._lambda_url}/{path}" + merged_headers = self._default_headers | (headers or {}) match request_method: case "POST": return requests.post( url, data=data if include_payload else None, timeout=self._timeout, + headers=merged_headers, ) case "GET": return requests.get( url, data=data if include_payload else None, timeout=self._timeout, + headers=merged_headers, ) @@ -211,7 +245,10 @@ def client(request: pytest.FixtureRequest, base_url: str) -> Client: env = request.config.getoption("--env") if env == "local": - return LocalClient(lambda_url=base_url) + return LocalClient( + lambda_url=base_url, + headers={"nhsd-correlation-id": "local-test-correlation-id"}, + ) elif env == "remote": return _create_remote_client(request) else: diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index c123dc7c..d604d6f5 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -31,10 +31,15 @@ def test_bundle_returns_200(self, client: Client) -> None: data=bundle.model_dump_json(by_alias=True), path="FHIR/R4/Bundle", request_method="POST", + headers={"nhsd-correlation-id": "test-nhsd-correlation-id-555666777"}, ) assert response.status_code == 200 assert response.headers["Content-Type"] == "application/fhir+json" + assert ( + response.headers["nhsd-correlation-id"] + == "test-nhsd-correlation-id-555666777" + ) response_data = response.json() response_bundle = Bundle.model_validate(response_data, by_alias=True) diff --git a/pathology-api/tests/schema/test_openapi_schema.py b/pathology-api/tests/schema/test_openapi_schema.py index 29c5c75a..54314210 100644 --- a/pathology-api/tests/schema/test_openapi_schema.py +++ b/pathology-api/tests/schema/test_openapi_schema.py @@ -33,5 +33,11 @@ def test_api_schema_compliance(case: Case, base_url: str) -> None: - Validates inputs properly - Returns appropriate status codes """ + # In local testing there is no APIM proxy to inject nhsd-correlation-id, + # so we add it explicitly before calling the API. + if case.headers is None: + case.headers = {"nhsd-correlation-id": "local-test-correlation-id"} + else: + case.headers["nhsd-correlation-id"] = "local-test-correlation-id" # Call the API and validate the response against the schema case.call_and_validate(base_url=base_url, timeout=30) From 1a1dce40e7dd2740c43141cf3d031e912be6fc94 Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:19:07 +0000 Subject: [PATCH 04/12] [CDAPI-118]: fix remote integration test --- pathology-api/tests/integration/test_endpoints.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index d604d6f5..5db921c2 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -36,10 +36,7 @@ def test_bundle_returns_200(self, client: Client) -> None: assert response.status_code == 200 assert response.headers["Content-Type"] == "application/fhir+json" - assert ( - response.headers["nhsd-correlation-id"] - == "test-nhsd-correlation-id-555666777" - ) + assert response.headers.get("nhsd-correlation-id") is not None response_data = response.json() response_bundle = Bundle.model_validate(response_data, by_alias=True) From cc2f31d2698ac2f5164148e49e17f79e1123f31e Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:54:33 +0000 Subject: [PATCH 05/12] [CDAPI-118]: Include correlation-id for status endpoint --- .../images/api-gateway-mock/resources/server.py | 6 +++++- pathology-api/lambda_handler.py | 17 +++++++---------- pathology-api/tests/conftest.py | 1 - .../tests/integration/test_endpoints.py | 15 ++++++++++++--- .../tests/schema/test_openapi_schema.py | 6 ------ 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/infrastructure/images/api-gateway-mock/resources/server.py b/infrastructure/images/api-gateway-mock/resources/server.py index 6cb09f4b..cf59a7a1 100644 --- a/infrastructure/images/api-gateway-mock/resources/server.py +++ b/infrastructure/images/api-gateway-mock/resources/server.py @@ -37,12 +37,16 @@ def forward_request(path_params): app.logger.info("received request with data: %s", request.get_data(as_text=True)) + x_correlation_id = request.headers.get("X-Correlation-ID") + forwarded_headers = {k.lower(): v for k, v in request.headers.items()} + forwarded_headers["nhsd-correlation-id"] = x_correlation_id + response = requests.post( "http://pathology-api:8080/2015-03-31" # NOSONAR python:S5332 "/functions/function/invocations", json={ "body": request.get_data(as_text=True).replace("\n", "").replace(" ", ""), - "headers": {k.lower(): v for k, v in request.headers.items()}, + "headers": forwarded_headers, "requestContext": { "http": { "path": f"/{path_params}", diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index 4d87762f..f1d2582b 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -136,16 +136,13 @@ def handler(data: dict[str, Any], context: LambdaContext) -> dict[str, Any]: headers = data.get("headers", {}) or {} correlation_id = headers.get(_CORRELATION_ID_HEADER) if not correlation_id: - raw_path = (data.get("rawPath") or "").lstrip("/") - if raw_path != "_status": - return { - "statusCode": 400, - "headers": {"Content-Type": "application/fhir+json"}, - "body": OperationOutcome.create_validation_error( - f"Missing required header: {_CORRELATION_ID_HEADER}" - ).model_dump_json(by_alias=True, exclude_none=True), - } - return app.resolve(data, context) + return { + "statusCode": 500, + "headers": {"Content-Type": "application/fhir+json"}, + "body": OperationOutcome.create_server_error( + f"Missing required header: {_CORRELATION_ID_HEADER}" + ).model_dump_json(by_alias=True, exclude_none=True), + } with set_correlation_id(correlation_id): response = app.resolve(data, context) response.setdefault("headers", {})[_CORRELATION_ID_HEADER] = correlation_id diff --git a/pathology-api/tests/conftest.py b/pathology-api/tests/conftest.py index e62cf485..e1a21b8f 100644 --- a/pathology-api/tests/conftest.py +++ b/pathology-api/tests/conftest.py @@ -247,7 +247,6 @@ def client(request: pytest.FixtureRequest, base_url: str) -> Client: if env == "local": return LocalClient( lambda_url=base_url, - headers={"nhsd-correlation-id": "local-test-correlation-id"}, ) elif env == "remote": return _create_remote_client(request) diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index 5db921c2..1a2d29e1 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -31,12 +31,14 @@ def test_bundle_returns_200(self, client: Client) -> None: data=bundle.model_dump_json(by_alias=True), path="FHIR/R4/Bundle", request_method="POST", - headers={"nhsd-correlation-id": "test-nhsd-correlation-id-555666777"}, + headers={"X-Correlation-ID": "test-correlation-id-555666777"}, ) assert response.status_code == 200 assert response.headers["Content-Type"] == "application/fhir+json" - assert response.headers.get("nhsd-correlation-id") is not None + assert response.headers["nhsd-correlation-id"].startswith( + ".test-correlation-id-555666777" + ) response_data = response.json() response_bundle = Bundle.model_validate(response_data, by_alias=True) @@ -258,9 +260,16 @@ class TestStatusEndpoint: @pytest.mark.status_auth_headers def test_status_returns_200(self, client: Client) -> None: - response = client.send_without_payload(request_method="GET", path="_status") + response = client.send_without_payload( + request_method="GET", + path="_status", + headers={"X-Correlation-ID": "test-correlation-id-555666777"}, + ) assert response.status_code == 200 assert response.headers["Content-Type"] == "application/json" + assert response.headers["nhsd-correlation-id"].startswith( + ".test-correlation-id-555666777" + ) parsed = StatusResponse.model_validate(response.json()) diff --git a/pathology-api/tests/schema/test_openapi_schema.py b/pathology-api/tests/schema/test_openapi_schema.py index 54314210..29c5c75a 100644 --- a/pathology-api/tests/schema/test_openapi_schema.py +++ b/pathology-api/tests/schema/test_openapi_schema.py @@ -33,11 +33,5 @@ def test_api_schema_compliance(case: Case, base_url: str) -> None: - Validates inputs properly - Returns appropriate status codes """ - # In local testing there is no APIM proxy to inject nhsd-correlation-id, - # so we add it explicitly before calling the API. - if case.headers is None: - case.headers = {"nhsd-correlation-id": "local-test-correlation-id"} - else: - case.headers["nhsd-correlation-id"] = "local-test-correlation-id" # Call the API and validate the response against the schema case.call_and_validate(base_url=base_url, timeout=30) From 0a6049e6db615c6b8493228a8cce4f28b2cbe451 Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:20:51 +0000 Subject: [PATCH 06/12] [CDAPI-118]: Update tests for 500 error --- pathology-api/lambda_handler.py | 6 +++++- pathology-api/test_lambda_handler.py | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index f1d2582b..35f04b63 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -100,7 +100,11 @@ def handle_exception(exception: Exception) -> Response[str]: @app.get("/_status") def status() -> Response[str]: _logger.debug("Status check endpoint called") - return Response(status_code=200, body="OK", headers={"Content-Type": "text/plain"}) + return Response( + status_code=200, + body='{"status": "pass"}', + headers={"Content-Type": "application/json"}, + ) _CORRELATION_ID_HEADER = "nhsd-correlation-id" diff --git a/pathology-api/test_lambda_handler.py b/pathology-api/test_lambda_handler.py index 412d41c3..80af9425 100644 --- a/pathology-api/test_lambda_handler.py +++ b/pathology-api/test_lambda_handler.py @@ -82,7 +82,7 @@ def test_create_test_result_success(self) -> None: # A UUID value so can only check its presence. assert response_bundle.id is not None - def test_missing_correlation_id_header_returns_400(self) -> None: + def test_missing_correlation_id_header_returns_500(self) -> None: bundle = Bundle.create( type="document", entry=[ @@ -105,12 +105,12 @@ def test_missing_correlation_id_header_returns_400(self) -> None: response = handler(event, context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 500 assert response["headers"] == {"Content-Type": "application/fhir+json"} returned_issue = self._parse_returned_issue(response["body"]) - assert returned_issue["severity"] == "error" - assert returned_issue["code"] == "invalid" + assert returned_issue["severity"] == "fatal" + assert returned_issue["code"] == "exception" assert ( returned_issue["diagnostics"] == "Missing required header: nhsd-correlation-id" @@ -290,11 +290,16 @@ def test_create_test_result_model_validate_error( assert returned_issue["diagnostics"] == expected_diagnostic def test_status_success(self) -> None: - event = self._create_test_event(path_params="_status", request_method="GET") + event = self._create_test_event( + path_params="_status", + request_method="GET", + headers={"nhsd-correlation-id": "test-correlation-id"}, + ) context = LambdaContext() response = handler(event, context) assert response["statusCode"] == 200 - assert response["body"] == "OK" - assert response["headers"] == {"Content-Type": "text/plain"} + assert response["body"] == '{"status": "pass"}' + assert response["headers"]["Content-Type"] == "application/json" + assert response["headers"]["nhsd-correlation-id"] == "test-correlation-id" From 252f1ed7fbb4608a721d4517abf4dd14ef90aa75 Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:18:54 +0000 Subject: [PATCH 07/12] temp --- pathology-api/lambda_handler.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index 35f04b63..f2af4d9c 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -139,6 +139,21 @@ def post_result() -> Response[str]: def handler(data: dict[str, Any], context: LambdaContext) -> dict[str, Any]: headers = data.get("headers", {}) or {} correlation_id = headers.get(_CORRELATION_ID_HEADER) + import logging + + logger = logging.getLogger(__name__) + + headers_lower = {k.lower(): v for k, v in headers.items()} + + logger.info( + "RFL Inbound headers check: x-correlation-id=%r," + "nhsd-correlation-id=%r, all_headers=%s", + headers_lower.get("x-correlation-id"), + headers_lower.get("nhsd-correlation-id"), + headers_lower, + ) + + print("RFL Request headers sent:", headers_lower) if not correlation_id: return { "statusCode": 500, From 6be6ada2d3534a6324a10219815196545852ba22 Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:54:33 +0000 Subject: [PATCH 08/12] temp log --- pathology-api/lambda_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index f2af4d9c..6a566db4 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -139,6 +139,9 @@ def post_result() -> Response[str]: def handler(data: dict[str, Any], context: LambdaContext) -> dict[str, Any]: headers = data.get("headers", {}) or {} correlation_id = headers.get(_CORRELATION_ID_HEADER) + + _logger.info("RFL Data = %s", data) + import logging logger = logging.getLogger(__name__) From 33ba3f82491a043e457d5d5e419800b8251a14d1 Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:48:13 +0000 Subject: [PATCH 09/12] set correlation id in post_result --- pathology-api/lambda_handler.py | 40 +++++++-------------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index 6a566db4..bd40344d 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -112,6 +112,13 @@ def status() -> Response[str]: @app.post("/FHIR/R4/Bundle") def post_result() -> Response[str]: + correlation_id = app.current_event.headers.get(_CORRELATION_ID_HEADER) + + if not correlation_id: + raise ValueError(f"Missing required header: {_CORRELATION_ID_HEADER}") + elif not app.current_event.raw_path.__contains__("_status"): + set_correlation_id(correlation_id) + _logger.debug("Post result endpoint called.") try: @@ -137,35 +144,4 @@ def post_result() -> Response[str]: def handler(data: dict[str, Any], context: LambdaContext) -> dict[str, Any]: - headers = data.get("headers", {}) or {} - correlation_id = headers.get(_CORRELATION_ID_HEADER) - - _logger.info("RFL Data = %s", data) - - import logging - - logger = logging.getLogger(__name__) - - headers_lower = {k.lower(): v for k, v in headers.items()} - - logger.info( - "RFL Inbound headers check: x-correlation-id=%r," - "nhsd-correlation-id=%r, all_headers=%s", - headers_lower.get("x-correlation-id"), - headers_lower.get("nhsd-correlation-id"), - headers_lower, - ) - - print("RFL Request headers sent:", headers_lower) - if not correlation_id: - return { - "statusCode": 500, - "headers": {"Content-Type": "application/fhir+json"}, - "body": OperationOutcome.create_server_error( - f"Missing required header: {_CORRELATION_ID_HEADER}" - ).model_dump_json(by_alias=True, exclude_none=True), - } - with set_correlation_id(correlation_id): - response = app.resolve(data, context) - response.setdefault("headers", {})[_CORRELATION_ID_HEADER] = correlation_id - return response + return app.resolve(data, context) From 2723a9347cf7d66d633488d4deec1fb902ad0167 Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:57:43 +0000 Subject: [PATCH 10/12] temp logging --- pathology-api/lambda_handler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index bd40344d..c60752c9 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -114,6 +114,8 @@ def status() -> Response[str]: def post_result() -> Response[str]: correlation_id = app.current_event.headers.get(_CORRELATION_ID_HEADER) + _logger.info("RFL app = %s", app) + if not correlation_id: raise ValueError(f"Missing required header: {_CORRELATION_ID_HEADER}") elif not app.current_event.raw_path.__contains__("_status"): From 0f8d1aa644cd30366bcc4b285f6e55f8d2baea5d Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:23:25 +0000 Subject: [PATCH 11/12] [CDAPI-118]: Reintroduce Filter to add corr id to every log --- pathology-api/lambda_handler.py | 38 +++++++++---------- pathology-api/src/pathology_api/logging.py | 11 +++++- .../src/pathology_api/test_request_context.py | 4 -- pathology-api/test_lambda_handler.py | 36 ++++++------------ .../tests/integration/test_endpoints.py | 16 ++++++-- 5 files changed, 51 insertions(+), 54 deletions(-) diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index c60752c9..5b5ec7ae 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -114,35 +114,31 @@ def status() -> Response[str]: def post_result() -> Response[str]: correlation_id = app.current_event.headers.get(_CORRELATION_ID_HEADER) - _logger.info("RFL app = %s", app) - if not correlation_id: raise ValueError(f"Missing required header: {_CORRELATION_ID_HEADER}") - elif not app.current_event.raw_path.__contains__("_status"): - set_correlation_id(correlation_id) - - _logger.debug("Post result endpoint called.") + with set_correlation_id(correlation_id): + _logger.debug("Post result endpoint called.") - try: - payload = app.current_event.json_body - except JSONDecodeError as e: - raise ValidationError("Invalid payload provided.") from e + try: + payload = app.current_event.json_body + except JSONDecodeError as e: + raise ValidationError("Invalid payload provided.") from e - _logger.debug("Payload received: %s", payload) + _logger.debug("Payload received: %s", payload) - if payload is None: - raise ValidationError( - "Resources must be provided as a bundle of type 'document'" - ) + if payload is None: + raise ValidationError( + "Resources must be provided as a bundle of type 'document'" + ) - bundle = Bundle.model_validate(payload, by_alias=True) + bundle = Bundle.model_validate(payload, by_alias=True) - response = handle_request(bundle) + response = handle_request(bundle) - return _with_default_headers( - status_code=200, - body=response, - ) + return _with_default_headers( + status_code=200, + body=response, + ) def handler(data: dict[str, Any], context: LambdaContext) -> dict[str, Any]: diff --git a/pathology-api/src/pathology_api/logging.py b/pathology-api/src/pathology_api/logging.py index dc4466b3..fc59087e 100644 --- a/pathology-api/src/pathology_api/logging.py +++ b/pathology-api/src/pathology_api/logging.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Protocol from aws_lambda_powertools import Logger @@ -5,6 +6,14 @@ from pathology_api.request_context import get_correlation_id +class _CorrelationIdFilter(logging.Filter): + """Injects the current correlation ID into every log record.""" + + def filter(self, record: logging.LogRecord) -> bool: + record.correlation_id = get_correlation_id() + return True + + class LogProvider(Protocol): """Protocol defining required contract for a logger.""" @@ -22,5 +31,5 @@ def exception(self, msg: str, *args: Any, **kwargs: Any) -> None: ... def get_logger(service: str) -> LogProvider: """Get a configured logger instance.""" logger = Logger(service=service, level="DEBUG", serialize_stacktrace=True) - logger.set_correlation_id(get_correlation_id()) + logger.addFilter(_CorrelationIdFilter()) return logger diff --git a/pathology-api/src/pathology_api/test_request_context.py b/pathology-api/src/pathology_api/test_request_context.py index ca5ef3bc..d29ac646 100644 --- a/pathology-api/src/pathology_api/test_request_context.py +++ b/pathology-api/src/pathology_api/test_request_context.py @@ -2,10 +2,6 @@ class TestSetAndGetCorrelationId: - def test_set_and_get_correlation_id_within_context(self) -> None: - with set_correlation_id("round-trip-test-123"): - assert get_correlation_id() == "round-trip-test-123" - def test_correlation_id_is_cleared_after_context_exit(self) -> None: with set_correlation_id("round-trip-test-123"): assert get_correlation_id() == "round-trip-test-123" diff --git a/pathology-api/test_lambda_handler.py b/pathology-api/test_lambda_handler.py index 80af9425..edaf5354 100644 --- a/pathology-api/test_lambda_handler.py +++ b/pathology-api/test_lambda_handler.py @@ -67,10 +67,8 @@ def test_create_test_result_success(self) -> None: response = handler(event, context) assert response["statusCode"] == 200 - assert response["headers"] == { - "Content-Type": "application/fhir+json", - "nhsd-correlation-id": "test-correlation-id", - } + assert response["headers"]["Content-Type"] == "application/fhir+json" + assert response["headers"]["nhsd-correlation-id"] == "test-correlation-id" response_body = response["body"] assert isinstance(response_body, str) @@ -127,10 +125,8 @@ def test_create_test_result_no_payload(self) -> None: response = handler(event, context) assert response["statusCode"] == 400 - assert response["headers"] == { - "Content-Type": "application/fhir+json", - "nhsd-correlation-id": "test-correlation-id", - } + assert response["headers"]["Content-Type"] == "application/fhir+json" + assert response["headers"]["nhsd-correlation-id"] == "test-correlation-id" returned_issue = self._parse_returned_issue(response["body"]) @@ -153,10 +149,8 @@ def test_create_test_result_empty_payload(self) -> None: response = handler(event, context) assert response["statusCode"] == 400 - assert response["headers"] == { - "Content-Type": "application/fhir+json", - "nhsd-correlation-id": "test-correlation-id", - } + assert response["headers"]["Content-Type"] == "application/fhir+json" + assert response["headers"]["nhsd-correlation-id"] == "test-correlation-id" returned_issue = self._parse_returned_issue(response["body"]) @@ -179,10 +173,8 @@ def test_create_test_result_invalid_json(self) -> None: response = handler(event, context) assert response["statusCode"] == 400 - assert response["headers"] == { - "Content-Type": "application/fhir+json", - "nhsd-correlation-id": "test-correlation-id", - } + assert response["headers"]["Content-Type"] == "application/fhir+json" + assert response["headers"]["nhsd-correlation-id"] == "test-correlation-id" returned_issue = self._parse_returned_issue(response["body"]) assert returned_issue["severity"] == "error" @@ -234,10 +226,8 @@ def test_create_test_result_processing_error( response = handler(event, context) assert response["statusCode"] == expected_status_code - assert response["headers"] == { - "Content-Type": "application/fhir+json", - "nhsd-correlation-id": "test-correlation-id", - } + assert response["headers"]["Content-Type"] == "application/fhir+json" + assert response["headers"]["nhsd-correlation-id"] == "test-correlation-id" returned_issue = self._parse_returned_issue(response["body"]) assert returned_issue == expected_issue @@ -279,10 +269,8 @@ def test_create_test_result_model_validate_error( response = handler(event, context) assert response["statusCode"] == 400 - assert response["headers"] == { - "Content-Type": "application/fhir+json", - "nhsd-correlation-id": "test-correlation-id", - } + assert response["headers"]["Content-Type"] == "application/fhir+json" + assert response["headers"]["nhsd-correlation-id"] == "test-correlation-id" returned_issue = self._parse_returned_issue(response["body"]) assert returned_issue["severity"] == "error" diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index 1a2d29e1..eda094f4 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -36,6 +36,7 @@ def test_bundle_returns_200(self, client: Client) -> None: assert response.status_code == 200 assert response.headers["Content-Type"] == "application/fhir+json" + assert response.headers["X-Correlation-ID"] == "test-correlation-id-555666777" assert response.headers["nhsd-correlation-id"].startswith( ".test-correlation-id-555666777" ) @@ -263,19 +264,26 @@ def test_status_returns_200(self, client: Client) -> None: response = client.send_without_payload( request_method="GET", path="_status", - headers={"X-Correlation-ID": "test-correlation-id-555666777"}, + headers={"X-Correlation-ID": "test-correlation-id-111222333"}, ) assert response.status_code == 200 assert response.headers["Content-Type"] == "application/json" assert response.headers["nhsd-correlation-id"].startswith( - ".test-correlation-id-555666777" + ".test-correlation-id-111222333.rrt-" + ) + + import json + import logging + + logger = logging.getLogger(__name__) + logger.warning( + "/_status response JSON: %s", json.dumps(response.json(), indent=2) ) parsed = StatusResponse.model_validate(response.json()) assert parsed.status == "pass" assert parsed.checks.healthcheck.responseCode == 200 - assert parsed.checks.healthcheck.outcome == "OK" class StatusLinks(BaseModel): @@ -286,7 +294,7 @@ class HealthCheck(BaseModel): status: Literal["pass", "fail"] timeout: Literal["true", "false"] responseCode: int - outcome: str + outcome: dict[Any, Any] links: StatusLinks From e435ae50b3de28c30d523460f5076ee0b92217c5 Mon Sep 17 00:00:00 2001 From: nhsd-rebecca-flynn <241736882+nhsd-rebecca-flynn@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:37:21 +0000 Subject: [PATCH 12/12] add logging of headers if no correlation id --- pathology-api/lambda_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index 5b5ec7ae..57bdad03 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -115,6 +115,9 @@ def post_result() -> Response[str]: correlation_id = app.current_event.headers.get(_CORRELATION_ID_HEADER) if not correlation_id: + _logger.warning( + "no correlation id. Current event headers: %s", app.current_event.headers + ) raise ValueError(f"Missing required header: {_CORRELATION_ID_HEADER}") with set_correlation_id(correlation_id): _logger.debug("Post result endpoint called.")