From cc6d5c34b5ca3eed047fd162cec273fb23aa0754 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:55:27 +0000 Subject: [PATCH 01/40] [GPCAPIM-275]: Remove duplicate lines. --- gateway-api/src/gateway_api/test_app.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index fdf77815..6e469151 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -16,9 +16,6 @@ if TYPE_CHECKING: from fhir.parameters import Parameters -if TYPE_CHECKING: - from fhir.parameters import Parameters - @pytest.fixture def client() -> Generator[FlaskClient[Flask], None, None]: From 4ebe273e91c327e3a51f98c16cda92f00cdb1749 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:09:00 +0000 Subject: [PATCH 02/40] [GPCAPIM-275]: Import modules consistently. --- gateway-api/src/gateway_api/test_app.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 6e469151..718466fd 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -3,13 +3,15 @@ import json import os from collections.abc import Generator -from typing import TYPE_CHECKING +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any import pytest from flask import Flask from flask.testing import FlaskClient from gateway_api.app import app, get_app_host, get_app_port +from gateway_api.common.common import FlaskResponse from gateway_api.controller import Controller from gateway_api.get_structured_record.request import GetStructuredRecordRequest @@ -58,10 +60,6 @@ def test_get_structured_record_returns_200_with_bundle( valid_simple_request_payload: "Parameters", ) -> None: """Test that successful controller response is returned correctly.""" - from datetime import datetime, timezone - from typing import Any - - from gateway_api.common.common import FlaskResponse # Mock the controller to return a successful FlaskResponse with a Bundle mock_bundle_data: Any = { From 742f2fd823082f700f949fea71cdb436b01a8282 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:22:33 +0000 Subject: [PATCH 03/40] [GPCAPIM-275]: Enable other test modules to use a valid Bundle. --- gateway-api/src/gateway_api/conftest.py | 28 +++++++++++++++++++ gateway-api/src/gateway_api/test_app.py | 37 ++++--------------------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 05307c86..b9866638 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -1,6 +1,9 @@ """Pytest configuration and shared fixtures for gateway API tests.""" +from datetime import datetime, timezone + import pytest +from fhir.bundle import Bundle from fhir.parameters import Parameters @@ -18,3 +21,28 @@ def valid_simple_request_payload() -> Parameters: }, ], } + + +@pytest.fixture +def valid_simple_response_payload() -> Bundle: + return { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": datetime.now(timezone.utc).isoformat(), + "entry": [ + { + "fullUrl": "http://example.com/Patient/9999999999", + "resource": { + "name": [{"family": "Alice", "given": ["Johnson"], "use": "Ally"}], + "gender": "female", + "birthDate": "1990-05-15", + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + {"value": "9999999999", "system": "urn:nhs:numbers"} + ], + }, + } + ], + } diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 718466fd..97d2f46f 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -3,10 +3,10 @@ import json import os from collections.abc import Generator -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any import pytest +from fhir.bundle import Bundle +from fhir.parameters import Parameters from flask import Flask from flask.testing import FlaskClient @@ -15,9 +15,6 @@ from gateway_api.controller import Controller from gateway_api.get_structured_record.request import GetStructuredRecordRequest -if TYPE_CHECKING: - from fhir.parameters import Parameters - @pytest.fixture def client() -> Generator[FlaskClient[Flask], None, None]: @@ -57,42 +54,18 @@ def test_get_structured_record_returns_200_with_bundle( self, client: FlaskClient[Flask], monkeypatch: pytest.MonkeyPatch, - valid_simple_request_payload: "Parameters", + valid_simple_request_payload: Parameters, + valid_simple_response_payload: Bundle, ) -> None: """Test that successful controller response is returned correctly.""" - # Mock the controller to return a successful FlaskResponse with a Bundle - mock_bundle_data: Any = { - "resourceType": "Bundle", - "id": "example-patient-bundle", - "type": "collection", - "timestamp": datetime.now(timezone.utc).isoformat(), - "entry": [ - { - "fullUrl": "http://example.com/Patient/9999999999", - "resource": { - "name": [ - {"family": "Alice", "given": ["Johnson"], "use": "Ally"} - ], - "gender": "female", - "birthDate": "1990-05-15", - "resourceType": "Patient", - "id": "9999999999", - "identifier": [ - {"value": "9999999999", "system": "urn:nhs:numbers"} - ], - }, - } - ], - } - def mock_run( self: Controller, # noqa: ARG001 request: GetStructuredRecordRequest, # noqa: ARG001 ) -> FlaskResponse: return FlaskResponse( status_code=200, - data=json.dumps(mock_bundle_data), + data=json.dumps(valid_simple_response_payload), headers={"Content-Type": "application/fhir+json"}, ) From 41437aced651a600da455bb0b6ea764fb2ccca6a Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:13:24 +0000 Subject: [PATCH 04/40] [GPCAPIM-275]: Reduce mocking complexity. Move common required headers to fixture. --- gateway-api/poetry.lock | 32 ++++++-- gateway-api/pyproject.toml | 2 + gateway-api/src/gateway_api/conftest.py | 8 ++ gateway-api/src/gateway_api/test_app.py | 101 +++++++++++------------- 4 files changed, 82 insertions(+), 61 deletions(-) diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 88b054f5..b4c4e471 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -349,7 +349,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\""} +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "coverage" @@ -684,7 +684,7 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, @@ -1196,7 +1196,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1294,7 +1294,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1455,7 +1455,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -1486,7 +1486,7 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -1582,6 +1582,24 @@ pytest = ">=7.0.0" [package.extras] test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "tox (>=3.24.5)"] +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "pytest-subtests" version = "0.14.2" @@ -2360,4 +2378,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "a452bd22e2386a3ff58b4c7a5ac2cb571de9e3d49a4fbc161ffd3aafa2a7bf44" +content-hash = "9646e1adfb86cc4e07b149bc1a93f1e32921f0cd50c57603cdb6fe907092ce7a" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 748ebd4f..b95d627c 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -13,6 +13,7 @@ clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-comm flask = "^3.1.2" types-flask = "^1.1.6" requests = "^2.32.5" +pytest-mock = "^3.15.1" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, @@ -55,6 +56,7 @@ dev = [ "schemathesis>=4.4.1", "types-requests (>=2.32.4.20250913,<3.0.0.0)", "types-pyyaml (>=6.0.12.20250915,<7.0.0.0)", + "pytest-mock (>=3.15.1,<4.0.0)", ] [tool.mypy] diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index b9866638..d9db3b59 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -46,3 +46,11 @@ def valid_simple_response_payload() -> Bundle: } ], } + + +@pytest.fixture +def valid_headers() -> dict[str, str]: + return { + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + } diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 97d2f46f..34b5e042 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -3,17 +3,17 @@ import json import os from collections.abc import Generator +from copy import copy import pytest from fhir.bundle import Bundle from fhir.parameters import Parameters from flask import Flask from flask.testing import FlaskClient +from pytest_mock import MockerFixture from gateway_api.app import app, get_app_host, get_app_port from gateway_api.common.common import FlaskResponse -from gateway_api.controller import Controller -from gateway_api.get_structured_record.request import GetStructuredRecordRequest @pytest.fixture @@ -53,25 +53,19 @@ class TestGetStructuredRecord: def test_get_structured_record_returns_200_with_bundle( self, client: FlaskClient[Flask], - monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, valid_simple_request_payload: Parameters, valid_simple_response_payload: Bundle, ) -> None: """Test that successful controller response is returned correctly.""" - def mock_run( - self: Controller, # noqa: ARG001 - request: GetStructuredRecordRequest, # noqa: ARG001 - ) -> FlaskResponse: - return FlaskResponse( - status_code=200, - data=json.dumps(valid_simple_response_payload), - headers={"Content-Type": "application/fhir+json"}, - ) - - monkeypatch.setattr( - "gateway_api.controller.Controller.run", - mock_run, + postive_response = FlaskResponse( + status_code=200, + data=json.dumps(valid_simple_response_payload), + headers={"Content-Type": "application/fhir+json"}, + ) + mocker.patch( + "gateway_api.controller.Controller.run", return_value=postive_response ) response = client.post( @@ -96,73 +90,72 @@ def mock_run( assert data["entry"][0]["resource"]["id"] == "9999999999" assert data["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" - def test_get_structured_record_handles_exception( + def test_get_structured_record_returns_500_when_an_uncaught_exception_is_raised( self, client: FlaskClient[Flask], - monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, valid_simple_request_payload: "Parameters", + valid_headers: dict[str, str], ) -> None: - """ - Test that exceptions during controller execution are caught and return 500. - """ - - # This is mocking the run method of the Controller - # and therefore self is a Controller - def mock_run_with_exception( - self: Controller, # noqa: ARG001 - request: GetStructuredRecordRequest, # noqa: ARG001 - ) -> None: - raise ValueError("Test exception") - - monkeypatch.setattr( - "gateway_api.controller.Controller.run", - mock_run_with_exception, + internal_error = ValueError("Test exception") + mocker.patch( + "gateway_api.controller.Controller.run", side_effect=internal_error ) response = client.post( "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload, - headers={ - "Ssp-TraceID": "test-trace-id", - "ODS-from": "test-ods", - }, + headers=valid_headers, ) assert response.status_code == 500 - def test_get_structured_record_handles_request_validation_error( + @pytest.mark.parametrize( + ("missing_header_key", "expected_message"), + [ + pytest.param( + "ODS-from", + b'Missing or empty required header "ODS-from"', + id="missing ODS code", + ), + pytest.param( + "Ssp-TraceID", + b'Missing or empty required header "Ssp-TraceID"', + id="missing trace id", + ), + ], + ) + def test_get_structured_record_request_returns_400_when_required_header_missing( self, client: FlaskClient[Flask], valid_simple_request_payload: "Parameters", + valid_headers: dict[str, str], + missing_header_key: str, + expected_message: bytes, ) -> None: """Test that RequestValidationError returns 400 with error message.""" - # Create a request missing the required ODS-from header + invalid_headers = copy(valid_headers) + del invalid_headers[missing_header_key] + response = client.post( "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload, - headers={ - "Ssp-TraceID": "test-trace-id", - # Missing "ODS-from" header to trigger RequestValidationError - }, + headers=invalid_headers, ) assert response.status_code == 400 assert "text/plain" in response.content_type - assert b'Missing or empty required header "ODS-from"' in response.data + assert expected_message in response.data - def test_get_structured_record_handles_unexpected_exception_during_init( - self, - client: FlaskClient[Flask], + def test_get_structured_record_handles_invalid_json_data( + self, client: FlaskClient[Flask], valid_headers: dict[str, str] ) -> None: """Test that unexpected exceptions during request init return 500.""" - # Send invalid JSON to trigger an exception during request processing + invalid_json = "invalid json data" + response = client.post( "/patient/$gpc.getstructuredrecord", - data="invalid json data", - headers={ - "Ssp-TraceID": "test-trace-id", - "ODS-from": "test-ods", - "Content-Type": "application/fhir+json", - }, + data=invalid_json, + headers=valid_headers, ) assert response.status_code == 500 From dd8c3180c25a0a400e1d0a3898a1bc06ec833163 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:06:24 +0000 Subject: [PATCH 05/40] [GPCAPIM-275]: Resolve spurious Sonar issue --- gateway-api/src/gateway_api/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index d9db3b59..c7a16d40 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -32,7 +32,7 @@ def valid_simple_response_payload() -> Bundle: "timestamp": datetime.now(timezone.utc).isoformat(), "entry": [ { - "fullUrl": "http://example.com/Patient/9999999999", + "fullUrl": "https://example.com/Patient/9999999999", "resource": { "name": [{"family": "Alice", "given": ["Johnson"], "use": "Ally"}], "gender": "female", From 4f1534da5520886bd1bad5541079c8106040fb25 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:57:17 +0000 Subject: [PATCH 06/40] [GPCAPIM-275]: Use single assertion in unit tests --- gateway-api/src/gateway_api/test_app.py | 166 +++++++++++++++--------- 1 file changed, 107 insertions(+), 59 deletions(-) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 34b5e042..9c4a2dcf 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -50,64 +50,53 @@ def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None: class TestGetStructuredRecord: - def test_get_structured_record_returns_200_with_bundle( + @pytest.mark.usefixtures("mock_positive_return_value_from_controller_run") + def test_valid_get_structured_record_request_returns_bundle( self, - client: FlaskClient[Flask], - mocker: MockerFixture, - valid_simple_request_payload: Parameters, - valid_simple_response_payload: Bundle, + get_structured_record_response: Flask, ) -> None: - """Test that successful controller response is returned correctly.""" - - postive_response = FlaskResponse( - status_code=200, - data=json.dumps(valid_simple_response_payload), - headers={"Content-Type": "application/fhir+json"}, - ) - mocker.patch( - "gateway_api.controller.Controller.run", return_value=postive_response - ) - - response = client.post( - "/patient/$gpc.getstructuredrecord", - json=valid_simple_request_payload, - headers={ - "Ssp-TraceID": "test-trace-id", - "ODS-from": "test-ods", - }, - ) - - assert response.status_code == 200 - data = response.get_json() - assert isinstance(data, dict) - assert data.get("resourceType") == "Bundle" - assert data.get("id") == "example-patient-bundle" - assert data.get("type") == "collection" - assert "entry" in data - assert isinstance(data["entry"], list) - assert len(data["entry"]) > 0 - assert data["entry"][0]["resource"]["resourceType"] == "Patient" - assert data["entry"][0]["resource"]["id"] == "9999999999" - assert data["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" + expected_body_wihtout_timestamp = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "entry": [ + { + "fullUrl": "https://example.com/Patient/9999999999", + "resource": { + "name": [ + {"family": "Alice", "given": ["Johnson"], "use": "Ally"} + ], + "gender": "female", + "birthDate": "1990-05-15", + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + {"value": "9999999999", "system": "urn:nhs:numbers"} + ], + }, + } + ], + } + + actual_body_without_timestamp = get_structured_record_response.get_json() + del actual_body_without_timestamp["timestamp"] + + assert actual_body_without_timestamp == expected_body_wihtout_timestamp + + @pytest.mark.usefixtures("mock_positive_return_value_from_controller_run") + def test_valid_get_structured_record_request_returns_200( + self, + get_structured_record_response: Flask, + ) -> None: + assert get_structured_record_response.status_code == 200 + @pytest.mark.usefixtures("mock_raise_error_from_controller_run") def test_get_structured_record_returns_500_when_an_uncaught_exception_is_raised( self, - client: FlaskClient[Flask], - mocker: MockerFixture, - valid_simple_request_payload: "Parameters", - valid_headers: dict[str, str], + get_structured_record_response: Flask, ) -> None: - internal_error = ValueError("Test exception") - mocker.patch( - "gateway_api.controller.Controller.run", side_effect=internal_error - ) - - response = client.post( - "/patient/$gpc.getstructuredrecord", - json=valid_simple_request_payload, - headers=valid_headers, - ) - assert response.status_code == 500 + actual_status_code = get_structured_record_response.status_code + assert actual_status_code == 500 @pytest.mark.parametrize( ("missing_header_key", "expected_message"), @@ -132,7 +121,6 @@ def test_get_structured_record_request_returns_400_when_required_header_missing( missing_header_key: str, expected_message: bytes, ) -> None: - """Test that RequestValidationError returns 400 with error message.""" invalid_headers = copy(valid_headers) del invalid_headers[missing_header_key] @@ -146,10 +134,47 @@ def test_get_structured_record_request_returns_400_when_required_header_missing( assert "text/plain" in response.content_type assert expected_message in response.data - def test_get_structured_record_handles_invalid_json_data( - self, client: FlaskClient[Flask], valid_headers: dict[str, str] + def test_get_structured_record_returns_500_when_invalid_json_sent( + self, get_structured_record_response_using_invalid_json_body: Flask + ) -> None: + assert get_structured_record_response_using_invalid_json_body.status_code == 500 + + def test_get_structured_record_returns_content_type_textplain_for_invalid_json_sent( + self, get_structured_record_response_using_invalid_json_body: Flask ) -> None: - """Test that unexpected exceptions during request init return 500.""" + assert ( + "text/plain" + in get_structured_record_response_using_invalid_json_body.content_type + ) + + def test_get_structured_record_returns_intenral_server_error_when_invalid_json_sent( + self, get_structured_record_response_using_invalid_json_body: Flask + ) -> None: + assert ( + b"Internal Server Error:" + in get_structured_record_response_using_invalid_json_body.data + ) + + @staticmethod + @pytest.fixture + def get_structured_record_response( + client: FlaskClient[Flask], + valid_headers: dict[str, str], + valid_simple_request_payload: Parameters, + ) -> Flask: + response = client.post( + "/patient/$gpc.getstructuredrecord", + json=valid_simple_request_payload, + headers=valid_headers, + ) + return response + + @staticmethod + @pytest.fixture + def get_structured_record_response_using_invalid_json_body( + client: FlaskClient[Flask], + valid_headers: dict[str, str], + ) -> Flask: invalid_json = "invalid json data" response = client.post( @@ -157,10 +182,33 @@ def test_get_structured_record_handles_invalid_json_data( data=invalid_json, headers=valid_headers, ) + return response - assert response.status_code == 500 - assert "text/plain" in response.content_type - assert b"Internal Server Error:" in response.data + @staticmethod + @pytest.fixture + def mock_positive_return_value_from_controller_run( + mocker: MockerFixture, + valid_headers: dict[str, str], + valid_simple_response_payload: Bundle, + ) -> None: + postive_response = FlaskResponse( + status_code=200, + data=json.dumps(valid_simple_response_payload), + headers=valid_headers, + ) + mocker.patch( + "gateway_api.controller.Controller.run", return_value=postive_response + ) + + @staticmethod + @pytest.fixture + def mock_raise_error_from_controller_run( + mocker: MockerFixture, + ) -> None: + internal_error = ValueError("Test exception") + mocker.patch( + "gateway_api.controller.Controller.run", side_effect=internal_error + ) class TestHealthCheck: From 3580a2b3565344d78abae5a9645f39d84753554b Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:44:50 +0000 Subject: [PATCH 07/40] [GPCAPIM-275]: Content-type header is a required header. --- gateway-api/src/gateway_api/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index c7a16d40..b0b24f14 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -53,4 +53,5 @@ def valid_headers() -> dict[str, str]: return { "Ssp-TraceID": "test-trace-id", "ODS-from": "test-ods", + "Content-type": "application/fhir+json", } From 22adc40e2ceef9fdeae64bc8b3dbf932b33e8a93 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:48:13 +0000 Subject: [PATCH 08/40] [GPCAPIM-275]: Use static datetimes for unit tests. --- gateway-api/src/gateway_api/conftest.py | 4 +--- gateway-api/src/gateway_api/test_app.py | 9 ++++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index b0b24f14..ed88f984 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -1,7 +1,5 @@ """Pytest configuration and shared fixtures for gateway API tests.""" -from datetime import datetime, timezone - import pytest from fhir.bundle import Bundle from fhir.parameters import Parameters @@ -29,7 +27,7 @@ def valid_simple_response_payload() -> Bundle: "resourceType": "Bundle", "id": "example-patient-bundle", "type": "collection", - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": "2026-02-05T22:45:42.766330+00:00", "entry": [ { "fullUrl": "https://example.com/Patient/9999999999", diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 9c4a2dcf..b766f826 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -55,10 +55,11 @@ def test_valid_get_structured_record_request_returns_bundle( self, get_structured_record_response: Flask, ) -> None: - expected_body_wihtout_timestamp = { + expected_body = { "resourceType": "Bundle", "id": "example-patient-bundle", "type": "collection", + "timestamp": "2026-02-05T22:45:42.766330+00:00", "entry": [ { "fullUrl": "https://example.com/Patient/9999999999", @@ -78,10 +79,8 @@ def test_valid_get_structured_record_request_returns_bundle( ], } - actual_body_without_timestamp = get_structured_record_response.get_json() - del actual_body_without_timestamp["timestamp"] - - assert actual_body_without_timestamp == expected_body_wihtout_timestamp + actual_body = get_structured_record_response.get_json() + assert actual_body == expected_body @pytest.mark.usefixtures("mock_positive_return_value_from_controller_run") def test_valid_get_structured_record_request_returns_200( From 4b9dbecedfd2538c23ad0d776578de3df759e937 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:35:41 +0000 Subject: [PATCH 09/40] [GPCAPIM-275]: Move towards using a common error class --- gateway-api/src/gateway_api/app.py | 3 ++ gateway-api/src/gateway_api/common/error.py | 41 +++++++++++++++++++ .../get_structured_record/request.py | 8 +++- gateway-api/src/gateway_api/test_app.py | 30 ++++++++++---- 4 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 gateway-api/src/gateway_api/common/error.py diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 265601e5..362619ca 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -4,6 +4,7 @@ from flask import Flask, request from flask.wrappers import Response +from gateway_api.common.error import Error from gateway_api.controller import Controller from gateway_api.get_structured_record import ( GetStructuredRecordRequest, @@ -45,6 +46,8 @@ def get_structured_record() -> Response: content_type="text/plain", ) return response + except Error as error: + return error.build_response() except Exception as e: response = Response( response=f"Internal Server Error: {e}", diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py new file mode 100644 index 00000000..f7b4b5d8 --- /dev/null +++ b/gateway-api/src/gateway_api/common/error.py @@ -0,0 +1,41 @@ +import json +from dataclasses import dataclass +from http.client import BAD_REQUEST +from typing import TYPE_CHECKING + +from flask import Response + +if TYPE_CHECKING: + from fhir.operation_outcome import OperationOutcome + + +@dataclass +class Error(Exception): + message: str = "Internal Server Error" + status_code: int = 500 + severity: str = "error" + fhir_error_code: str = "exception" + + def build_response(self) -> Response: + operation_outcome: OperationOutcome = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": self.severity, + "code": self.fhir_error_code, + "diagnostics": self.message, + } + ], + } + response = Response( + response=json.dumps(operation_outcome), + status=self.status_code, + content_type="application/fhir+json", + ) + return response + + +class CDGAPIErrors: + INVALID_REQUEST_JSON = Error( + "Invalid JSON body sent in request", status_code=BAD_REQUEST + ) diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index c4279272..b12e040f 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -4,8 +4,10 @@ from fhir import OperationOutcome, Parameters from fhir.operation_outcome import OperationOutcomeIssue from flask.wrappers import Request, Response +from werkzeug.exceptions import BadRequest from gateway_api.common.common import FlaskResponse +from gateway_api.common.error import CDGAPIErrors if TYPE_CHECKING: from fhir.bundle import Bundle @@ -23,7 +25,11 @@ class GetStructuredRecordRequest: def __init__(self, request: Request) -> None: self._http_request = request self._headers = request.headers - self._request_body: Parameters = request.get_json() + try: + self._request_body: Parameters = request.get_json() + except BadRequest as error: + raise CDGAPIErrors.INVALID_REQUEST_JSON from error + self._response_body: Bundle | OperationOutcome | None = None self._status_code: int | None = None diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index b766f826..8e7d66fa 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -4,6 +4,7 @@ import os from collections.abc import Generator from copy import copy +from typing import TYPE_CHECKING import pytest from fhir.bundle import Bundle @@ -15,6 +16,9 @@ from gateway_api.app import app, get_app_host, get_app_port from gateway_api.common.common import FlaskResponse +if TYPE_CHECKING: + from fhir.operation_outcome import OperationOutcome + @pytest.fixture def client() -> Generator[FlaskClient[Flask], None, None]: @@ -133,26 +137,34 @@ def test_get_structured_record_request_returns_400_when_required_header_missing( assert "text/plain" in response.content_type assert expected_message in response.data - def test_get_structured_record_returns_500_when_invalid_json_sent( + def test_get_structured_record_returns_400_when_invalid_json_sent( self, get_structured_record_response_using_invalid_json_body: Flask ) -> None: - assert get_structured_record_response_using_invalid_json_body.status_code == 500 + assert get_structured_record_response_using_invalid_json_body.status_code == 400 - def test_get_structured_record_returns_content_type_textplain_for_invalid_json_sent( + def test_get_structured_record_returns_content_type_fhir_json_for_invalid_json_sent( self, get_structured_record_response_using_invalid_json_body: Flask ) -> None: assert ( - "text/plain" + "application/fhir+json" in get_structured_record_response_using_invalid_json_body.content_type ) - def test_get_structured_record_returns_intenral_server_error_when_invalid_json_sent( + def test_get_structured_record_returns_internal_server_error_when_invalid_json_sent( self, get_structured_record_response_using_invalid_json_body: Flask ) -> None: - assert ( - b"Internal Server Error:" - in get_structured_record_response_using_invalid_json_body.data - ) + expected: OperationOutcome = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": "Invalid JSON body sent in request", + } + ], + } + actual = get_structured_record_response_using_invalid_json_body.get_json() + assert actual == expected @staticmethod @pytest.fixture From 80a90eda41b8bee52adf057f7ceb6910d1efa05f Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:58:24 +0000 Subject: [PATCH 10/40] [GPCAPIM-275]: Move towards using a common error class. --- gateway-api/src/gateway_api/common/error.py | 7 +++++++ .../get_structured_record/request.py | 7 ++----- .../get_structured_record/test_request.py | 14 +++++-------- gateway-api/src/gateway_api/test_app.py | 20 ++++++++++++++----- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index f7b4b5d8..f7288f60 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -39,3 +39,10 @@ class CDGAPIErrors: INVALID_REQUEST_JSON = Error( "Invalid JSON body sent in request", status_code=BAD_REQUEST ) + + MISSING_TRACE_ID = Error( + 'Missing or empty required header "Ssp-TraceID"', status_code=BAD_REQUEST + ) + MISSING_ODS_CODE = Error( + 'Missing or empty required header "ODS-from"', status_code=BAD_REQUEST + ) diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index b12e040f..a9a1b4e1 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -33,7 +33,6 @@ def __init__(self, request: Request) -> None: self._response_body: Bundle | OperationOutcome | None = None self._status_code: int | None = None - # Validate required headers self._validate_headers() @property @@ -62,13 +61,11 @@ def _validate_headers(self) -> None: """ trace_id = self._headers.get("Ssp-TraceID", "").strip() if not trace_id: - raise RequestValidationError( - 'Missing or empty required header "Ssp-TraceID"' - ) + raise CDGAPIErrors.MISSING_TRACE_ID ods_from = self._headers.get("ODS-from", "").strip() if not ods_from: - raise RequestValidationError('Missing or empty required header "ODS-from"') + raise CDGAPIErrors.MISSING_ODS_CODE def build_response(self) -> Response: return Response( diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py index 6fa5f9a2..944c1628 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -7,7 +7,7 @@ from werkzeug.test import EnvironBuilder from gateway_api.common.common import FlaskResponse -from gateway_api.get_structured_record import RequestValidationError +from gateway_api.common.error import Error from gateway_api.get_structured_record.request import GetStructuredRecordRequest if TYPE_CHECKING: @@ -79,9 +79,7 @@ def test_raises_value_error_when_ods_from_header_is_missing( } mock_request = create_mock_request(headers, valid_simple_request_payload) - with pytest.raises( - RequestValidationError, match='Missing or empty required header "ODS-from"' - ): + with pytest.raises(Error, match='Missing or empty required header "ODS-from"'): GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_ods_from_header_is_whitespace( @@ -96,9 +94,7 @@ def test_raises_value_error_when_ods_from_header_is_whitespace( } mock_request = create_mock_request(headers, valid_simple_request_payload) - with pytest.raises( - RequestValidationError, match='Missing or empty required header "ODS-from"' - ): + with pytest.raises(Error, match='Missing or empty required header "ODS-from"'): GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_trace_id_header_is_missing( @@ -111,7 +107,7 @@ def test_raises_value_error_when_trace_id_header_is_missing( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, + Error, match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) @@ -129,7 +125,7 @@ def test_raises_value_error_when_trace_id_header_is_whitespace( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, + Error, match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 8e7d66fa..42c486f9 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -106,12 +106,12 @@ def test_get_structured_record_returns_500_when_an_uncaught_exception_is_raised( [ pytest.param( "ODS-from", - b'Missing or empty required header "ODS-from"', + 'Missing or empty required header "ODS-from"', id="missing ODS code", ), pytest.param( "Ssp-TraceID", - b'Missing or empty required header "Ssp-TraceID"', + 'Missing or empty required header "Ssp-TraceID"', id="missing trace id", ), ], @@ -122,7 +122,7 @@ def test_get_structured_record_request_returns_400_when_required_header_missing( valid_simple_request_payload: "Parameters", valid_headers: dict[str, str], missing_header_key: str, - expected_message: bytes, + expected_message: str, ) -> None: invalid_headers = copy(valid_headers) del invalid_headers[missing_header_key] @@ -134,8 +134,18 @@ def test_get_structured_record_request_returns_400_when_required_header_missing( ) assert response.status_code == 400 - assert "text/plain" in response.content_type - assert expected_message in response.data + assert "application/fhir+json" in response.content_type + expected_body: OperationOutcome = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": expected_message, + } + ], + } + assert expected_body == response.get_json() def test_get_structured_record_returns_400_when_invalid_json_sent( self, get_structured_record_response_using_invalid_json_body: Flask From 72af28cf4dbf887f86d54cb253fd67a28e1d255e Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:11:04 +0000 Subject: [PATCH 11/40] [GPCAPIM-275]: Rework unit tests to only assert once in a test method --- gateway-api/src/gateway_api/test_app.py | 80 ++++++++++++++++++------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 42c486f9..b3743e6b 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -101,40 +101,62 @@ def test_get_structured_record_returns_500_when_an_uncaught_exception_is_raised( actual_status_code = get_structured_record_response.status_code assert actual_status_code == 500 + @staticmethod + @pytest.fixture + def missing_headers( + request: pytest.FixtureRequest, valid_headers: dict[str, str] + ) -> dict[str, str]: + invalid_headers = copy(valid_headers) + del invalid_headers[request.param] + return invalid_headers + + @pytest.mark.parametrize( + "missing_headers", + ["ODS-from", "Ssp-TraceID"], + indirect=True, + ) + @pytest.mark.usefixtures("missing_headers") + def test_get_structured_record_returns_400_when_required_header_missing( + self, + get_structured_record_response_from_missing_header: Flask, + ) -> None: + + assert get_structured_record_response_from_missing_header.status_code == 400 + @pytest.mark.parametrize( - ("missing_header_key", "expected_message"), + "missing_headers", + ["ODS-from", "Ssp-TraceID"], + indirect=True, + ) + @pytest.mark.usefixtures("missing_headers") + def test_get_structured_record_returns_fhir_content_when_missing_header( + self, + get_structured_record_response_from_missing_header: Flask, + ) -> None: + assert ( + "application/fhir+json" + in get_structured_record_response_from_missing_header.content_type + ) + + @pytest.mark.parametrize( + ("missing_headers", "expected_message"), [ pytest.param( "ODS-from", 'Missing or empty required header "ODS-from"', - id="missing ODS code", ), pytest.param( "Ssp-TraceID", 'Missing or empty required header "Ssp-TraceID"', - id="missing trace id", ), ], + indirect=["missing_headers"], ) - def test_get_structured_record_request_returns_400_when_required_header_missing( + def test_get_structured_record_returns_operation_outcome_when_missing_header( self, - client: FlaskClient[Flask], - valid_simple_request_payload: "Parameters", - valid_headers: dict[str, str], - missing_header_key: str, + get_structured_record_response_from_missing_header: Flask, expected_message: str, ) -> None: - invalid_headers = copy(valid_headers) - del invalid_headers[missing_header_key] - - response = client.post( - "/patient/$gpc.getstructuredrecord", - json=valid_simple_request_payload, - headers=invalid_headers, - ) - - assert response.status_code == 400 - assert "application/fhir+json" in response.content_type expected_body: OperationOutcome = { "resourceType": "OperationOutcome", "issue": [ @@ -145,7 +167,10 @@ def test_get_structured_record_request_returns_400_when_required_header_missing( } ], } - assert expected_body == response.get_json() + assert ( + expected_body + == get_structured_record_response_from_missing_header.get_json() + ) def test_get_structured_record_returns_400_when_invalid_json_sent( self, get_structured_record_response_using_invalid_json_body: Flask @@ -190,6 +215,21 @@ def get_structured_record_response( ) return response + @staticmethod + @pytest.fixture + def get_structured_record_response_from_missing_header( + client: FlaskClient[Flask], + missing_headers: dict[str, str], + valid_simple_request_payload: Parameters, + ) -> Flask: + + response = client.post( + "/patient/$gpc.getstructuredrecord", + data=json.dumps(valid_simple_request_payload), + headers=missing_headers, + ) + return response + @staticmethod @pytest.fixture def get_structured_record_response_using_invalid_json_body( From a41f24157e86a2ce747fe62db1c7a02489d37e3b Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:16:29 +0000 Subject: [PATCH 12/40] [GPCAPIM-275]: Have a "catch all" error within the common error class --- gateway-api/src/gateway_api/app.py | 18 +++--------------- gateway-api/src/gateway_api/common/error.py | 5 ++++- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 362619ca..dc7d748a 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -4,11 +4,10 @@ from flask import Flask, request from flask.wrappers import Response -from gateway_api.common.error import Error +from gateway_api.common.error import CDGAPIErrors, Error from gateway_api.controller import Controller from gateway_api.get_structured_record import ( GetStructuredRecordRequest, - RequestValidationError, ) app = Flask(__name__) @@ -39,21 +38,10 @@ def get_app_port() -> int: def get_structured_record() -> Response: try: get_structured_record_request = GetStructuredRecordRequest(request) - except RequestValidationError as e: - response = Response( - response=str(e), - status=400, - content_type="text/plain", - ) - return response except Error as error: return error.build_response() - except Exception as e: - response = Response( - response=f"Internal Server Error: {e}", - status=500, - content_type="text/plain", - ) + except Exception: + response = CDGAPIErrors.GENERIC_ERROR.build_response() return response try: diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index f7288f60..548a1b58 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -11,7 +11,7 @@ @dataclass class Error(Exception): - message: str = "Internal Server Error" + message: str status_code: int = 500 severity: str = "error" fhir_error_code: str = "exception" @@ -36,6 +36,8 @@ def build_response(self) -> Response: class CDGAPIErrors: + GENERIC_ERROR = Error("Internal Server Error") + INVALID_REQUEST_JSON = Error( "Invalid JSON body sent in request", status_code=BAD_REQUEST ) @@ -43,6 +45,7 @@ class CDGAPIErrors: MISSING_TRACE_ID = Error( 'Missing or empty required header "Ssp-TraceID"', status_code=BAD_REQUEST ) + MISSING_ODS_CODE = Error( 'Missing or empty required header "ODS-from"', status_code=BAD_REQUEST ) From 701038b773fb7d7dd90262e3aacdce88b8b01a6e Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:23:20 +0000 Subject: [PATCH 13/40] [GPCAPIM-275]: Run request handling all in one try-except as exceptions will/should bubble up under the same Error class --- gateway-api/src/gateway_api/app.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index dc7d748a..ed4f63f6 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -38,19 +38,15 @@ def get_app_port() -> int: def get_structured_record() -> Response: try: get_structured_record_request = GetStructuredRecordRequest(request) + controller = Controller() + flask_response = controller.run(request=get_structured_record_request) + get_structured_record_request.set_response_from_flaskresponse(flask_response) except Error as error: return error.build_response() except Exception: response = CDGAPIErrors.GENERIC_ERROR.build_response() return response - try: - controller = Controller() - flask_response = controller.run(request=get_structured_record_request) - get_structured_record_request.set_response_from_flaskresponse(flask_response) - except Exception as e: - get_structured_record_request.set_negative_response(str(e)) - return get_structured_record_request.build_response() From 5e55b85858cc78ce69e8bc48ea11214e4f3d9a47 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:35:45 +0000 Subject: [PATCH 14/40] [GPCAPIM-275]: Run request handling all in one try-except as exceptions will/should bubble up under the same Error class --- gateway-api/src/gateway_api/app.py | 9 ++++++--- gateway-api/src/gateway_api/common/error.py | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index ed4f63f6..350a5ba0 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -41,10 +41,13 @@ def get_structured_record() -> Response: controller = Controller() flask_response = controller.run(request=get_structured_record_request) get_structured_record_request.set_response_from_flaskresponse(flask_response) - except Error as error: - return error.build_response() + except Error as e: + e.log() + return e.build_response() except Exception: - response = CDGAPIErrors.GENERIC_ERROR.build_response() + error = CDGAPIErrors.GENERIC_ERROR + error.log() + response = error.build_response() return response return get_structured_record_request.build_response() diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 548a1b58..597b62cb 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -34,6 +34,9 @@ def build_response(self) -> Response: ) return response + def log(self) -> None: + print(self) + class CDGAPIErrors: GENERIC_ERROR = Error("Internal Server Error") From 5d924a8edd5f46393571b8e9efd23c91ff2b0ba7 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:05:13 +0000 Subject: [PATCH 15/40] [GPCAPIM-275]: Enable additional details to be passed to errors. --- gateway-api/src/gateway_api/app.py | 6 +-- gateway-api/src/gateway_api/common/error.py | 43 +++++++++++------- gateway-api/src/gateway_api/controller.py | 7 ++- .../get_structured_record/request.py | 8 ++-- .../get_structured_record/test_request.py | 14 +++--- .../src/gateway_api/test_controller.py | 45 +++++-------------- 6 files changed, 56 insertions(+), 67 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 350a5ba0..c666627e 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -4,7 +4,7 @@ from flask import Flask, request from flask.wrappers import Response -from gateway_api.common.error import CDGAPIErrors, Error +from gateway_api.common.error import BaseError from gateway_api.controller import Controller from gateway_api.get_structured_record import ( GetStructuredRecordRequest, @@ -41,11 +41,11 @@ def get_structured_record() -> Response: controller = Controller() flask_response = controller.run(request=get_structured_record_request) get_structured_record_request.set_response_from_flaskresponse(flask_response) - except Error as e: + except BaseError as e: e.log() return e.build_response() except Exception: - error = CDGAPIErrors.GENERIC_ERROR + error = BaseError() error.log() response = error.build_response() return response diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 597b62cb..2407e2f8 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -1,6 +1,6 @@ import json from dataclasses import dataclass -from http.client import BAD_REQUEST +from http.client import INTERNAL_SERVER_ERROR from typing import TYPE_CHECKING from flask import Response @@ -10,11 +10,14 @@ @dataclass -class Error(Exception): - message: str - status_code: int = 500 +class BaseError(Exception): + _message = "Internal Server Error" + status_code: int = INTERNAL_SERVER_ERROR severity: str = "error" - fhir_error_code: str = "exception" + error_code: str = "exception" + + def __init__(self, **additional_details: str): + self.additional_details = additional_details def build_response(self) -> Response: operation_outcome: OperationOutcome = { @@ -22,7 +25,7 @@ def build_response(self) -> Response: "issue": [ { "severity": self.severity, - "code": self.fhir_error_code, + "code": self.error_code, "diagnostics": self.message, } ], @@ -37,18 +40,24 @@ def build_response(self) -> Response: def log(self) -> None: print(self) + @property + def message(self) -> str: + return self._message.format(**self.additional_details) + + def __str__(self) -> str: + return self.message + + +class NoPatientFound(BaseError): + _message = "No PDS patient found for NHS number {nhs_number}" + status_code = 400 -class CDGAPIErrors: - GENERIC_ERROR = Error("Internal Server Error") - INVALID_REQUEST_JSON = Error( - "Invalid JSON body sent in request", status_code=BAD_REQUEST - ) +class InvalidRequestJSON(BaseError): + _message = "Invalid JSON body sent in request" + status_code = 400 - MISSING_TRACE_ID = Error( - 'Missing or empty required header "Ssp-TraceID"', status_code=BAD_REQUEST - ) - MISSING_ODS_CODE = Error( - 'Missing or empty required header "ODS-from"', status_code=BAD_REQUEST - ) +class MissingOrEmptyHeader(BaseError): + _message = 'Missing or empty required header "{header}"' + status_code = 400 diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 4a17d08c..43d2db1c 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -7,6 +7,7 @@ import json from typing import TYPE_CHECKING +from gateway_api.common.error import NoPatientFound from gateway_api.provider_request import GpProviderClient if TYPE_CHECKING: @@ -224,10 +225,8 @@ def _get_pds_details( ) if pds_result is None: - raise RequestError( - status_code=404, - message=f"No PDS patient found for NHS number {nhs_number}", - ) + error = NoPatientFound(nhs_number=nhs_number) + raise error if pds_result.gp_ods_code: provider_ods_code = pds_result.gp_ods_code diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index a9a1b4e1..ffd8a302 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -7,7 +7,7 @@ from werkzeug.exceptions import BadRequest from gateway_api.common.common import FlaskResponse -from gateway_api.common.error import CDGAPIErrors +from gateway_api.common.error import InvalidRequestJSON, MissingOrEmptyHeader if TYPE_CHECKING: from fhir.bundle import Bundle @@ -28,7 +28,7 @@ def __init__(self, request: Request) -> None: try: self._request_body: Parameters = request.get_json() except BadRequest as error: - raise CDGAPIErrors.INVALID_REQUEST_JSON from error + raise InvalidRequestJSON() from error self._response_body: Bundle | OperationOutcome | None = None self._status_code: int | None = None @@ -61,11 +61,11 @@ def _validate_headers(self) -> None: """ trace_id = self._headers.get("Ssp-TraceID", "").strip() if not trace_id: - raise CDGAPIErrors.MISSING_TRACE_ID + raise MissingOrEmptyHeader(header="Ssp-TraceID") ods_from = self._headers.get("ODS-from", "").strip() if not ods_from: - raise CDGAPIErrors.MISSING_ODS_CODE + raise MissingOrEmptyHeader(header="ODS-from") def build_response(self) -> Response: return Response( diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py index 944c1628..c6565953 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -7,7 +7,7 @@ from werkzeug.test import EnvironBuilder from gateway_api.common.common import FlaskResponse -from gateway_api.common.error import Error +from gateway_api.common.error import BaseError from gateway_api.get_structured_record.request import GetStructuredRecordRequest if TYPE_CHECKING: @@ -79,7 +79,9 @@ def test_raises_value_error_when_ods_from_header_is_missing( } mock_request = create_mock_request(headers, valid_simple_request_payload) - with pytest.raises(Error, match='Missing or empty required header "ODS-from"'): + with pytest.raises( + BaseError, match='Missing or empty required header "ODS-from"' + ): GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_ods_from_header_is_whitespace( @@ -94,7 +96,9 @@ def test_raises_value_error_when_ods_from_header_is_whitespace( } mock_request = create_mock_request(headers, valid_simple_request_payload) - with pytest.raises(Error, match='Missing or empty required header "ODS-from"'): + with pytest.raises( + BaseError, match='Missing or empty required header "ODS-from"' + ): GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_trace_id_header_is_missing( @@ -107,7 +111,7 @@ def test_raises_value_error_when_trace_id_header_is_missing( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - Error, + BaseError, match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) @@ -125,7 +129,7 @@ def test_raises_value_error_when_trace_id_header_is_whitespace( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - Error, + BaseError, match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 3fc3ded4..568ec656 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -14,6 +14,7 @@ import gateway_api.controller as controller_module from gateway_api.app import app +from gateway_api.common.error import BaseError from gateway_api.controller import ( Controller, SdsSearchResults, @@ -345,7 +346,7 @@ def test_call_gp_provider_returns_200_on_success( [({}, {})], indirect=["get_structured_record_request"], ) -def test_call_gp_provider_returns_404_when_pds_patient_not_found( +def test_controller_run_raises_error_when_request_body_is_empty( patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) controller: Controller, get_structured_record_request: GetStructuredRecordRequest, @@ -353,11 +354,10 @@ def test_call_gp_provider_returns_404_when_pds_patient_not_found( """ If PDS returns no patient record, the controller should return 404. """ - # FakePdsClient defaults to returning None => RequestError => 404 - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "No PDS patient found for NHS number" in (r.data or "") + with pytest.raises( + BaseError, match="No PDS patient found for NHS number 9999999999" + ): + _ = controller.run(get_structured_record_request) @pytest.mark.parametrize( @@ -479,35 +479,12 @@ def test_call_gp_provider_returns_502_when_gp_provider_returns_none( assert r.headers is None -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_constructs_pds_client_with_expected_kwargs( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - Validate that the controller constructs the PDS client with expected kwargs. - """ - _ = controller.run(get_structured_record_request) # will stop at PDS None => 404 - - assert FakePdsClient.last_init is not None - assert FakePdsClient.last_init["auth_token"] == "PLACEHOLDER_AUTH_TOKEN" # noqa: S105 - assert FakePdsClient.last_init["end_user_org_ods"] == "CONSUMER" - assert FakePdsClient.last_init["base_url"] == "https://pds.example" - assert FakePdsClient.last_init["nhsd_session_urid"] == "session-123" - assert FakePdsClient.last_init["timeout"] == 3 - - @pytest.mark.parametrize( "get_structured_record_request", [({}, {"parameter": [{"valueIdentifier": {"value": "1234567890"}}]})], indirect=["get_structured_record_request"], ) -def test_call_gp_provider_404_message_includes_nhs_number_from_request_body( +def test_controller_run_raises_patient_not_found_error_when_patient_doesnt_exist( patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) controller: Controller, get_structured_record_request: GetStructuredRecordRequest, @@ -516,10 +493,10 @@ def test_call_gp_provider_404_message_includes_nhs_number_from_request_body( If PDS returns no patient record, error message should include NHS number parsed from the FHIR Parameters request body. """ - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert r.data == "No PDS patient found for NHS number 1234567890" + with pytest.raises( + BaseError, match="No PDS patient found for NHS number 1234567890" + ): + _ = controller.run(get_structured_record_request) @pytest.mark.parametrize( From e5fecaaebcdb5097710162cc474b895291f3b81a Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:30:05 +0000 Subject: [PATCH 16/40] [GPCAPIM-275]: We do not send the ODS code to PDS as an End User Org --- gateway-api/src/gateway_api/controller.py | 10 ++-------- gateway-api/src/gateway_api/pds_search.py | 5 ----- gateway-api/src/gateway_api/test_pds_search.py | 8 -------- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 43d2db1c..2e626ba5 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -152,9 +152,7 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: auth_token = self.get_auth_token() try: - provider_ods = self._get_pds_details( - auth_token, request.ods_from.strip(), request.nhs_number - ) + provider_ods = self._get_pds_details(auth_token, request.nhs_number) except RequestError as err: return FlaskResponse(status_code=err.status_code, data=str(err)) @@ -198,14 +196,11 @@ def get_auth_token(self) -> str: # Placeholder implementation return "PLACEHOLDER_AUTH_TOKEN" - def _get_pds_details( - self, auth_token: str, consumer_ods: str, nhs_number: str - ) -> str: + def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: """ Call PDS to find the provider ODS code (GP ODS code) for a patient. :param auth_token: Authorization token to use for PDS. - :param consumer_ods: Consumer organisation ODS code (from request headers). :param nhs_number: NHS number :returns: Provider ODS code (GP ODS code). :raises RequestError: If the patient cannot be found or has no provider ODS code @@ -213,7 +208,6 @@ def _get_pds_details( # PDS: find patient and extract GP ODS code (provider ODS) pds = PdsClient( auth_token=auth_token, - end_user_org_ods=consumer_ods, base_url=self.pds_base_url, nhsd_session_urid=self.nhsd_session_urid, timeout=self.timeout, diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index b21b6ecf..7cd3b6fd 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds_search.py @@ -86,7 +86,6 @@ class PdsClient: pds = PdsClient( auth_token="YOUR_ACCESS_TOKEN", - end_user_org_ods="A12345", base_url="https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4", ) @@ -104,7 +103,6 @@ class PdsClient: def __init__( self, auth_token: str, - end_user_org_ods: str, base_url: str = SANDBOX_URL, nhsd_session_urid: str | None = None, timeout: int = 10, @@ -114,7 +112,6 @@ def __init__( Create a PDS client. :param auth_token: OAuth2 bearer token (without the ``"Bearer "`` prefix). - :param end_user_org_ods: NHSD End User Organisation ODS code. :param base_url: Base URL for the PDS API (one of :attr:`SANDBOX_URL`, :attr:`INT_URL`, :attr:`PROD_URL`). Trailing slashes are stripped. :param nhsd_session_urid: Optional ``NHSD-Session-URID`` header value. @@ -123,7 +120,6 @@ def __init__( ignoring the date ranges. """ self.auth_token = auth_token - self.end_user_org_ods = end_user_org_ods self.base_url = base_url.rstrip("/") self.nhsd_session_urid = nhsd_session_urid self.timeout = timeout @@ -151,7 +147,6 @@ def _build_headers( """ headers = { "X-Request-ID": request_id or str(uuid.uuid4()), - "NHSD-End-User-Organisation-ODS": self.end_user_org_ods, "Accept": "application/fhir+json", } diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/test_pds_search.py index a433c9a1..b83f4d2a 100644 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ b/gateway-api/src/gateway_api/test_pds_search.py @@ -193,7 +193,6 @@ def test_search_patient_by_nhs_number_get_patient_success( client = PdsClient( auth_token="test-token", # noqa: S106 (test token hardcoded) - end_user_org_ods="A12345", base_url="https://example.test/personal-demographics/FHIR/R4", nhsd_session_urid="test-urid", ) @@ -246,7 +245,6 @@ def test_search_patient_by_nhs_number_no_current_gp_returns_gp_ods_code_none( client = PdsClient( auth_token="test-token", # noqa: S106 (test token hardcoded) - end_user_org_ods="A12345", base_url="https://example.test/personal-demographics/FHIR/R4", ) @@ -287,7 +285,6 @@ def test_search_patient_by_nhs_number_sends_expected_headers( client = PdsClient( auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", base_url="https://example.test/personal-demographics/FHIR/R4", ) @@ -303,7 +300,6 @@ def test_search_patient_by_nhs_number_sends_expected_headers( headers = mock_requests_get["headers"] assert headers["Authorization"] == "Bearer test-token" - assert headers["NHSD-End-User-Organisation-ODS"] == "A12345" assert headers["Accept"] == "application/fhir+json" assert headers["X-Request-ID"] == req_id assert headers["X-Correlation-ID"] == corr_id @@ -334,7 +330,6 @@ def test_search_patient_by_nhs_number_generates_request_id( client = PdsClient( auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", base_url="https://example.test/personal-demographics/FHIR/R4", ) @@ -363,7 +358,6 @@ def test_search_patient_by_nhs_number_not_found_raises_error( """ pds = PdsClient( auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", base_url="https://example.test/personal-demographics/FHIR/R4", ) @@ -425,7 +419,6 @@ def test_search_patient_by_nhs_number_extracts_current_gp_ods_code( client = PdsClient( auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", base_url="https://example.test/personal-demographics/FHIR/R4", ) @@ -516,7 +509,6 @@ def test_extract_single_search_result_invalid_body_raises_runtime_error() -> Non """ client = PdsClient( auth_token="test-token", # noqa: S106 (test token hardcoded) - end_user_org_ods="A12345", base_url="https://example.test/personal-demographics/FHIR/R4", ) From 8354dafcde342f9bd6e38a9b9ab071b891805464 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:42:01 +0000 Subject: [PATCH 17/40] [GPCAPIM-275]: Given we are accessing PDS as an application, we do not need to handle NHS Session URID --- gateway-api/src/gateway_api/controller.py | 4 ---- gateway-api/src/gateway_api/pds_search.py | 7 ------- gateway-api/src/gateway_api/test_controller.py | 1 - gateway-api/src/gateway_api/test_pds_search.py | 1 - gateway-api/stubs/stubs/stub_pds.py | 2 -- 5 files changed, 15 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 2e626ba5..f30916ad 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -115,7 +115,6 @@ def __init__( self, pds_base_url: str = PdsClient.SANDBOX_URL, sds_base_url: str = "https://example.invalid/sds", - nhsd_session_urid: str | None = None, timeout: int = 10, ) -> None: """ @@ -123,12 +122,10 @@ def __init__( :param pds_base_url: Base URL for PDS client. :param sds_base_url: Base URL for SDS client. - :param nhsd_session_urid: Session URID for NHS Digital session handling. :param timeout: Timeout in seconds for downstream calls. """ self.pds_base_url = pds_base_url self.sds_base_url = sds_base_url - self.nhsd_session_urid = nhsd_session_urid self.timeout = timeout self.gp_provider_client = None @@ -209,7 +206,6 @@ def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: pds = PdsClient( auth_token=auth_token, base_url=self.pds_base_url, - nhsd_session_urid=self.nhsd_session_urid, timeout=self.timeout, ignore_dates=True, ) diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index 7cd3b6fd..757c743f 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds_search.py @@ -104,7 +104,6 @@ def __init__( self, auth_token: str, base_url: str = SANDBOX_URL, - nhsd_session_urid: str | None = None, timeout: int = 10, ignore_dates: bool = False, ) -> None: @@ -114,14 +113,12 @@ def __init__( :param auth_token: OAuth2 bearer token (without the ``"Bearer "`` prefix). :param base_url: Base URL for the PDS API (one of :attr:`SANDBOX_URL`, :attr:`INT_URL`, :attr:`PROD_URL`). Trailing slashes are stripped. - :param nhsd_session_urid: Optional ``NHSD-Session-URID`` header value. :param timeout: Default timeout in seconds for HTTP calls. :param ignore_dates: If ``True`` just get the most recent name or GP record, ignoring the date ranges. """ self.auth_token = auth_token self.base_url = base_url.rstrip("/") - self.nhsd_session_urid = nhsd_session_urid self.timeout = timeout self.ignore_dates = ignore_dates self.stub = PdsFhirApiStub() @@ -154,10 +151,6 @@ def _build_headers( if self.base_url != self.SANDBOX_URL: headers["Authorization"] = f"Bearer {self.auth_token}" - # NHSD-Session-URID is required in some flows; include only if configured. - if self.nhsd_session_urid: - headers["NHSD-Session-URID"] = self.nhsd_session_urid - # Correlation ID is used to track the same request across multiple systems. # Can be safely omitted, mirrored back in response if included. if correlation_id: diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 568ec656..0b5ae244 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -224,7 +224,6 @@ def controller() -> Controller: return Controller( pds_base_url="https://pds.example", sds_base_url="https://sds.example", - nhsd_session_urid="session-123", timeout=3, ) diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/test_pds_search.py index b83f4d2a..c969e091 100644 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ b/gateway-api/src/gateway_api/test_pds_search.py @@ -194,7 +194,6 @@ def test_search_patient_by_nhs_number_get_patient_success( client = PdsClient( auth_token="test-token", # noqa: S106 (test token hardcoded) base_url="https://example.test/personal-demographics/FHIR/R4", - nhsd_session_urid="test-urid", ) result = client.search_patient_by_nhs_number("9000000009") diff --git a/gateway-api/stubs/stubs/stub_pds.py b/gateway-api/stubs/stubs/stub_pds.py index f8249295..2081f896 100644 --- a/gateway-api/stubs/stubs/stub_pds.py +++ b/gateway-api/stubs/stubs/stub_pds.py @@ -275,8 +275,6 @@ def get( request_id = headers.get("X-Request-ID") correlation_id = headers.get("X-Correlation-ID") authorization = headers.get("Authorization") - role_id = headers.get("NHSD-Session-URID") - end_user_org_ods = headers.get("NHSD-End-User-Organisation-ODS") return self.get_patient( nhs_number=nhs_number, From 36d9812a760c8c272d749d7e78ce537e90bdfa82 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:50:56 +0000 Subject: [PATCH 18/40] [GPCAPIM-275]: PDS sandbox can receive auth header. --- gateway-api/src/gateway_api/controller.py | 3 +-- gateway-api/src/gateway_api/pds_search.py | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index f30916ad..5a93e342 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -190,8 +190,7 @@ def get_auth_token(self) -> str: :returns: Authorization token as a string. """ - # Placeholder implementation - return "PLACEHOLDER_AUTH_TOKEN" + return "AUTH_TOKEN123" def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: """ diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index 757c743f..230a8309 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds_search.py @@ -145,12 +145,9 @@ def _build_headers( headers = { "X-Request-ID": request_id or str(uuid.uuid4()), "Accept": "application/fhir+json", + "Authorization": f"Bearer {self.auth_token}", } - # Trying to pass an auth token to the sandbox makes PDS unhappy - if self.base_url != self.SANDBOX_URL: - headers["Authorization"] = f"Bearer {self.auth_token}" - # Correlation ID is used to track the same request across multiple systems. # Can be safely omitted, mirrored back in response if included. if correlation_id: From ddd5b948869ea23438e5a8e05cf16a4e1c60ad63 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:28:27 +0000 Subject: [PATCH 19/40] [GPCAPIM-275]: Move towards common error class --- gateway-api/src/gateway_api/app.py | 3 +- gateway-api/src/gateway_api/common/error.py | 26 +++++++ gateway-api/src/gateway_api/controller.py | 64 +++++------------ .../src/gateway_api/test_controller.py | 72 ++++++++++++------- 4 files changed, 89 insertions(+), 76 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index c666627e..881c32fa 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -47,8 +47,7 @@ def get_structured_record() -> Response: except Exception: error = BaseError() error.log() - response = error.build_response() - return response + return error.build_response() return get_structured_record_request.build_response() diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 2407e2f8..d452882b 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -18,6 +18,7 @@ class BaseError(Exception): def __init__(self, **additional_details: str): self.additional_details = additional_details + super().__init__(self) def build_response(self) -> Response: operation_outcome: OperationOutcome = { @@ -61,3 +62,28 @@ class InvalidRequestJSON(BaseError): class MissingOrEmptyHeader(BaseError): _message = 'Missing or empty required header "{header}"' status_code = 400 + + +class NoCurrentProvider(BaseError): + _message = "PDS patient {nhs_number} did not contain a current provider ODS code" + status_code = 404 + + +class NoOrganisationFound(BaseError): + _message = "No SDS org found for {org_type} ODS code {ods_code}" + status_code = 404 + + +class NoAsidFound(BaseError): + _message = ( + "SDS result for {org_type} ODS code {ods_code} did not contain a current ASID" + ) + status_code = 404 + + +class NoCurrentEndpoint(BaseError): + _message = ( + "SDS result for provider ODS code {provider_ods} did not contain " + "a current endpoint" + ) + status_code = 404 diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 5a93e342..a8a31c15 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -7,7 +7,13 @@ import json from typing import TYPE_CHECKING -from gateway_api.common.error import NoPatientFound +from gateway_api.common.error import ( + NoAsidFound, + NoCurrentEndpoint, + NoCurrentProvider, + NoOrganisationFound, + NoPatientFound, +) from gateway_api.provider_request import GpProviderClient if TYPE_CHECKING: @@ -148,17 +154,11 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: """ auth_token = self.get_auth_token() - try: - provider_ods = self._get_pds_details(auth_token, request.nhs_number) - except RequestError as err: - return FlaskResponse(status_code=err.status_code, data=str(err)) + provider_ods = self._get_pds_details(auth_token, request.nhs_number) - try: - consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( - auth_token, request.ods_from.strip(), provider_ods - ) - except RequestError as err: - return FlaskResponse(status_code=err.status_code, data=str(err)) + consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( + auth_token, request.ods_from.strip(), provider_ods + ) # Call GP provider with correct parameters self.gp_provider_client = GpProviderClient( @@ -220,13 +220,7 @@ def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: if pds_result.gp_ods_code: provider_ods_code = pds_result.gp_ods_code else: - raise RequestError( - status_code=404, - message=( - f"PDS patient {nhs_number} did not contain a current " - "provider ODS code" - ), - ) + raise NoCurrentProvider(nhs_number=nhs_number) return provider_ods_code @@ -255,47 +249,23 @@ def _get_sds_details( provider_details: SdsSearchResults | None = sds.get_org_details(provider_ods) if provider_details is None: - raise RequestError( - status_code=404, - message=f"No SDS org found for provider ODS code {provider_ods}", - ) + raise NoOrganisationFound(org_type="provider", ods_code=provider_ods) provider_asid = (provider_details.asid or "").strip() if not provider_asid: - raise RequestError( - status_code=404, - message=( - f"SDS result for provider ODS code {provider_ods} did not contain " - "a current ASID" - ), - ) + raise NoAsidFound(org_type="provider", ods_code=provider_ods) provider_endpoint = (provider_details.endpoint or "").strip() if not provider_endpoint: - raise RequestError( - status_code=404, - message=( - f"SDS result for provider ODS code {provider_ods} did not contain " - "a current endpoint" - ), - ) + raise NoCurrentEndpoint(provider_ods=provider_ods) # SDS: Get consumer details (ASID) for consumer ODS consumer_details: SdsSearchResults | None = sds.get_org_details(consumer_ods) if consumer_details is None: - raise RequestError( - status_code=404, - message=f"No SDS org found for consumer ODS code {consumer_ods}", - ) + raise NoOrganisationFound(org_type="consumer", ods_code=consumer_ods) consumer_asid = (consumer_details.asid or "").strip() if not consumer_asid: - raise RequestError( - status_code=404, - message=( - f"SDS result for consumer ODS code {consumer_ods} did not contain " - "a current ASID" - ), - ) + raise NoAsidFound(org_type="consumer", ods_code=consumer_ods) return consumer_asid, provider_asid, provider_endpoint diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 0b5ae244..200ef420 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -14,7 +14,13 @@ import gateway_api.controller as controller_module from gateway_api.app import app -from gateway_api.common.error import BaseError +from gateway_api.common.error import ( + NoAsidFound, + NoCurrentEndpoint, + NoCurrentProvider, + NoOrganisationFound, + NoPatientFound, +) from gateway_api.controller import ( Controller, SdsSearchResults, @@ -354,7 +360,7 @@ def test_controller_run_raises_error_when_request_body_is_empty( If PDS returns no patient record, the controller should return 404. """ with pytest.raises( - BaseError, match="No PDS patient found for NHS number 9999999999" + NoPatientFound, match="No PDS patient found for NHS number 9999999999" ): _ = controller.run(get_structured_record_request) @@ -376,10 +382,11 @@ def test_call_gp_provider_returns_404_when_gp_ods_code_missing( pds = pds_factory(ods_code="") monkeypatch.setattr(controller_module, "PdsClient", pds) - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "did not contain a current provider ODS code" in (r.data or "") + with pytest.raises( + NoCurrentProvider, + match="PDS patient 9999999999 did not contain a current provider ODS code", + ): + _ = controller.run(get_structured_record_request) @pytest.mark.parametrize( @@ -402,10 +409,11 @@ def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert r.data == "No SDS org found for provider ODS code PROVIDER" + with pytest.raises( + NoOrganisationFound, + match="No SDS org found for provider ODS code PROVIDER", + ): + _ = controller.run(get_structured_record_request) @pytest.mark.parametrize( @@ -434,10 +442,13 @@ def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "did not contain a current ASID" in (r.data or "") + with pytest.raises( + NoAsidFound, + match=( + "SDS result for provider ODS code PROVIDER did not contain a current ASID" + ), + ): + _ = controller.run(get_structured_record_request) @pytest.mark.parametrize( @@ -493,7 +504,7 @@ def test_controller_run_raises_patient_not_found_error_when_patient_doesnt_exist from the FHIR Parameters request body. """ with pytest.raises( - BaseError, match="No PDS patient found for NHS number 1234567890" + NoPatientFound, match="No PDS patient found for NHS number 1234567890" ): _ = controller.run(get_structured_record_request) @@ -522,10 +533,14 @@ def test_call_gp_provider_returns_404_when_sds_provider_endpoint_blank( monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "did not contain a current endpoint" in (r.data or "") + with pytest.raises( + NoCurrentEndpoint, + match=( + "SDS result for provider ODS code PROVIDER did not contain " + "a current endpoint" + ), + ): + _ = controller.run(get_structured_record_request) @pytest.mark.parametrize( @@ -554,10 +569,10 @@ def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert r.data == "No SDS org found for consumer ODS code CONSUMER" + with pytest.raises( + NoOrganisationFound, match="No SDS org found for consumer ODS code CONSUMER" + ): + _ = controller.run(get_structured_record_request) @pytest.mark.parametrize( @@ -590,10 +605,13 @@ def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "did not contain a current ASID" in (r.data or "") + with pytest.raises( + NoAsidFound, + match=( + "SDS result for consumer ODS code CONSUMER did not contain a current ASID" + ), + ): + _ = controller.run(get_structured_record_request) @pytest.mark.parametrize( From 750e4d4b1dbbd2770c55bc2115179b2ae1f123ff Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:11:26 +0000 Subject: [PATCH 20/40] [GPCAPIM-275]: Use http client's status code definitions for clarity --- gateway-api/src/gateway_api/common/error.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index d452882b..7f733db1 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -1,6 +1,6 @@ import json from dataclasses import dataclass -from http.client import INTERNAL_SERVER_ERROR +from http.client import BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND from typing import TYPE_CHECKING from flask import Response @@ -51,34 +51,34 @@ def __str__(self) -> str: class NoPatientFound(BaseError): _message = "No PDS patient found for NHS number {nhs_number}" - status_code = 400 + status_code = BAD_REQUEST class InvalidRequestJSON(BaseError): _message = "Invalid JSON body sent in request" - status_code = 400 + status_code = BAD_REQUEST class MissingOrEmptyHeader(BaseError): _message = 'Missing or empty required header "{header}"' - status_code = 400 + status_code = BAD_REQUEST class NoCurrentProvider(BaseError): _message = "PDS patient {nhs_number} did not contain a current provider ODS code" - status_code = 404 + status_code = NOT_FOUND class NoOrganisationFound(BaseError): _message = "No SDS org found for {org_type} ODS code {ods_code}" - status_code = 404 + status_code = NOT_FOUND class NoAsidFound(BaseError): _message = ( "SDS result for {org_type} ODS code {ods_code} did not contain a current ASID" ) - status_code = 404 + status_code = NOT_FOUND class NoCurrentEndpoint(BaseError): @@ -86,4 +86,4 @@ class NoCurrentEndpoint(BaseError): "SDS result for provider ODS code {provider_ods} did not contain " "a current endpoint" ) - status_code = 404 + status_code = NOT_FOUND From 7aa9fa24ebc38e2b442d09c8b5796ad2024d4f5f Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:24:58 +0000 Subject: [PATCH 21/40] [GPCAPIM-275]: Move towards common error class --- gateway-api/src/gateway_api/common/error.py | 10 +++++++ gateway-api/src/gateway_api/controller.py | 26 ------------------- .../get_structured_record/__init__.py | 7 ++--- .../get_structured_record/request.py | 9 +------ gateway-api/src/gateway_api/pds_search.py | 19 +++----------- .../src/gateway_api/provider_request.py | 15 +++-------- .../src/gateway_api/test_pds_search.py | 10 ++++--- .../src/gateway_api/test_provider_request.py | 9 ++++--- 8 files changed, 30 insertions(+), 75 deletions(-) diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 7f733db1..8e8133ef 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -87,3 +87,13 @@ class NoCurrentEndpoint(BaseError): "a current endpoint" ) status_code = NOT_FOUND + + +class PdsRequestFailed(BaseError): + _message = "PDS FHIR API request failed: {error_reason}" + status_code = INTERNAL_SERVER_ERROR + + +class SdsRequestFailed(BaseError): + _message = "SDS FHIR API request failed: {error_reason}" + status_code = INTERNAL_SERVER_ERROR diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index a8a31c15..f485295c 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -27,30 +27,6 @@ from gateway_api.pds_search import PdsClient, PdsSearchResults -@dataclass -class RequestError(Exception): - """ - Raised (and handled) when there is a problem with the incoming request. - - Instances of this exception are caught by controller entry points and converted - into an appropriate :class:`FlaskResponse`. - - :param status_code: HTTP status code that should be returned. - :param message: Human-readable error message. - """ - - status_code: int - message: str - - def __str__(self) -> str: - """ - Coercing this exception to a string returns the error message. - - :returns: The error message. - """ - return self.message - - @dataclass class SdsSearchResults: """ @@ -199,7 +175,6 @@ def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: :param auth_token: Authorization token to use for PDS. :param nhs_number: NHS number :returns: Provider ODS code (GP ODS code). - :raises RequestError: If the patient cannot be found or has no provider ODS code """ # PDS: find patient and extract GP ODS code (provider ODS) pds = PdsClient( @@ -238,7 +213,6 @@ def _get_sds_details( :param consumer_ods: Consumer organisation ODS code (from request headers). :param provider_ods: Provider organisation ODS code (from PDS). :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint). - :raises RequestError: If SDS data is missing or incomplete for provider/consumer """ # SDS: Get provider details (ASID + endpoint) for provider ODS sds = SdsClient( diff --git a/gateway-api/src/gateway_api/get_structured_record/__init__.py b/gateway-api/src/gateway_api/get_structured_record/__init__.py index 56dd174d..456f2a4e 100644 --- a/gateway-api/src/gateway_api/get_structured_record/__init__.py +++ b/gateway-api/src/gateway_api/get_structured_record/__init__.py @@ -1,8 +1,5 @@ """Get Structured Record module.""" -from gateway_api.get_structured_record.request import ( - GetStructuredRecordRequest, - RequestValidationError, -) +from gateway_api.get_structured_record.request import GetStructuredRecordRequest -__all__ = ["RequestValidationError", "GetStructuredRecordRequest"] +__all__ = ["GetStructuredRecordRequest"] diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index ffd8a302..8b7edb64 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -13,10 +13,6 @@ from fhir.bundle import Bundle -class RequestValidationError(Exception): - """Exception raised for errors in the request validation.""" - - class GetStructuredRecordRequest: INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" RESOURCE: str = "patient" @@ -55,10 +51,7 @@ def request_body(self) -> str: return json.dumps(self._request_body) def _validate_headers(self) -> None: - """Validate required headers are present and non-empty. - - :raises RequestValidationError: If required headers are missing or empty. - """ + """Validate required headers are present and non-empty.""" trace_id = self._headers.get("Ssp-TraceID", "").strip() if not trace_id: raise MissingOrEmptyHeader(header="Ssp-TraceID") diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index 230a8309..1f0b60ed 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds_search.py @@ -29,6 +29,8 @@ import requests from stubs.stub_pds import PdsFhirApiStub +from gateway_api.common.error import PdsRequestFailed + # Recursive JSON-like structure typing used for parsed FHIR bodies. type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] type ResultStructureDict = dict[str, ResultStructure] @@ -38,16 +40,6 @@ type GetCallable = Callable[..., requests.Response] -class ExternalServiceError(Exception): - """ - Raised when the downstream PDS request fails. - - This module catches :class:`requests.HTTPError` thrown by - ``response.raise_for_status()`` and re-raises it as ``ExternalServiceError`` so - callers are not coupled to ``requests`` exception types. - """ - - @dataclass class PdsSearchResults: """ @@ -176,8 +168,6 @@ def search_patient_by_nhs_number( :attr:`timeout` is used. :return: A :class:`PdsSearchResults` instance if a patient can be extracted, otherwise ``None``. - :raises ExternalServiceError: If the HTTP request returns an error status and - ``raise_for_status()`` raises :class:`requests.HTTPError`. """ headers = self._build_headers( request_id=request_id, @@ -195,12 +185,9 @@ def search_patient_by_nhs_number( ) try: - # In production, failures surface here (4xx/5xx -> HTTPError). response.raise_for_status() except requests.HTTPError as err: - raise ExternalServiceError( - f"PDS request failed: {err.response.reason}" - ) from err + raise PdsRequestFailed(error_reason=err.response.reason) from err body = response.json() return self._extract_single_search_result(body) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index a628dbcf..c0e3563a 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -28,6 +28,8 @@ from requests import HTTPError, Response, post from stubs.stub_provider import stub_post +from gateway_api.common.error import SdsRequestFailed + ARS_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:structured" ":fhir:operation:gpc.getstructuredrecord-1" @@ -46,12 +48,6 @@ post: PostCallable = stub_post # type: ignore[no-redef] -class ExternalServiceError(Exception): - """ - Exception raised when the downstream GPProvider FHIR API request fails. - """ - - class GpProviderClient: """ A client for interacting with the GPProvider FHIR GP System. @@ -114,9 +110,6 @@ def access_structured_record( Returns: Response: The response from the GPProvider FHIR API. - - Raises: - ExternalServiceError: If the API request fails with an HTTP error. """ headers = self._build_headers(trace_id) @@ -134,8 +127,6 @@ def access_structured_record( try: response.raise_for_status() except HTTPError as err: - raise ExternalServiceError( - f"GPProvider FHIR API request failed:{err.response.reason}" - ) from err + raise SdsRequestFailed(error_reason=err.response.reason) from err return response diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/test_pds_search.py index c969e091..80268ce2 100644 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ b/gateway-api/src/gateway_api/test_pds_search.py @@ -16,8 +16,8 @@ if TYPE_CHECKING: from requests.structures import CaseInsensitiveDict +from gateway_api.common.error import PdsRequestFailed from gateway_api.pds_search import ( - ExternalServiceError, PdsClient, ResultList, ) @@ -345,11 +345,11 @@ def test_search_patient_by_nhs_number_not_found_raises_error( mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) ) -> None: """ - Verify that a 404 response results in :class:`ExternalServiceError`. + Verify that a 404 response results in :class:`PDSRequestFailed`. The stub returns a 404 OperationOutcome for unknown NHS numbers. The client calls ``raise_for_status()``, which raises ``requests.HTTPError``; the client wraps that - into :class:`ExternalServiceError`. + into :class:`PDSRequestFailed`. :param stub: Stub backend fixture. :param mock_requests_get: Patched ``requests.get`` fixture. @@ -360,7 +360,9 @@ def test_search_patient_by_nhs_number_not_found_raises_error( base_url="https://example.test/personal-demographics/FHIR/R4", ) - with pytest.raises(ExternalServiceError): + with pytest.raises( + PdsRequestFailed, match="PDS FHIR API request failed: Not Found" + ): pds.search_patient_by_nhs_number("9900000001") diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 6441490a..6af8a0aa 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -14,7 +14,8 @@ from stubs.stub_provider import GpProviderStub from gateway_api import provider_request -from gateway_api.provider_request import ExternalServiceError, GpProviderClient +from gateway_api.common.error import SdsRequestFailed +from gateway_api.provider_request import GpProviderClient ars_interactionId = ( "urn:nhs:names:services:gpconnect:structured" @@ -199,7 +200,7 @@ def test_access_structured_record_raises_external_service_error( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) ) -> None: """ - Test that the `access_structured_record` method raises an `ExternalServiceError` + Test that the `access_structured_record` method raises an `SdsRequestFailed` when the GPProvider FHIR API request fails with an HTTP error. """ provider_asid = "200000001154" @@ -214,7 +215,7 @@ def test_access_structured_record_raises_external_service_error( ) with pytest.raises( - ExternalServiceError, - match="GPProvider FHIR API request failed:Bad Request", + SdsRequestFailed, + match="SDS FHIR API request failed: Bad Request", ): client.access_structured_record(trace_id, "body") From 5af0f0a8523c17a291a5b992218f92f3bf869786 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:34:22 +0000 Subject: [PATCH 22/40] [GPCAPIM-275]: Remove unnecessary imports. --- gateway-api/src/gateway_api/controller.py | 18 ++++-------------- gateway-api/src/gateway_api/pds_search.py | 2 -- gateway-api/src/gateway_api/test_controller.py | 11 +++-------- gateway-api/src/gateway_api/test_pds_search.py | 8 ++------ gateway-api/stubs/stubs/stub_pds.py | 2 -- 5 files changed, 9 insertions(+), 32 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index f485295c..3e5f938c 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,11 +2,9 @@ Controller layer for orchestrating calls to external services """ -from __future__ import annotations - -import json -from typing import TYPE_CHECKING +from dataclasses import dataclass +from gateway_api.common.common import FlaskResponse from gateway_api.common.error import ( NoAsidFound, NoCurrentEndpoint, @@ -14,17 +12,9 @@ NoOrganisationFound, NoPatientFound, ) -from gateway_api.provider_request import GpProviderClient - -if TYPE_CHECKING: - from gateway_api.get_structured_record.request import GetStructuredRecordRequest - -__all__ = ["json"] # Make mypy happy in tests - -from dataclasses import dataclass - -from gateway_api.common.common import FlaskResponse +from gateway_api.get_structured_record.request import GetStructuredRecordRequest from gateway_api.pds_search import PdsClient, PdsSearchResults +from gateway_api.provider_request import GpProviderClient @dataclass diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index 1f0b60ed..71099afa 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds_search.py @@ -18,8 +18,6 @@ malformed upstream data (or malformed test fixtures) and should be corrected at source. """ -from __future__ import annotations - import uuid from collections.abc import Callable from dataclasses import dataclass diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 200ef420..b3a0b5fd 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -2,11 +2,10 @@ Unit tests for :mod:`gateway_api.controller`. """ -from __future__ import annotations - +from collections.abc import Generator from dataclasses import dataclass from types import SimpleNamespace -from typing import TYPE_CHECKING, Any +from typing import Any import pytest from flask import request as flask_request @@ -14,6 +13,7 @@ import gateway_api.controller as controller_module from gateway_api.app import app +from gateway_api.common.common import json_str from gateway_api.common.error import ( NoAsidFound, NoCurrentEndpoint, @@ -27,11 +27,6 @@ ) from gateway_api.get_structured_record.request import GetStructuredRecordRequest -if TYPE_CHECKING: - from collections.abc import Generator - - from gateway_api.common.common import json_str - # ----------------------------- # Fake downstream dependencies diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/test_pds_search.py index 80268ce2..e786c242 100644 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ b/gateway-api/src/gateway_api/test_pds_search.py @@ -2,20 +2,16 @@ Unit tests for :mod:`gateway_api.pds_search`. """ -from __future__ import annotations - from dataclasses import dataclass from datetime import date -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast from uuid import uuid4 import pytest import requests +from requests.structures import CaseInsensitiveDict from stubs.stub_pds import PdsFhirApiStub -if TYPE_CHECKING: - from requests.structures import CaseInsensitiveDict - from gateway_api.common.error import PdsRequestFailed from gateway_api.pds_search import ( PdsClient, diff --git a/gateway-api/stubs/stubs/stub_pds.py b/gateway-api/stubs/stubs/stub_pds.py index 2081f896..6233d7b1 100644 --- a/gateway-api/stubs/stubs/stub_pds.py +++ b/gateway-api/stubs/stubs/stub_pds.py @@ -4,8 +4,6 @@ The stub does **not** implement the full PDS API surface, nor full FHIR validation. """ -from __future__ import annotations - import json import re import uuid From 366f6d45999b1946d5ab104eb9fc50c444f84e7b Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:48:07 +0000 Subject: [PATCH 23/40] [GPCAPIM-275]: Move modules to their own directory --- gateway-api/src/gateway_api/controller.py | 63 +------------------ gateway-api/src/gateway_api/pds/__init__.py | 9 +++ .../{pds_search.py => pds/client.py} | 25 +------- .../src/gateway_api/pds/search_results.py | 26 ++++++++ .../test_client.py} | 6 +- .../src/gateway_api/provider/__init__.py | 7 +++ .../client.py} | 0 .../test_client.py} | 5 +- gateway-api/src/gateway_api/sds/__init__.py | 7 +++ gateway-api/src/gateway_api/sds/client.py | 42 +++++++++++++ .../src/gateway_api/sds/search_results.py | 16 +++++ .../src/gateway_api/test_controller.py | 6 +- 12 files changed, 118 insertions(+), 94 deletions(-) create mode 100644 gateway-api/src/gateway_api/pds/__init__.py rename gateway-api/src/gateway_api/{pds_search.py => pds/client.py} (94%) create mode 100644 gateway-api/src/gateway_api/pds/search_results.py rename gateway-api/src/gateway_api/{test_pds_search.py => pds/test_client.py} (99%) create mode 100644 gateway-api/src/gateway_api/provider/__init__.py rename gateway-api/src/gateway_api/{provider_request.py => provider/client.py} (100%) rename gateway-api/src/gateway_api/{test_provider_request.py => provider/test_client.py} (97%) create mode 100644 gateway-api/src/gateway_api/sds/__init__.py create mode 100644 gateway-api/src/gateway_api/sds/client.py create mode 100644 gateway-api/src/gateway_api/sds/search_results.py diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 3e5f938c..90e1cc67 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,8 +2,6 @@ Controller layer for orchestrating calls to external services """ -from dataclasses import dataclass - from gateway_api.common.common import FlaskResponse from gateway_api.common.error import ( NoAsidFound, @@ -13,64 +11,9 @@ NoPatientFound, ) from gateway_api.get_structured_record.request import GetStructuredRecordRequest -from gateway_api.pds_search import PdsClient, PdsSearchResults -from gateway_api.provider_request import GpProviderClient - - -@dataclass -class SdsSearchResults: - """ - Stub SDS search results dataclass. - - Replace this with the real one once it's implemented. - - :param asid: Accredited System ID. - :param endpoint: Endpoint URL associated with the organisation, if applicable. - """ - - asid: str - endpoint: str | None - - -class SdsClient: - """ - Stub SDS client for obtaining ASID from ODS code. - - Replace this with the real one once it's implemented. - """ - - SANDBOX_URL = "https://example.invalid/sds" - - def __init__( - self, - auth_token: str, - base_url: str = SANDBOX_URL, - timeout: int = 10, - ) -> None: - """ - Create an SDS client. - - :param auth_token: Authentication token to present to SDS. - :param base_url: Base URL for SDS. - :param timeout: Timeout in seconds for SDS calls. - """ - self.auth_token = auth_token - self.base_url = base_url - self.timeout = timeout - - def get_org_details(self, ods_code: str) -> SdsSearchResults | None: - """ - Retrieve SDS org details for a given ODS code. - - This is a placeholder implementation that always returns an ASID and endpoint. - - :param ods_code: ODS code to look up. - :returns: SDS search results or ``None`` if not found. - """ - # Placeholder implementation - return SdsSearchResults( - asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" - ) +from gateway_api.pds import PdsClient, PdsSearchResults +from gateway_api.provider import GpProviderClient +from gateway_api.sds import SdsClient, SdsSearchResults class Controller: diff --git a/gateway-api/src/gateway_api/pds/__init__.py b/gateway-api/src/gateway_api/pds/__init__.py new file mode 100644 index 00000000..7c687699 --- /dev/null +++ b/gateway-api/src/gateway_api/pds/__init__.py @@ -0,0 +1,9 @@ +"""PDS (Personal Demographics Service) client and data structures.""" + +from gateway_api.pds.client import PdsClient +from gateway_api.pds.search_results import PdsSearchResults + +__all__ = [ + "PdsClient", + "PdsSearchResults", +] diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds/client.py similarity index 94% rename from gateway-api/src/gateway_api/pds_search.py rename to gateway-api/src/gateway_api/pds/client.py index 71099afa..0cd70660 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -20,7 +20,6 @@ import uuid from collections.abc import Callable -from dataclasses import dataclass from datetime import date, datetime, timezone from typing import cast @@ -28,6 +27,7 @@ from stubs.stub_pds import PdsFhirApiStub from gateway_api.common.error import PdsRequestFailed +from gateway_api.pds.search_results import PdsSearchResults # Recursive JSON-like structure typing used for parsed FHIR bodies. type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] @@ -38,29 +38,6 @@ type GetCallable = Callable[..., requests.Response] -@dataclass -class PdsSearchResults: - """ - A single extracted patient record. - - Only a small subset of the PDS Patient fields are currently required by this - gateway. More will be added in later phases. - - :param given_names: Given names from the *current* ``Patient.name`` record, - concatenated with spaces. - :param family_name: Family name from the *current* ``Patient.name`` record. - :param nhs_number: NHS number (``Patient.id``). - :param gp_ods_code: The ODS code of the *current* GP, extracted from - ``Patient.generalPractitioner[].identifier.value`` if a current GP record exists - otherwise ``None``. - """ - - given_names: str - family_name: str - nhs_number: str - gp_ods_code: str | None - - class PdsClient: """ Simple client for PDS FHIR R4 patient retrieval. diff --git a/gateway-api/src/gateway_api/pds/search_results.py b/gateway-api/src/gateway_api/pds/search_results.py new file mode 100644 index 00000000..fc6e929e --- /dev/null +++ b/gateway-api/src/gateway_api/pds/search_results.py @@ -0,0 +1,26 @@ +"""PDS search result data structures.""" + +from dataclasses import dataclass + + +@dataclass +class PdsSearchResults: + """ + A single extracted patient record. + + Only a small subset of the PDS Patient fields are currently required by this + gateway. More will be added in later phases. + + :param given_names: Given names from the *current* ``Patient.name`` record, + concatenated with spaces. + :param family_name: Family name from the *current* ``Patient.name`` record. + :param nhs_number: NHS number (``Patient.id``). + :param gp_ods_code: The ODS code of the *current* GP, extracted from + ``Patient.generalPractitioner[].identifier.value`` if a current GP record exists + otherwise ``None``. + """ + + given_names: str + family_name: str + nhs_number: str + gp_ods_code: str | None diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/pds/test_client.py similarity index 99% rename from gateway-api/src/gateway_api/test_pds_search.py rename to gateway-api/src/gateway_api/pds/test_client.py index e786c242..4b94817f 100644 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -13,9 +13,9 @@ from stubs.stub_pds import PdsFhirApiStub from gateway_api.common.error import PdsRequestFailed -from gateway_api.pds_search import ( +from gateway_api.pds.client import ( PdsClient, - ResultList, + ResultList, # TODO: Use FHIR class here ) @@ -117,7 +117,7 @@ def _capturing_get( stub.get = _capturing_get # type: ignore[method-assign] # Monkeypatch PdsFhirApiStub so PdsClient uses our test stub - import gateway_api.pds_search as pds_module + import gateway_api.pds.client as pds_module monkeypatch.setattr( pds_module, diff --git a/gateway-api/src/gateway_api/provider/__init__.py b/gateway-api/src/gateway_api/provider/__init__.py new file mode 100644 index 00000000..1cc394f9 --- /dev/null +++ b/gateway-api/src/gateway_api/provider/__init__.py @@ -0,0 +1,7 @@ +"""Provider client for fetching structured patient records from GP systems.""" + +from gateway_api.provider.client import GpProviderClient + +__all__ = [ + "GpProviderClient", +] diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider/client.py similarity index 100% rename from gateway-api/src/gateway_api/provider_request.py rename to gateway-api/src/gateway_api/provider/client.py diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/provider/test_client.py similarity index 97% rename from gateway-api/src/gateway_api/test_provider_request.py rename to gateway-api/src/gateway_api/provider/test_client.py index 6af8a0aa..5b540d4d 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -13,9 +13,8 @@ from requests.structures import CaseInsensitiveDict from stubs.stub_provider import GpProviderStub -from gateway_api import provider_request from gateway_api.common.error import SdsRequestFailed -from gateway_api.provider_request import GpProviderClient +from gateway_api.provider import GpProviderClient, client ars_interactionId = ( "urn:nhs:names:services:gpconnect:structured" @@ -61,7 +60,7 @@ def _fake_post( trace_id=headers.get("Ssp-TraceID", "dummy-trace-id"), body=data ) - monkeypatch.setattr(provider_request, "post", _fake_post) + monkeypatch.setattr(client, "post", _fake_post) return capture diff --git a/gateway-api/src/gateway_api/sds/__init__.py b/gateway-api/src/gateway_api/sds/__init__.py new file mode 100644 index 00000000..8f6e5ec0 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/__init__.py @@ -0,0 +1,7 @@ +from gateway_api.sds.client import SdsClient +from gateway_api.sds.search_results import SdsSearchResults + +__all__ = [ + "SdsClient", + "SdsSearchResults", +] diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py new file mode 100644 index 00000000..a78ab923 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/client.py @@ -0,0 +1,42 @@ +from gateway_api.sds.search_results import SdsSearchResults + + +class SdsClient: + """ + Stub SDS client for obtaining ASID from ODS code. + + Replace this with the real one once it's implemented. + """ + + SANDBOX_URL = "https://example.invalid/sds" + + def __init__( + self, + auth_token: str, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ) -> None: + """ + Create an SDS client. + + :param auth_token: Authentication token to present to SDS. + :param base_url: Base URL for SDS. + :param timeout: Timeout in seconds for SDS calls. + """ + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + """ + Retrieve SDS org details for a given ODS code. + + This is a placeholder implementation that always returns an ASID and endpoint. + + :param ods_code: ODS code to look up. + :returns: SDS search results or ``None`` if not found. + """ + # Placeholder implementation + return SdsSearchResults( + asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" + ) diff --git a/gateway-api/src/gateway_api/sds/search_results.py b/gateway-api/src/gateway_api/sds/search_results.py new file mode 100644 index 00000000..ad956b89 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/search_results.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + + +@dataclass +class SdsSearchResults: + """ + Stub SDS search results dataclass. + + Replace this with the real one once it's implemented. + + :param asid: Accredited System ID. + :param endpoint: Endpoint URL associated with the organisation, if applicable. + """ + + asid: str + endpoint: str | None diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index b3a0b5fd..6be83160 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -21,11 +21,9 @@ NoOrganisationFound, NoPatientFound, ) -from gateway_api.controller import ( - Controller, - SdsSearchResults, -) +from gateway_api.controller import Controller from gateway_api.get_structured_record.request import GetStructuredRecordRequest +from gateway_api.sds import SdsSearchResults # ----------------------------- From 416958622e45fb279e1bb699e608935b25d8b3c4 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:09:09 +0000 Subject: [PATCH 24/40] [GPCAPIM-275]: Add example Patient resource - taken from PDS FHIR's sandbox --- gateway-api/src/gateway_api/pds/client.py | 2 +- gateway-api/src/gateway_api/pds/pds_search.py | 354 ++++++++++++++++ .../src/gateway_api/pds/test_client.py | 2 +- .../src/gateway_api/provider/client.py | 2 +- .../src/gateway_api/provider/test_client.py | 2 +- .../src/gateway_api/test_provider_request.py | 220 ++++++++++ gateway-api/stubs/stubs/data/__init__.py | 0 .../stubs/stubs/data/bundles/__init__.py | 20 + .../stubs/stubs/data/patients/__init__.py | 19 + .../data/patients/alice_jones_9999999999.json | 34 ++ .../data/patients/jane_smith_9000000009.json | 24 ++ gateway-api/stubs/stubs/pds/__init__.py | 0 .../stubs/stubs/{stub_pds.py => pds/stub.py} | 62 +-- gateway-api/stubs/stubs/provider/__init__.py | 0 .../{stub_provider.py => provider/stub.py} | 47 +-- .../tests/acceptance/steps/happy_path.py | 4 +- ...GatewayAPIConsumer-GatewayAPIProvider.json | 36 +- .../tests/contract/test_consumer_contract.py | 36 +- .../tests/data/patient/pds_fhir_example.json | 390 ++++++++++++++++++ .../integration/test_get_structured_record.py | 4 +- 20 files changed, 1120 insertions(+), 138 deletions(-) create mode 100644 gateway-api/src/gateway_api/pds/pds_search.py create mode 100644 gateway-api/src/gateway_api/test_provider_request.py create mode 100644 gateway-api/stubs/stubs/data/__init__.py create mode 100644 gateway-api/stubs/stubs/data/bundles/__init__.py create mode 100644 gateway-api/stubs/stubs/data/patients/__init__.py create mode 100644 gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json create mode 100644 gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json create mode 100644 gateway-api/stubs/stubs/pds/__init__.py rename gateway-api/stubs/stubs/{stub_pds.py => pds/stub.py} (84%) create mode 100644 gateway-api/stubs/stubs/provider/__init__.py rename gateway-api/stubs/stubs/{stub_provider.py => provider/stub.py} (69%) create mode 100644 gateway-api/tests/data/patient/pds_fhir_example.json diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 0cd70660..b8e22a4b 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -24,7 +24,7 @@ from typing import cast import requests -from stubs.stub_pds import PdsFhirApiStub +from stubs.pds.stub import PdsFhirApiStub from gateway_api.common.error import PdsRequestFailed from gateway_api.pds.search_results import PdsSearchResults diff --git a/gateway-api/src/gateway_api/pds/pds_search.py b/gateway-api/src/gateway_api/pds/pds_search.py new file mode 100644 index 00000000..b8e22a4b --- /dev/null +++ b/gateway-api/src/gateway_api/pds/pds_search.py @@ -0,0 +1,354 @@ +""" +PDS (Personal Demographics Service) FHIR R4 patient lookup client. + +Contracts enforced by the helper functions: + +* ``Patient.name[]`` records passed to :func:`find_current_name_record` must contain:: + + record["period"]["start"] + record["period"]["end"] + +* ``Patient.generalPractitioner[]`` records passed to :func:`find_current_record` must + contain:: + + record["identifier"]["period"]["start"] + record["identifier"]["period"]["end"] + +If required keys are missing, a ``KeyError`` is raised intentionally. This is treated as +malformed upstream data (or malformed test fixtures) and should be corrected at source. +""" + +import uuid +from collections.abc import Callable +from datetime import date, datetime, timezone +from typing import cast + +import requests +from stubs.pds.stub import PdsFhirApiStub + +from gateway_api.common.error import PdsRequestFailed +from gateway_api.pds.search_results import PdsSearchResults + +# Recursive JSON-like structure typing used for parsed FHIR bodies. +type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] +type ResultStructureDict = dict[str, ResultStructure] +type ResultList = list[ResultStructureDict] + +# Type for stub get method +type GetCallable = Callable[..., requests.Response] + + +class PdsClient: + """ + Simple client for PDS FHIR R4 patient retrieval. + + The client currently supports one operation: + + * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` + + This method returns a :class:`PdsSearchResults` instance when a patient can be + extracted, otherwise ``None``. + + **Usage example**:: + + pds = PdsClient( + auth_token="YOUR_ACCESS_TOKEN", + base_url="https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4", + ) + + result = pds.search_patient_by_nhs_number(9000000009) + + if result: + print(result) + """ + + # URLs for different PDS environments. Requires authentication to use live. + SANDBOX_URL = "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4" + INT_URL = "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4" + PROD_URL = "https://api.service.nhs.uk/personal-demographics/FHIR/R4" + + def __init__( + self, + auth_token: str, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ignore_dates: bool = False, + ) -> None: + """ + Create a PDS client. + + :param auth_token: OAuth2 bearer token (without the ``"Bearer "`` prefix). + :param base_url: Base URL for the PDS API (one of :attr:`SANDBOX_URL`, + :attr:`INT_URL`, :attr:`PROD_URL`). Trailing slashes are stripped. + :param timeout: Default timeout in seconds for HTTP calls. + :param ignore_dates: If ``True`` just get the most recent name or GP record, + ignoring the date ranges. + """ + self.auth_token = auth_token + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.ignore_dates = ignore_dates + self.stub = PdsFhirApiStub() + + # TODO: Put this back to using the environment variable + # if os.environ.get("STUB_PDS", None): + self.get_method: GetCallable = self.stub.get + # else: + # self.get_method: GetCallable = requests.get + + def _build_headers( + self, + request_id: str | None = None, + correlation_id: str | None = None, + ) -> dict[str, str]: + """ + Build mandatory and optional headers for a PDS request. + + :param request_id: Optional ``X-Request-ID``. If not supplied a new UUID is + generated. + :param correlation_id: Optional ``X-Correlation-ID`` for cross-system tracing. + :return: Dictionary of HTTP headers for the outbound request. + """ + headers = { + "X-Request-ID": request_id or str(uuid.uuid4()), + "Accept": "application/fhir+json", + "Authorization": f"Bearer {self.auth_token}", + } + + # Correlation ID is used to track the same request across multiple systems. + # Can be safely omitted, mirrored back in response if included. + if correlation_id: + headers["X-Correlation-ID"] = correlation_id + + return headers + + def search_patient_by_nhs_number( + self, + nhs_number: str, + request_id: str | None = None, + correlation_id: str | None = None, + timeout: int | None = None, + ) -> PdsSearchResults | None: + """ + Retrieve a patient by NHS number. + + Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient + resource on success, then extracts a single :class:`PdsSearchResults`. + + :param nhs_number: NHS number to search for. + :param request_id: Optional request ID to reuse for retries; if not supplied a + UUID is generated. + :param correlation_id: Optional correlation ID for tracing. + :param timeout: Optional per-call timeout in seconds. If not provided, + :attr:`timeout` is used. + :return: A :class:`PdsSearchResults` instance if a patient can be extracted, + otherwise ``None``. + """ + headers = self._build_headers( + request_id=request_id, + correlation_id=correlation_id, + ) + + url = f"{self.base_url}/Patient/{nhs_number}" + + # This normally calls requests.get, but if STUB_PDS is set it uses the stub. + response = self.get_method( + url, + headers=headers, + params={}, + timeout=timeout or self.timeout, + ) + + try: + response.raise_for_status() + except requests.HTTPError as err: + raise PdsRequestFailed(error_reason=err.response.reason) from err + + body = response.json() + return self._extract_single_search_result(body) + + # --------------- internal helpers for result extraction ----------------- + + def _get_gp_ods_code(self, general_practitioners: ResultList) -> str | None: + """ + Extract the current GP ODS code from ``Patient.generalPractitioner``. + + This function implements the business rule: + + * If the list is empty, return ``None``. + * If the list is non-empty and no record is current, return ``None``. + * If exactly one record is current, return its ``identifier.value``. + + In future this may change to return the most recent record if none is current. + + :param general_practitioners: List of ``generalPractitioner`` records from a + Patient resource. + :return: ODS code string if a current record exists, otherwise ``None``. + :raises KeyError: If a record is missing required ``identifier.period`` fields. + """ + if len(general_practitioners) == 0: + return None + + gp = self.find_current_gp(general_practitioners) + if gp is None: + return None + + identifier = cast("ResultStructureDict", gp.get("identifier", {})) + ods_code = str(identifier.get("value", None)) + + # Avoid returning the literal string "None" if identifier.value is absent. + return None if ods_code == "None" else ods_code + + def _extract_single_search_result( + self, body: ResultStructureDict + ) -> PdsSearchResults | None: + """ + Extract a single :class:`PdsSearchResults` from a Patient response. + + This helper accepts either: + * a single FHIR Patient resource (as returned by ``GET /Patient/{id}``), or + * a FHIR Bundle containing Patient entries (as typically returned by searches). + + For Bundle inputs, the code assumes either zero matches (empty entry list) or a + single match; if multiple entries are present, the first entry is used. + :param body: Parsed JSON body containing either a Patient resource or a Bundle + whose first entry contains a Patient resource under ``resource``. + :return: A populated :class:`PdsSearchResults` if extraction succeeds, otherwise + ``None``. + """ + # Accept either: + # 1) Patient (GET /Patient/{id}) + # 2) Bundle with Patient in entry[0].resource (search endpoints) + if str(body.get("resourceType", "")) == "Patient": + patient = body + else: + entries: ResultList = cast("ResultList", body.get("entry", [])) + if not entries: + raise RuntimeError("PDS response contains no patient entries") + + # Use the first patient entry. Search by NHS number is unique. Search by + # demographics for an application is allowed to return max one entry from + # PDS. Search by a human can return more, but presumably we count as an + # application. + # See MaxResults parameter in the PDS OpenAPI spec. + entry = entries[0] + patient = cast("ResultStructureDict", entry.get("resource", {})) + + nhs_number = str(patient.get("id", "")).strip() + if not nhs_number: + raise RuntimeError("PDS patient resource missing NHS number") + + # Select current name record and extract names. + names = cast("ResultList", patient.get("name", [])) + current_name = self.find_current_name_record(names) + + if current_name is not None: + given_names_list = cast("list[str]", current_name.get("given", [])) + family_name = str(current_name.get("family", "")) or "" + given_names_str = " ".join(given_names_list).strip() + else: + given_names_str = "" + family_name = "" + + # Extract GP ODS code if a current GP record exists. + gp_list = cast("ResultList", patient.get("generalPractitioner", [])) + gp_ods_code = self._get_gp_ods_code(gp_list) + + return PdsSearchResults( + given_names=given_names_str, + family_name=family_name, + nhs_number=nhs_number, + gp_ods_code=gp_ods_code, + ) + + def find_current_gp( + self, records: ResultList, today: date | None = None + ) -> ResultStructureDict | None: + """ + Select the current record from a ``generalPractitioner`` list. + + A record is "current" if its ``identifier.period`` covers ``today`` (inclusive): + + ``start <= today <= end`` + + Or else if self.ignore_dates is True, the last record in the list is returned. + + The list may be in any of the following states: + + * empty + * contains one or more records, none current + * contains one or more records, exactly one current + + :param records: List of ``generalPractitioner`` records. + :param today: Optional override date, intended for deterministic tests. + If not supplied, the current UTC date is used. + :return: The first record whose ``identifier.period`` covers ``today``, or + ``None`` if no record is current. + :raises KeyError: If required keys are missing for a record being evaluated. + :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. + """ + if today is None: + today = datetime.now(timezone.utc).date() + + if self.ignore_dates: + if len(records) > 0: + return records[-1] + else: + return None + + for record in records: + identifier = cast("ResultStructureDict", record["identifier"]) + periods = cast("dict[str, str]", identifier["period"]) + start_str = periods["start"] + end_str = periods["end"] + + start = date.fromisoformat(start_str) + end = date.fromisoformat(end_str) + + if start <= today <= end: + return record + + return None + + def find_current_name_record( + self, records: ResultList, today: date | None = None + ) -> ResultStructureDict | None: + """ + Select the current record from a ``Patient.name`` list. + + A record is "current" if its ``period`` covers ``today`` (inclusive): + + ``start <= today <= end`` + + Or else if self.ignore_dates is True, the last record in the list is returned. + + :param records: List of ``Patient.name`` records. + :param today: Optional override date, intended for deterministic tests. + If not supplied, the current UTC date is used. + :return: The first name record whose ``period`` covers ``today``, or ``None`` if + no record is current. + :raises KeyError: If required keys (``period.start`` / ``period.end``) are + missing. + :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. + """ + if today is None: + today = datetime.now(timezone.utc).date() + + if self.ignore_dates: + if len(records) > 0: + return records[-1] + else: + return None + + for record in records: + periods = cast("dict[str, str]", record["period"]) + start_str = periods["start"] + end_str = periods["end"] + + start = date.fromisoformat(start_str) + end = date.fromisoformat(end_str) + + if start <= today <= end: + return record + + return None diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index 4b94817f..bc65b46f 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -10,7 +10,7 @@ import pytest import requests from requests.structures import CaseInsensitiveDict -from stubs.stub_pds import PdsFhirApiStub +from stubs.pds.stub import PdsFhirApiStub from gateway_api.common.error import PdsRequestFailed from gateway_api.pds.client import ( diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index c0e3563a..cd88e8c1 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -26,7 +26,7 @@ from urllib.parse import urljoin from requests import HTTPError, Response, post -from stubs.stub_provider import stub_post +from stubs.provider.stub import stub_post from gateway_api.common.error import SdsRequestFailed diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 5b540d4d..d9a566ff 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -11,7 +11,7 @@ import pytest from requests import Response from requests.structures import CaseInsensitiveDict -from stubs.stub_provider import GpProviderStub +from stubs.provider.stub import GpProviderStub from gateway_api.common.error import SdsRequestFailed from gateway_api.provider import GpProviderClient, client diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py new file mode 100644 index 00000000..d9a566ff --- /dev/null +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -0,0 +1,220 @@ +""" +Unit tests for :mod:`gateway_api.provider_request`. + +This module contains unit tests for the `GpProviderClient` class, which is responsible +for interacting with the GPProvider FHIR API. + +""" + +from typing import Any + +import pytest +from requests import Response +from requests.structures import CaseInsensitiveDict +from stubs.provider.stub import GpProviderStub + +from gateway_api.common.error import SdsRequestFailed +from gateway_api.provider import GpProviderClient, client + +ars_interactionId = ( + "urn:nhs:names:services:gpconnect:structured" + ":fhir:operation:gpc.getstructuredrecord-1" +) + + +@pytest.fixture +def stub() -> GpProviderStub: + return GpProviderStub() + + +@pytest.fixture +def mock_request_post( + monkeypatch: pytest.MonkeyPatch, stub: GpProviderStub +) -> dict[str, Any]: + """ + Fixture to patch the `requests.post` method for testing. + + This fixture intercepts calls to `requests.post` and routes them to the + stub provider. It also captures the most recent request details, such as + headers, body, and URL, for verification in tests. + + Returns: + dict[str, Any]: A dictionary containing the captured request details. + """ + capture: dict[str, Any] = {} + + def _fake_post( + url: str, + headers: CaseInsensitiveDict[str], + data: str, + timeout: int, # NOQA ARG001 (unused in stub) + ) -> Response: + """A fake requests.post implementation.""" + + capture["headers"] = dict(headers) + capture["data"] = data + capture["url"] = url + + # Provide dummy or captured arguments as required by the stub signature + return stub.access_record_structured( + trace_id=headers.get("Ssp-TraceID", "dummy-trace-id"), body=data + ) + + monkeypatch.setattr(client, "post", _fake_post) + return capture + + +def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( + mock_request_post: dict[str, Any], +) -> None: + """ + Test that the `access_structured_record` method constructs the correct URL + for the GPProvider FHIR API request and receives a 200 OK response. + + This test verifies that the URL includes the correct FHIR base path and + operation for accessing a structured patient record. + """ + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://test.com" + trace_id = "some_uuid_value" + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + result = client.access_structured_record(trace_id, "body") + + captured_url = mock_request_post.get("url", provider_endpoint) + + assert ( + captured_url + == provider_endpoint + "/FHIR/STU3/patient/$gpc.getstructuredrecord" + ) + assert result.status_code == 200 + + +def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( + mock_request_post: dict[str, Any], +) -> None: + """ + Test that the `access_structured_record` method includes the correct headers + in the GPProvider FHIR API request and receives a 200 OK response. + + This test verifies that the headers include: + - Content-Type and Accept headers for FHIR+JSON. + - Ssp-TraceID, Ssp-From, Ssp-To, and Ssp-InteractionID for GPConnect. + """ + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://test.com" + trace_id = "some_uuid_value" + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + expected_headers = { + "Content-Type": "application/fhir+json", + "Accept": "application/fhir+json", + "Ssp-TraceID": str(trace_id), + "Ssp-From": consumer_asid, + "Ssp-To": provider_asid, + "Ssp-InteractionID": ars_interactionId, + } + + result = client.access_structured_record(trace_id, "body") + + captured_headers = mock_request_post["headers"] + + assert expected_headers == captured_headers + assert result.status_code == 200 + + +def test_valid_gpprovider_access_structured_record_with_correct_body_200( + mock_request_post: dict[str, Any], +) -> None: + """ + Test that the `access_structured_record` method includes the correct body + in the GPProvider FHIR API request and receives a 200 OK response. + + This test verifies that the request body matches the expected FHIR parameters + resource sent to the GPProvider API. + """ + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://test.com" + trace_id = "some_uuid_value" + + request_body = "some_FHIR_request_params" + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + result = client.access_structured_record(trace_id, request_body) + + captured_body = mock_request_post["data"] + + assert result.status_code == 200 + assert captured_body == request_body + + +def test_valid_gpprovider_access_structured_record_returns_stub_response_200( + mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) + stub: GpProviderStub, +) -> None: + """ + Test that the `access_structured_record` method returns the same response + as provided by the stub provider. + + This test verifies that the response from the GPProvider FHIR API matches + the expected response, including the status code and content. + """ + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://test.com" + trace_id = "some_uuid_value" + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + expected_response = stub.access_record_structured(trace_id, "body") + + result = client.access_structured_record(trace_id, "body") + + assert result.status_code == 200 + assert result.content == expected_response.content + + +def test_access_structured_record_raises_external_service_error( + mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) +) -> None: + """ + Test that the `access_structured_record` method raises an `SdsRequestFailed` + when the GPProvider FHIR API request fails with an HTTP error. + """ + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://test.com" + trace_id = "invalid for test" + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + with pytest.raises( + SdsRequestFailed, + match="SDS FHIR API request failed: Bad Request", + ): + client.access_structured_record(trace_id, "body") diff --git a/gateway-api/stubs/stubs/data/__init__.py b/gateway-api/stubs/stubs/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/stubs/stubs/data/bundles/__init__.py b/gateway-api/stubs/stubs/data/bundles/__init__.py new file mode 100644 index 00000000..d714c29d --- /dev/null +++ b/gateway-api/stubs/stubs/data/bundles/__init__.py @@ -0,0 +1,20 @@ +from typing import Any + +from stubs.data.patients import Patients + + +class Bundles: + @staticmethod + def _wrap_patient_in_bundle(patient: dict[str, Any]) -> dict[str, Any]: + return { + "resourceType": "Bundle", + "type": "collection", + "meta": { + "profile": [ + "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" + ] + }, + "entry": [{"resource": patient}], + } + + ALICE_JONES_9999999999 = _wrap_patient_in_bundle(Patients.ALICE_JONES_9999999999) diff --git a/gateway-api/stubs/stubs/data/patients/__init__.py b/gateway-api/stubs/stubs/data/patients/__init__.py new file mode 100644 index 00000000..27cc2751 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/__init__.py @@ -0,0 +1,19 @@ +import json +import pathlib +from typing import Any + + +def _path_to_here() -> pathlib.Path: + return pathlib.Path(__file__).parent + + +class Patients: + @staticmethod + def load_patient(filename: str) -> dict[str, Any]: + with open(_path_to_here() / filename, encoding="utf-8") as f: + patient: dict[str, Any] = json.load(f) + return patient + + JANE_SMITH_9000000009 = load_patient("jane_smith_9000000009.json") + + ALICE_JONES_9999999999 = load_patient("alice_jones_9999999999.json") diff --git a/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json new file mode 100644 index 00000000..558a4e30 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9999999999", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + ], + "name": [ + { + "use": "official", + "family": "Jones", + "given": ["Alice"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1980-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json b/gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json new file mode 100644 index 00000000..81b0ce5f --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json @@ -0,0 +1,24 @@ +{ + "resourceType": "Patient", + "id": "9000000009", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01" +} diff --git a/gateway-api/stubs/stubs/pds/__init__.py b/gateway-api/stubs/stubs/pds/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/stubs/stubs/stub_pds.py b/gateway-api/stubs/stubs/pds/stub.py similarity index 84% rename from gateway-api/stubs/stubs/stub_pds.py rename to gateway-api/stubs/stubs/pds/stub.py index 6233d7b1..e4b336c0 100644 --- a/gateway-api/stubs/stubs/stub_pds.py +++ b/gateway-api/stubs/stubs/pds/stub.py @@ -14,6 +14,8 @@ from requests import Response from requests.structures import CaseInsensitiveDict +from stubs.data.patients import Patients + def _create_response( status_code: int, @@ -70,69 +72,13 @@ def __init__(self, strict_headers: bool = True) -> None: # Tests may overwrite this record via upsert_patient. self.upsert_patient( nhs_number="9000000009", - patient={ - "resourceType": "Patient", - "id": "9000000009", - "meta": { - "versionId": "1", - "lastUpdated": "2020-01-01T00:00:00Z", - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9000000009", - } - ], - "name": [ - { - "use": "official", - "family": "Smith", - "given": ["Jane"], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "gender": "female", - "birthDate": "1970-01-01", - }, + patient=Patients.JANE_SMITH_9000000009, version_id=1, ) self.upsert_patient( nhs_number="9999999999", - patient={ - "resourceType": "Patient", - "id": "9999999999", - "meta": { - "versionId": "1", - "lastUpdated": "2020-01-01T00:00:00Z", - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "name": [ - { - "use": "official", - "family": "Jones", - "given": ["Alice"], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "gender": "female", - "birthDate": "1980-01-01", - "generalPractitioner": [ - { - "id": "1", - "type": "Organization", - "identifier": { - "value": "A12345", - "period": {"start": "2020-01-01", "end": "9999-12-31"}, - }, - } - ], - }, + patient=Patients.ALICE_JONES_9999999999, version_id=1, ) diff --git a/gateway-api/stubs/stubs/provider/__init__.py b/gateway-api/stubs/stubs/provider/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/provider/stub.py similarity index 69% rename from gateway-api/stubs/stubs/stub_provider.py rename to gateway-api/stubs/stubs/provider/stub.py index 2d0c96ba..cc723fa3 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/provider/stub.py @@ -28,6 +28,8 @@ from requests import Response from requests.structures import CaseInsensitiveDict +from stubs.data.bundles import Bundles + def _create_response( status_code: int, @@ -64,49 +66,6 @@ class GpProviderStub: # https://simplifier.net/guide/gp-connect-access-record-structured/Home/Examples/Allergy-examples?version=1.6.2 """ - # Example patient resource - patient_bundle = { - "resourceType": "Bundle", - "type": "collection", - "meta": { - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" - ] - }, - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", - "meta": { - "versionId": "1469448000000", - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "active": True, - "name": [ - { - "use": "official", - "text": "JACKSON Jane (Miss)", - "family": "Jackson", - "given": ["Jane"], - "prefix": ["Miss"], - } - ], - "gender": "female", - "birthDate": "1952-05-31", - } - } - ], - } - def access_record_structured( self, trace_id: str, @@ -122,7 +81,7 @@ def access_record_structured( stub_response = _create_response( status_code=200, headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), - content=json.dumps(self.patient_bundle).encode("utf-8"), + content=json.dumps(Bundles.ALICE_JONES_9999999999).encode("utf-8"), reason="OK", ) diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index 3485f224..bf777cbe 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -6,7 +6,7 @@ import requests from fhir.parameters import Parameters from pytest_bdd import given, parsers, then, when -from stubs.stub_provider import GpProviderStub +from stubs.data.bundles import Bundles from tests.acceptance.conftest import ResponseContext from tests.conftest import Client @@ -60,6 +60,6 @@ def check_status_code(response_context: ResponseContext, expected_status: int) - @then("the response should contain the patient bundle from the provider") def check_response_matches_provider(response_context: ResponseContext) -> None: assert response_context.response, "Response has not been set." - assert response_context.response.json() == GpProviderStub.patient_bundle, ( + assert response_context.response.json() == Bundles.ALICE_JONES_9999999999, ( "Expected response payload does not match actual response payload." ) diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 12c8a5cf..7c93ed79 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -55,10 +55,22 @@ "entry": [ { "resource": { - "active": true, - "birthDate": "1952-05-31", + "birthDate": "1980-01-01", "gender": "female", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", + "generalPractitioner": [ + { + "id": "1", + "identifier": { + "period": { + "end": "9999-12-31", + "start": "2020-01-01" + }, + "value": "A12345" + }, + "type": "Organization" + } + ], + "id": "9999999999", "identifier": [ { "system": "https://fhir.nhs.uk/Id/nhs-number", @@ -66,21 +78,19 @@ } ], "meta": { - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], - "versionId": "1469448000000" + "lastUpdated": "2020-01-01T00:00:00Z", + "versionId": "1" }, "name": [ { - "family": "Jackson", + "family": "Jones", "given": [ - "Jane" - ], - "prefix": [ - "Miss" + "Alice" ], - "text": "JACKSON Jane (Miss)", + "period": { + "end": "9999-12-31", + "start": "1900-01-01" + }, "use": "official" } ], diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index cf1998c3..7c9bffee 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -34,12 +34,10 @@ def test_get_structured_record(self) -> None: { "resource": { "resourceType": "Patient", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", + "id": "9999999999", "meta": { - "versionId": "1469448000000", - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z", }, "identifier": [ { @@ -47,18 +45,29 @@ def test_get_structured_record(self) -> None: "value": "9999999999", } ], - "active": True, "name": [ { "use": "official", - "text": "JACKSON Jane (Miss)", - "family": "Jackson", - "given": ["Jane"], - "prefix": ["Miss"], + "family": "Jones", + "given": ["Alice"], + "period": {"start": "1900-01-01", "end": "9999-12-31"}, } ], "gender": "female", - "birthDate": "1952-05-31", + "birthDate": "1980-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": { + "start": "2020-01-01", + "end": "9999-12-31", + }, + }, + } + ], } } ], @@ -128,10 +137,7 @@ def test_get_structured_record(self) -> None: assert body["type"] == "collection" assert len(body["entry"]) == 1 assert body["entry"][0]["resource"]["resourceType"] == "Patient" - assert ( - body["entry"][0]["resource"]["id"] - == "04603d77-1a4e-4d63-b246-d7504f8bd833" - ) + assert body["entry"][0]["resource"]["id"] == "9999999999" assert ( body["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" ) diff --git a/gateway-api/tests/data/patient/pds_fhir_example.json b/gateway-api/tests/data/patient/pds_fhir_example.json new file mode 100644 index 00000000..2c590256 --- /dev/null +++ b/gateway-api/tests/data/patient/pds_fhir_example.json @@ -0,0 +1,390 @@ +{ + "resourceType": "Patient", + "id": "9000000009", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-NHSNumberVerificationStatus", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-NHSNumberVerificationStatus", + "version": "1.0.0", + "code": "01", + "display": "Number present and verified" + } + ] + } + } + ] + } + ], + "meta": { + "versionId": "2", + "security": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-Confidentiality", + "code": "U", + "display": "unrestricted" + } + ] + }, + "name": [ + { + "id": "123", + "use": "usual", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "given": [ + "Jane" + ], + "family": "Smith", + "prefix": [ + "Mrs" + ] + } + ], + "gender": "female", + "birthDate": "2010-10-22", + "multipleBirthInteger": 1, + "deceasedDateTime": "2010-10-22T00:00:00+00:00", + "generalPractitioner": [ + { + "id": "254406A3", + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y12345", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + } + } + } + ], + "managingOrganization": { + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y12345", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + } + } + }, + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-NominatedPharmacy", + "valueReference": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y12345" + } + } + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-PreferredDispenserOrganization", + "valueReference": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y23456" + } + } + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-MedicalApplianceSupplier", + "valueReference": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y34567" + } + } + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-DeathNotificationStatus", + "extension": [ + { + "url": "deathNotificationStatus", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-DeathNotificationStatus", + "version": "1.0.0", + "code": "2", + "display": "Formal - death notice received from Registrar of Deaths" + } + ] + } + }, + { + "url": "systemEffectiveDate", + "valueDateTime": "2010-10-22T00:00:00+00:00" + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-NHSCommunication", + "extension": [ + { + "url": "language", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-HumanLanguage", + "version": "1.0.0", + "code": "fr", + "display": "French" + } + ] + } + }, + { + "url": "interpreterRequired", + "valueBoolean": true + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-ContactPreference", + "extension": [ + { + "url": "PreferredWrittenCommunicationFormat", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-PreferredWrittenCommunicationFormat", + "code": "12", + "display": "Braille" + } + ] + } + }, + { + "url": "PreferredContactMethod", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-PreferredContactMethod", + "code": "1", + "display": "Letter" + } + ] + } + }, + { + "url": "PreferredContactTimes", + "valueString": "Not after 7pm" + } + ] + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", + "valueAddress": { + "city": "Manchester", + "district": "Greater Manchester", + "country": "GBR" + } + }, + { + "url": "https://fhir.nhs.uk/StructureDefinition/Extension-PDS-RemovalFromRegistration", + "extension": [ + { + "url": "removalFromRegistrationCode", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/PDS-RemovalReasonExitCode", + "code": "SCT", + "display": "Transferred to Scotland" + } + ] + } + }, + { + "url": "effectiveTime", + "valuePeriod": { + "start": "2020-01-01T00:00:00+00:00", + "end": "2021-12-31T00:00:00+00:00" + } + } + ] + } + ], + "telecom": [ + { + "id": "789", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "system": "phone", + "value": "01632960587", + "use": "home" + }, + { + "id": "790", + "period": { + "start": "2019-01-01", + "end": "2022-12-31" + }, + "system": "email", + "value": "jane.smith@example.com", + "use": "home" + }, + { + "id": "OC789", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "system": "other", + "value": "01632960587", + "use": "home", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-OtherContactSystem", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-OtherContactSystem", + "code": "textphone", + "display": "Minicom (Textphone)" + } + } + ] + } + ], + "contact": [ + { + "id": "C123", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "relationship": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0131", + "code": "C", + "display": "Emergency Contact" + } + ] + } + ], + "telecom": [ + { + "system": "phone", + "value": "01632960587" + } + ] + } + ], + "address": [ + { + "id": "456", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "use": "home", + "line": [ + "1 Trevelyan Square", + "Boar Lane", + "City Centre", + "Leeds", + "West Yorkshire" + ], + "postalCode": "LS1 6AE", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "PAF" + } + }, + { + "url": "value", + "valueString": "12345678" + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "UPRN" + } + }, + { + "url": "value", + "valueString": "123456789012" + } + ] + } + ] + }, + { + "id": "T456", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "use": "temp", + "text": "Student Accommodation", + "line": [ + "1 Trevelyan Square", + "Boar Lane", + "City Centre", + "Leeds", + "West Yorkshire" + ], + "postalCode": "LS1 6AE", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "PAF" + } + }, + { + "url": "value", + "valueString": "12345678" + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "UPRN" + } + }, + { + "url": "value", + "valueString": "123456789012" + } + ] + } + ] + } + ] +} diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index 32151f2d..a776f0a4 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -3,7 +3,7 @@ import json from fhir.parameters import Parameters -from stubs.stub_provider import GpProviderStub +from stubs.data.bundles import Bundles from tests.conftest import Client @@ -27,7 +27,7 @@ def test_happy_path_returns_correct_message( response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) - assert response.json() == GpProviderStub.patient_bundle + assert response.json() == Bundles.ALICE_JONES_9999999999 def test_happy_path_content_type( self, client: Client, simple_request_payload: Parameters From be9493701c47ece673d0376c4b1cc85399c6e30d Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:11:55 +0000 Subject: [PATCH 25/40] [GPCAPIM-275]: Move modules to their own directory --- gateway-api/src/gateway_api/pds/pds_search.py | 354 ------------------ 1 file changed, 354 deletions(-) delete mode 100644 gateway-api/src/gateway_api/pds/pds_search.py diff --git a/gateway-api/src/gateway_api/pds/pds_search.py b/gateway-api/src/gateway_api/pds/pds_search.py deleted file mode 100644 index b8e22a4b..00000000 --- a/gateway-api/src/gateway_api/pds/pds_search.py +++ /dev/null @@ -1,354 +0,0 @@ -""" -PDS (Personal Demographics Service) FHIR R4 patient lookup client. - -Contracts enforced by the helper functions: - -* ``Patient.name[]`` records passed to :func:`find_current_name_record` must contain:: - - record["period"]["start"] - record["period"]["end"] - -* ``Patient.generalPractitioner[]`` records passed to :func:`find_current_record` must - contain:: - - record["identifier"]["period"]["start"] - record["identifier"]["period"]["end"] - -If required keys are missing, a ``KeyError`` is raised intentionally. This is treated as -malformed upstream data (or malformed test fixtures) and should be corrected at source. -""" - -import uuid -from collections.abc import Callable -from datetime import date, datetime, timezone -from typing import cast - -import requests -from stubs.pds.stub import PdsFhirApiStub - -from gateway_api.common.error import PdsRequestFailed -from gateway_api.pds.search_results import PdsSearchResults - -# Recursive JSON-like structure typing used for parsed FHIR bodies. -type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] -type ResultStructureDict = dict[str, ResultStructure] -type ResultList = list[ResultStructureDict] - -# Type for stub get method -type GetCallable = Callable[..., requests.Response] - - -class PdsClient: - """ - Simple client for PDS FHIR R4 patient retrieval. - - The client currently supports one operation: - - * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` - - This method returns a :class:`PdsSearchResults` instance when a patient can be - extracted, otherwise ``None``. - - **Usage example**:: - - pds = PdsClient( - auth_token="YOUR_ACCESS_TOKEN", - base_url="https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4", - ) - - result = pds.search_patient_by_nhs_number(9000000009) - - if result: - print(result) - """ - - # URLs for different PDS environments. Requires authentication to use live. - SANDBOX_URL = "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4" - INT_URL = "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4" - PROD_URL = "https://api.service.nhs.uk/personal-demographics/FHIR/R4" - - def __init__( - self, - auth_token: str, - base_url: str = SANDBOX_URL, - timeout: int = 10, - ignore_dates: bool = False, - ) -> None: - """ - Create a PDS client. - - :param auth_token: OAuth2 bearer token (without the ``"Bearer "`` prefix). - :param base_url: Base URL for the PDS API (one of :attr:`SANDBOX_URL`, - :attr:`INT_URL`, :attr:`PROD_URL`). Trailing slashes are stripped. - :param timeout: Default timeout in seconds for HTTP calls. - :param ignore_dates: If ``True`` just get the most recent name or GP record, - ignoring the date ranges. - """ - self.auth_token = auth_token - self.base_url = base_url.rstrip("/") - self.timeout = timeout - self.ignore_dates = ignore_dates - self.stub = PdsFhirApiStub() - - # TODO: Put this back to using the environment variable - # if os.environ.get("STUB_PDS", None): - self.get_method: GetCallable = self.stub.get - # else: - # self.get_method: GetCallable = requests.get - - def _build_headers( - self, - request_id: str | None = None, - correlation_id: str | None = None, - ) -> dict[str, str]: - """ - Build mandatory and optional headers for a PDS request. - - :param request_id: Optional ``X-Request-ID``. If not supplied a new UUID is - generated. - :param correlation_id: Optional ``X-Correlation-ID`` for cross-system tracing. - :return: Dictionary of HTTP headers for the outbound request. - """ - headers = { - "X-Request-ID": request_id or str(uuid.uuid4()), - "Accept": "application/fhir+json", - "Authorization": f"Bearer {self.auth_token}", - } - - # Correlation ID is used to track the same request across multiple systems. - # Can be safely omitted, mirrored back in response if included. - if correlation_id: - headers["X-Correlation-ID"] = correlation_id - - return headers - - def search_patient_by_nhs_number( - self, - nhs_number: str, - request_id: str | None = None, - correlation_id: str | None = None, - timeout: int | None = None, - ) -> PdsSearchResults | None: - """ - Retrieve a patient by NHS number. - - Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient - resource on success, then extracts a single :class:`PdsSearchResults`. - - :param nhs_number: NHS number to search for. - :param request_id: Optional request ID to reuse for retries; if not supplied a - UUID is generated. - :param correlation_id: Optional correlation ID for tracing. - :param timeout: Optional per-call timeout in seconds. If not provided, - :attr:`timeout` is used. - :return: A :class:`PdsSearchResults` instance if a patient can be extracted, - otherwise ``None``. - """ - headers = self._build_headers( - request_id=request_id, - correlation_id=correlation_id, - ) - - url = f"{self.base_url}/Patient/{nhs_number}" - - # This normally calls requests.get, but if STUB_PDS is set it uses the stub. - response = self.get_method( - url, - headers=headers, - params={}, - timeout=timeout or self.timeout, - ) - - try: - response.raise_for_status() - except requests.HTTPError as err: - raise PdsRequestFailed(error_reason=err.response.reason) from err - - body = response.json() - return self._extract_single_search_result(body) - - # --------------- internal helpers for result extraction ----------------- - - def _get_gp_ods_code(self, general_practitioners: ResultList) -> str | None: - """ - Extract the current GP ODS code from ``Patient.generalPractitioner``. - - This function implements the business rule: - - * If the list is empty, return ``None``. - * If the list is non-empty and no record is current, return ``None``. - * If exactly one record is current, return its ``identifier.value``. - - In future this may change to return the most recent record if none is current. - - :param general_practitioners: List of ``generalPractitioner`` records from a - Patient resource. - :return: ODS code string if a current record exists, otherwise ``None``. - :raises KeyError: If a record is missing required ``identifier.period`` fields. - """ - if len(general_practitioners) == 0: - return None - - gp = self.find_current_gp(general_practitioners) - if gp is None: - return None - - identifier = cast("ResultStructureDict", gp.get("identifier", {})) - ods_code = str(identifier.get("value", None)) - - # Avoid returning the literal string "None" if identifier.value is absent. - return None if ods_code == "None" else ods_code - - def _extract_single_search_result( - self, body: ResultStructureDict - ) -> PdsSearchResults | None: - """ - Extract a single :class:`PdsSearchResults` from a Patient response. - - This helper accepts either: - * a single FHIR Patient resource (as returned by ``GET /Patient/{id}``), or - * a FHIR Bundle containing Patient entries (as typically returned by searches). - - For Bundle inputs, the code assumes either zero matches (empty entry list) or a - single match; if multiple entries are present, the first entry is used. - :param body: Parsed JSON body containing either a Patient resource or a Bundle - whose first entry contains a Patient resource under ``resource``. - :return: A populated :class:`PdsSearchResults` if extraction succeeds, otherwise - ``None``. - """ - # Accept either: - # 1) Patient (GET /Patient/{id}) - # 2) Bundle with Patient in entry[0].resource (search endpoints) - if str(body.get("resourceType", "")) == "Patient": - patient = body - else: - entries: ResultList = cast("ResultList", body.get("entry", [])) - if not entries: - raise RuntimeError("PDS response contains no patient entries") - - # Use the first patient entry. Search by NHS number is unique. Search by - # demographics for an application is allowed to return max one entry from - # PDS. Search by a human can return more, but presumably we count as an - # application. - # See MaxResults parameter in the PDS OpenAPI spec. - entry = entries[0] - patient = cast("ResultStructureDict", entry.get("resource", {})) - - nhs_number = str(patient.get("id", "")).strip() - if not nhs_number: - raise RuntimeError("PDS patient resource missing NHS number") - - # Select current name record and extract names. - names = cast("ResultList", patient.get("name", [])) - current_name = self.find_current_name_record(names) - - if current_name is not None: - given_names_list = cast("list[str]", current_name.get("given", [])) - family_name = str(current_name.get("family", "")) or "" - given_names_str = " ".join(given_names_list).strip() - else: - given_names_str = "" - family_name = "" - - # Extract GP ODS code if a current GP record exists. - gp_list = cast("ResultList", patient.get("generalPractitioner", [])) - gp_ods_code = self._get_gp_ods_code(gp_list) - - return PdsSearchResults( - given_names=given_names_str, - family_name=family_name, - nhs_number=nhs_number, - gp_ods_code=gp_ods_code, - ) - - def find_current_gp( - self, records: ResultList, today: date | None = None - ) -> ResultStructureDict | None: - """ - Select the current record from a ``generalPractitioner`` list. - - A record is "current" if its ``identifier.period`` covers ``today`` (inclusive): - - ``start <= today <= end`` - - Or else if self.ignore_dates is True, the last record in the list is returned. - - The list may be in any of the following states: - - * empty - * contains one or more records, none current - * contains one or more records, exactly one current - - :param records: List of ``generalPractitioner`` records. - :param today: Optional override date, intended for deterministic tests. - If not supplied, the current UTC date is used. - :return: The first record whose ``identifier.period`` covers ``today``, or - ``None`` if no record is current. - :raises KeyError: If required keys are missing for a record being evaluated. - :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. - """ - if today is None: - today = datetime.now(timezone.utc).date() - - if self.ignore_dates: - if len(records) > 0: - return records[-1] - else: - return None - - for record in records: - identifier = cast("ResultStructureDict", record["identifier"]) - periods = cast("dict[str, str]", identifier["period"]) - start_str = periods["start"] - end_str = periods["end"] - - start = date.fromisoformat(start_str) - end = date.fromisoformat(end_str) - - if start <= today <= end: - return record - - return None - - def find_current_name_record( - self, records: ResultList, today: date | None = None - ) -> ResultStructureDict | None: - """ - Select the current record from a ``Patient.name`` list. - - A record is "current" if its ``period`` covers ``today`` (inclusive): - - ``start <= today <= end`` - - Or else if self.ignore_dates is True, the last record in the list is returned. - - :param records: List of ``Patient.name`` records. - :param today: Optional override date, intended for deterministic tests. - If not supplied, the current UTC date is used. - :return: The first name record whose ``period`` covers ``today``, or ``None`` if - no record is current. - :raises KeyError: If required keys (``period.start`` / ``period.end``) are - missing. - :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. - """ - if today is None: - today = datetime.now(timezone.utc).date() - - if self.ignore_dates: - if len(records) > 0: - return records[-1] - else: - return None - - for record in records: - periods = cast("dict[str, str]", record["period"]) - start_str = periods["start"] - end_str = periods["end"] - - start = date.fromisoformat(start_str) - end = date.fromisoformat(end_str) - - if start <= today <= end: - return record - - return None From 0c3c8cdddf2f49c4cac7902e90f62d7b97aa8979 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:15:52 +0000 Subject: [PATCH 26/40] [GPCAPIM-275]: Write a test to make number go up --- gateway-api/src/gateway_api/conftest.py | 5 +++++ gateway-api/src/gateway_api/sds/test_client.py | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 gateway-api/src/gateway_api/sds/test_client.py diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index ed88f984..e49ab8b9 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -53,3 +53,8 @@ def valid_headers() -> dict[str, str]: "ODS-from": "test-ods", "Content-type": "application/fhir+json", } + + +@pytest.fixture +def auth_token() -> str: + return "AUTH_TOKEN123" diff --git a/gateway-api/src/gateway_api/sds/test_client.py b/gateway-api/src/gateway_api/sds/test_client.py new file mode 100644 index 00000000..26105420 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/test_client.py @@ -0,0 +1,11 @@ +from gateway_api.sds.client import SdsClient + + +def test_sds_client(auth_token: str) -> None: + """Test that the SDS client returns the expected ASID and endpoint.""" + sds_client = SdsClient( + auth_token=auth_token, base_url="https://example.invalid/sds" + ) + result = sds_client.get_org_details("test_ods_code") + assert result is not None + assert result.asid == "asid_test_ods_code" From 30fdbaa786565f42add9934df85cac0f8800633b Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:18:22 +0000 Subject: [PATCH 27/40] [GPCAPIM-275]: Behaviour being testted already covered by integration test, TestGetStructuredRecord.test_happy_path* --- .../src/gateway_api/test_controller.py | 59 ------------------- 1 file changed, 59 deletions(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 6be83160..2367ba13 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -280,65 +280,6 @@ def get_structured_record_request( # ----------------------------- -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_200_on_success( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - On successful end-to-end call, the controller should return 200 with - expected body/headers. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), - ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), - ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - FakeGpProviderClient.response_status_code = 200 - FakeGpProviderClient.response_body = b'{"resourceType":"Bundle"}' - FakeGpProviderClient.response_headers = { - "Content-Type": "application/fhir+json", - "X-Downstream": "gp-provider", - } - - r = controller.run(get_structured_record_request) - - # Check that response from GP provider was passed through. - assert r.status_code == 200 - assert r.data == FakeGpProviderClient.response_body.decode("utf-8") - assert r.headers == FakeGpProviderClient.response_headers - - # Check that GP provider was initialised correctly - assert FakeGpProviderClient.last_init == { - "provider_endpoint": "https://provider.example/ep", - "provider_asid": "asid_PROV", - "consumer_asid": "asid_CONS", - } - - # Check that we passed the trace ID and body to the provider - assert FakeGpProviderClient.last_call == { - "trace_id": get_structured_record_request.trace_id, - "body": get_structured_record_request.request_body, - } - - @pytest.mark.parametrize( "get_structured_record_request", [({}, {})], From 6dc087366b383873a6738262f99591f4f9b0a941 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:53:03 +0000 Subject: [PATCH 28/40] [GPCAPIM-275]: Module was moved, so test file has been moved. This is a duplicate. --- gateway-api/src/gateway_api/common/error.py | 2 +- .../src/gateway_api/test_provider_request.py | 220 ------------------ 2 files changed, 1 insertion(+), 221 deletions(-) delete mode 100644 gateway-api/src/gateway_api/test_provider_request.py diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 8e8133ef..7a347653 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -39,7 +39,7 @@ def build_response(self) -> Response: return response def log(self) -> None: - print(self) + print(self) # TODO: Use traceback.print_exec() @property def message(self) -> str: diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py deleted file mode 100644 index d9a566ff..00000000 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -Unit tests for :mod:`gateway_api.provider_request`. - -This module contains unit tests for the `GpProviderClient` class, which is responsible -for interacting with the GPProvider FHIR API. - -""" - -from typing import Any - -import pytest -from requests import Response -from requests.structures import CaseInsensitiveDict -from stubs.provider.stub import GpProviderStub - -from gateway_api.common.error import SdsRequestFailed -from gateway_api.provider import GpProviderClient, client - -ars_interactionId = ( - "urn:nhs:names:services:gpconnect:structured" - ":fhir:operation:gpc.getstructuredrecord-1" -) - - -@pytest.fixture -def stub() -> GpProviderStub: - return GpProviderStub() - - -@pytest.fixture -def mock_request_post( - monkeypatch: pytest.MonkeyPatch, stub: GpProviderStub -) -> dict[str, Any]: - """ - Fixture to patch the `requests.post` method for testing. - - This fixture intercepts calls to `requests.post` and routes them to the - stub provider. It also captures the most recent request details, such as - headers, body, and URL, for verification in tests. - - Returns: - dict[str, Any]: A dictionary containing the captured request details. - """ - capture: dict[str, Any] = {} - - def _fake_post( - url: str, - headers: CaseInsensitiveDict[str], - data: str, - timeout: int, # NOQA ARG001 (unused in stub) - ) -> Response: - """A fake requests.post implementation.""" - - capture["headers"] = dict(headers) - capture["data"] = data - capture["url"] = url - - # Provide dummy or captured arguments as required by the stub signature - return stub.access_record_structured( - trace_id=headers.get("Ssp-TraceID", "dummy-trace-id"), body=data - ) - - monkeypatch.setattr(client, "post", _fake_post) - return capture - - -def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( - mock_request_post: dict[str, Any], -) -> None: - """ - Test that the `access_structured_record` method constructs the correct URL - for the GPProvider FHIR API request and receives a 200 OK response. - - This test verifies that the URL includes the correct FHIR base path and - operation for accessing a structured patient record. - """ - provider_asid = "200000001154" - consumer_asid = "200000001152" - provider_endpoint = "https://test.com" - trace_id = "some_uuid_value" - - client = GpProviderClient( - provider_endpoint=provider_endpoint, - provider_asid=provider_asid, - consumer_asid=consumer_asid, - ) - - result = client.access_structured_record(trace_id, "body") - - captured_url = mock_request_post.get("url", provider_endpoint) - - assert ( - captured_url - == provider_endpoint + "/FHIR/STU3/patient/$gpc.getstructuredrecord" - ) - assert result.status_code == 200 - - -def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( - mock_request_post: dict[str, Any], -) -> None: - """ - Test that the `access_structured_record` method includes the correct headers - in the GPProvider FHIR API request and receives a 200 OK response. - - This test verifies that the headers include: - - Content-Type and Accept headers for FHIR+JSON. - - Ssp-TraceID, Ssp-From, Ssp-To, and Ssp-InteractionID for GPConnect. - """ - provider_asid = "200000001154" - consumer_asid = "200000001152" - provider_endpoint = "https://test.com" - trace_id = "some_uuid_value" - - client = GpProviderClient( - provider_endpoint=provider_endpoint, - provider_asid=provider_asid, - consumer_asid=consumer_asid, - ) - expected_headers = { - "Content-Type": "application/fhir+json", - "Accept": "application/fhir+json", - "Ssp-TraceID": str(trace_id), - "Ssp-From": consumer_asid, - "Ssp-To": provider_asid, - "Ssp-InteractionID": ars_interactionId, - } - - result = client.access_structured_record(trace_id, "body") - - captured_headers = mock_request_post["headers"] - - assert expected_headers == captured_headers - assert result.status_code == 200 - - -def test_valid_gpprovider_access_structured_record_with_correct_body_200( - mock_request_post: dict[str, Any], -) -> None: - """ - Test that the `access_structured_record` method includes the correct body - in the GPProvider FHIR API request and receives a 200 OK response. - - This test verifies that the request body matches the expected FHIR parameters - resource sent to the GPProvider API. - """ - provider_asid = "200000001154" - consumer_asid = "200000001152" - provider_endpoint = "https://test.com" - trace_id = "some_uuid_value" - - request_body = "some_FHIR_request_params" - - client = GpProviderClient( - provider_endpoint=provider_endpoint, - provider_asid=provider_asid, - consumer_asid=consumer_asid, - ) - - result = client.access_structured_record(trace_id, request_body) - - captured_body = mock_request_post["data"] - - assert result.status_code == 200 - assert captured_body == request_body - - -def test_valid_gpprovider_access_structured_record_returns_stub_response_200( - mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) - stub: GpProviderStub, -) -> None: - """ - Test that the `access_structured_record` method returns the same response - as provided by the stub provider. - - This test verifies that the response from the GPProvider FHIR API matches - the expected response, including the status code and content. - """ - provider_asid = "200000001154" - consumer_asid = "200000001152" - provider_endpoint = "https://test.com" - trace_id = "some_uuid_value" - - client = GpProviderClient( - provider_endpoint=provider_endpoint, - provider_asid=provider_asid, - consumer_asid=consumer_asid, - ) - - expected_response = stub.access_record_structured(trace_id, "body") - - result = client.access_structured_record(trace_id, "body") - - assert result.status_code == 200 - assert result.content == expected_response.content - - -def test_access_structured_record_raises_external_service_error( - mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Test that the `access_structured_record` method raises an `SdsRequestFailed` - when the GPProvider FHIR API request fails with an HTTP error. - """ - provider_asid = "200000001154" - consumer_asid = "200000001152" - provider_endpoint = "https://test.com" - trace_id = "invalid for test" - - client = GpProviderClient( - provider_endpoint=provider_endpoint, - provider_asid=provider_asid, - consumer_asid=consumer_asid, - ) - - with pytest.raises( - SdsRequestFailed, - match="SDS FHIR API request failed: Bad Request", - ): - client.access_structured_record(trace_id, "body") From ebcb6e26a277fb4fdadbefa6b64e5cadc5fe22bb Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:42:45 +0000 Subject: [PATCH 29/40] [GPCAPIM-275]: Use common FHIR classes --- gateway-api/src/fhir/__init__.py | 2 + gateway-api/src/fhir/general_practitioner.py | 21 +++ gateway-api/src/fhir/human_name.py | 3 + gateway-api/src/fhir/patient.py | 2 + gateway-api/src/fhir/period.py | 10 ++ gateway-api/src/gateway_api/conftest.py | 20 ++- gateway-api/src/gateway_api/pds/client.py | 83 +++++----- .../src/gateway_api/pds/test_client.py | 142 +++++++++--------- gateway-api/src/gateway_api/test_app.py | 21 ++- 9 files changed, 182 insertions(+), 122 deletions(-) create mode 100644 gateway-api/src/fhir/general_practitioner.py create mode 100644 gateway-api/src/fhir/period.py diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index 4ad915ee..f7b15f5c 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -1,6 +1,7 @@ """FHIR data types and resources.""" from fhir.bundle import Bundle, BundleEntry +from fhir.general_practitioner import GeneralPractitioner from fhir.human_name import HumanName from fhir.identifier import Identifier from fhir.operation_outcome import OperationOutcome, OperationOutcomeIssue @@ -17,4 +18,5 @@ "Parameter", "Parameters", "Patient", + "GeneralPractitioner", ] diff --git a/gateway-api/src/fhir/general_practitioner.py b/gateway-api/src/fhir/general_practitioner.py new file mode 100644 index 00000000..6589fffe --- /dev/null +++ b/gateway-api/src/fhir/general_practitioner.py @@ -0,0 +1,21 @@ +"""FHIR GeneralPractitioner type.""" + +from typing import TypedDict + +from fhir.period import Period + + +class GeneralPractitionerIdentifier(TypedDict): + """Identifier for GeneralPractitioner with optional period.""" + + system: str + value: str + period: Period + + +class GeneralPractitioner(TypedDict): + """FHIR GeneralPractitioner reference.""" + + id: str + type: str + identifier: GeneralPractitionerIdentifier diff --git a/gateway-api/src/fhir/human_name.py b/gateway-api/src/fhir/human_name.py index 2a73deb0..6b284c88 100644 --- a/gateway-api/src/fhir/human_name.py +++ b/gateway-api/src/fhir/human_name.py @@ -2,8 +2,11 @@ from typing import TypedDict +from fhir.period import Period + class HumanName(TypedDict): use: str family: str given: list[str] + period: Period diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py index 33d0ce41..f7d6e642 100644 --- a/gateway-api/src/fhir/patient.py +++ b/gateway-api/src/fhir/patient.py @@ -2,6 +2,7 @@ from typing import TypedDict +from fhir.general_practitioner import GeneralPractitioner from fhir.human_name import HumanName from fhir.identifier import Identifier @@ -13,3 +14,4 @@ class Patient(TypedDict): name: list[HumanName] gender: str birthDate: str + generalPractitioner: list[GeneralPractitioner] diff --git a/gateway-api/src/fhir/period.py b/gateway-api/src/fhir/period.py new file mode 100644 index 00000000..6ac40b4f --- /dev/null +++ b/gateway-api/src/fhir/period.py @@ -0,0 +1,10 @@ +"""FHIR Period type.""" + +from typing import NotRequired, TypedDict + + +class Period(TypedDict, total=False): + """FHIR Period type.""" + + start: str + end: NotRequired[str] diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index e49ab8b9..5da3a7e7 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -32,7 +32,14 @@ def valid_simple_response_payload() -> Bundle: { "fullUrl": "https://example.com/Patient/9999999999", "resource": { - "name": [{"family": "Alice", "given": ["Johnson"], "use": "Ally"}], + "name": [ + { + "family": "Alice", + "given": ["Johnson"], + "use": "Ally", + "period": {"start": "2020-01-01"}, + } + ], "gender": "female", "birthDate": "1990-05-15", "resourceType": "Patient", @@ -40,6 +47,17 @@ def valid_simple_response_payload() -> Bundle: "identifier": [ {"value": "9999999999", "system": "urn:nhs:numbers"} ], + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + ], }, } ], diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index b8e22a4b..4eee2a48 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -24,16 +24,12 @@ from typing import cast import requests +from fhir import Bundle, BundleEntry, GeneralPractitioner, HumanName, Patient from stubs.pds.stub import PdsFhirApiStub from gateway_api.common.error import PdsRequestFailed from gateway_api.pds.search_results import PdsSearchResults -# Recursive JSON-like structure typing used for parsed FHIR bodies. -type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] -type ResultStructureDict = dict[str, ResultStructure] -type ResultList = list[ResultStructureDict] - # Type for stub get method type GetCallable = Callable[..., requests.Response] @@ -169,7 +165,9 @@ def search_patient_by_nhs_number( # --------------- internal helpers for result extraction ----------------- - def _get_gp_ods_code(self, general_practitioners: ResultList) -> str | None: + def _get_gp_ods_code( + self, general_practitioners: list[GeneralPractitioner] + ) -> str | None: """ Extract the current GP ODS code from ``Patient.generalPractitioner``. @@ -193,14 +191,12 @@ def _get_gp_ods_code(self, general_practitioners: ResultList) -> str | None: if gp is None: return None - identifier = cast("ResultStructureDict", gp.get("identifier", {})) - ods_code = str(identifier.get("value", None)) + ods_code = gp["identifier"]["value"] - # Avoid returning the literal string "None" if identifier.value is absent. return None if ods_code == "None" else ods_code def _extract_single_search_result( - self, body: ResultStructureDict + self, body: Patient | Bundle ) -> PdsSearchResults | None: """ Extract a single :class:`PdsSearchResults` from a Patient response. @@ -220,9 +216,9 @@ def _extract_single_search_result( # 1) Patient (GET /Patient/{id}) # 2) Bundle with Patient in entry[0].resource (search endpoints) if str(body.get("resourceType", "")) == "Patient": - patient = body + patient = cast("Patient", body) else: - entries: ResultList = cast("ResultList", body.get("entry", [])) + entries = cast("list[BundleEntry]", body.get("entry", [])) if not entries: raise RuntimeError("PDS response contains no patient entries") @@ -232,38 +228,36 @@ def _extract_single_search_result( # application. # See MaxResults parameter in the PDS OpenAPI spec. entry = entries[0] - patient = cast("ResultStructureDict", entry.get("resource", {})) + patient = cast("Patient", entry.get("resource", {})) nhs_number = str(patient.get("id", "")).strip() if not nhs_number: raise RuntimeError("PDS patient resource missing NHS number") - # Select current name record and extract names. - names = cast("ResultList", patient.get("name", [])) - current_name = self.find_current_name_record(names) + current_name = self.find_current_name_record(patient["name"]) if current_name is not None: - given_names_list = cast("list[str]", current_name.get("given", [])) - family_name = str(current_name.get("family", "")) or "" - given_names_str = " ".join(given_names_list).strip() + given_names = " ".join(current_name.get("given", [])).strip() + family_name = current_name.get("family", "") else: - given_names_str = "" + given_names = "" family_name = "" # Extract GP ODS code if a current GP record exists. - gp_list = cast("ResultList", patient.get("generalPractitioner", [])) - gp_ods_code = self._get_gp_ods_code(gp_list) + gp_ods_code = self._get_gp_ods_code(patient["generalPractitioner"]) return PdsSearchResults( - given_names=given_names_str, + given_names=given_names, family_name=family_name, nhs_number=nhs_number, gp_ods_code=gp_ods_code, ) def find_current_gp( - self, records: ResultList, today: date | None = None - ) -> ResultStructureDict | None: + self, + gerneral_practitioners: list[GeneralPractitioner], + today: date | None = None, + ) -> GeneralPractitioner | None: """ Select the current record from a ``generalPractitioner`` list. @@ -291,28 +285,23 @@ def find_current_gp( today = datetime.now(timezone.utc).date() if self.ignore_dates: - if len(records) > 0: - return records[-1] + if len(gerneral_practitioners) > 0: + return gerneral_practitioners[-1] else: return None - for record in records: - identifier = cast("ResultStructureDict", record["identifier"]) - periods = cast("dict[str, str]", identifier["period"]) - start_str = periods["start"] - end_str = periods["end"] - - start = date.fromisoformat(start_str) - end = date.fromisoformat(end_str) - + for record in gerneral_practitioners: + period = record["identifier"]["period"] # TODO: spell check lint + start = date.fromisoformat(period["start"]) + end = date.fromisoformat(period["end"]) if start <= today <= end: return record return None def find_current_name_record( - self, records: ResultList, today: date | None = None - ) -> ResultStructureDict | None: + self, names: list[HumanName], today: date | None = None + ) -> HumanName | None: """ Select the current record from a ``Patient.name`` list. @@ -335,20 +324,16 @@ def find_current_name_record( today = datetime.now(timezone.utc).date() if self.ignore_dates: - if len(records) > 0: - return records[-1] + if len(names) > 0: + return names[-1] else: return None - for record in records: - periods = cast("dict[str, str]", record["period"]) - start_str = periods["start"] - end_str = periods["end"] - - start = date.fromisoformat(start_str) - end = date.fromisoformat(end_str) - + for name in names: + period = cast("dict[str, str]", name["period"]) + start = date.fromisoformat(period["start"]) + end = date.fromisoformat(period["end"]) if start <= today <= end: - return record + return name return None diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index bc65b46f..71b565f9 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import date -from typing import Any, cast +from typing import TYPE_CHECKING, Any from uuid import uuid4 import pytest @@ -13,10 +13,10 @@ from stubs.pds.stub import PdsFhirApiStub from gateway_api.common.error import PdsRequestFailed -from gateway_api.pds.client import ( - PdsClient, - ResultList, # TODO: Use FHIR class here -) +from gateway_api.pds.client import PdsClient + +if TYPE_CHECKING: + from fhir import GeneralPractitioner, HumanName @dataclass @@ -436,23 +436,26 @@ def test_find_current_gp_with_today_override() -> None: pds = PdsClient("test-token", "A12345") pds_ignore_dates = PdsClient("test-token", "A12345", ignore_dates=True) - records = cast( - "ResultList", - [ - { - "identifier": { - "value": "a", - "period": {"start": "2020-01-01", "end": "2020-12-31"}, - } + records: list[GeneralPractitioner] = [ + { + "id": "1234", + "type": "Organization", + "identifier": { + "value": "a", + "period": {"start": "2020-01-01", "end": "2020-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", }, - { - "identifier": { - "value": "b", - "period": {"start": "2021-01-01", "end": "2021-12-31"}, - } + }, + { + "id": "abcd", + "type": "Organization", + "identifier": { + "value": "b", + "period": {"start": "2021-01-01", "end": "2021-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", }, - ], - ) + }, + ] assert pds.find_current_gp(records, today=date(2020, 6, 1)) == records[0] assert pds.find_current_gp(records, today=date(2021, 6, 1)) == records[1] @@ -470,23 +473,20 @@ def test_find_current_name_record_no_current_name() -> None: pds = PdsClient("test-token", "A12345") pds_ignore_date = PdsClient("test-token", "A12345", ignore_dates=True) - records = cast( - "ResultList", - [ - { - "use": "official", - "family": "Doe", - "given": ["John"], - "period": {"start": "2000-01-01", "end": "2010-12-31"}, - }, - { - "use": "official", - "family": "Smith", - "given": ["John"], - "period": {"start": "2011-01-01", "end": "2020-12-31"}, - }, - ], - ) + records: list[HumanName] = [ + { + "use": "official", + "family": "Doe", + "given": ["John"], + "period": {"start": "2000-01-01", "end": "2010-12-31"}, + }, + { + "use": "official", + "family": "Smith", + "given": ["John"], + "period": {"start": "2011-01-01", "end": "2020-12-31"}, + }, + ] assert pds.find_current_name_record(records) is None assert pds_ignore_date.find_current_name_record(records) is not None @@ -568,29 +568,26 @@ def test_find_current_name_record_ignore_dates_returns_last_or_none() -> None: """ pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) - records = cast( - "ResultList", - [ - { - "use": "official", - "family": "Old", - "given": ["First"], - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - }, - { - "use": "official", - "family": "Newer", - "given": ["Second"], - "period": {"start": "1901-01-01", "end": "1901-12-31"}, - }, - ], - ) + records: list[HumanName] = [ + { + "use": "official", + "family": "Old", + "given": ["First"], + "period": {"start": "1900-01-01", "end": "1900-12-31"}, + }, + { + "use": "official", + "family": "Newer", + "given": ["Second"], + "period": {"start": "1901-01-01", "end": "1901-12-31"}, + }, + ] # Pick a date that is not covered by any record; ignore_dates should still pick last chosen = pds_ignore.find_current_name_record(records, today=date(2026, 1, 1)) assert chosen == records[-1] - assert pds_ignore.find_current_name_record(cast("ResultList", [])) is None + assert pds_ignore.find_current_name_record([]) is None def test_find_current_gp_ignore_dates_returns_last_or_none() -> None: @@ -601,26 +598,29 @@ def test_find_current_gp_ignore_dates_returns_last_or_none() -> None: """ pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) - records = cast( - "ResultList", - [ - { - "identifier": { - "value": "GP-OLD", - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - } + records: list[GeneralPractitioner] = [ + { + "id": "abcd", + "type": "Organization", + "identifier": { + "value": "GP-OLD", + "period": {"start": "1900-01-01", "end": "1900-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", }, - { - "identifier": { - "value": "GP-NEWER", - "period": {"start": "1901-01-01", "end": "1901-12-31"}, - } + }, + { + "id": "1234", + "type": "Organization", + "identifier": { + "value": "GP-NEWER", + "period": {"start": "1901-01-01", "end": "1901-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", }, - ], - ) + }, + ] # Pick a date that is not covered by any record; ignore_dates should still pick last chosen = pds_ignore.find_current_gp(records, today=date(2026, 1, 1)) assert chosen == records[-1] - assert pds_ignore.find_current_gp(cast("ResultList", [])) is None + assert pds_ignore.find_current_gp([]) is None diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index b3743e6b..4657f3c5 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -68,8 +68,27 @@ def test_valid_get_structured_record_request_returns_bundle( { "fullUrl": "https://example.com/Patient/9999999999", "resource": { + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": { + "start": "2020-01-01", + "end": "9999-12-31", + }, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + ], "name": [ - {"family": "Alice", "given": ["Johnson"], "use": "Ally"} + { + "family": "Alice", + "given": ["Johnson"], + "use": "Ally", + "period": {"start": "2020-01-01"}, + } ], "gender": "female", "birthDate": "1990-05-15", From 39096cba54272de4587ba7f623cc3859af2863d4 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:29:07 +0000 Subject: [PATCH 30/40] [GPCAPIM-275]: Update terraform. --- .tool-versions | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.tool-versions b/.tool-versions index 253dc21c..fcbb5c74 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,6 +1,6 @@ # This file is for you! Please, updated to the versions agreed by your team. -terraform 1.14.0 +terraform 1.14.5 pre-commit 3.6.0 gitleaks 8.18.4 @@ -15,7 +15,7 @@ gitleaks 8.18.4 # docker/ghcr.io/make-ops-tools/gocloc latest@sha256:6888e62e9ae693c4ebcfed9f1d86c70fd083868acb8815fe44b561b9a73b5032 # SEE: https://github.com/make-ops-tools/gocloc/pkgs/container/gocloc # docker/ghcr.io/nhs-england-tools/github-runner-image 20230909-321fd1e-rt@sha256:ce4fd6035dc450a50d3cbafb4986d60e77cb49a71ab60a053bb1b9518139a646 # SEE: https://github.com/nhs-england-tools/github-runner-image/pkgs/container/github-runner-image # docker/hadolint/hadolint 2.12.0-alpine@sha256:7dba9a9f1a0350f6d021fb2f6f88900998a4fb0aaf8e4330aa8c38544f04db42 # SEE: https://hub.docker.com/r/hadolint/hadolint/tags -# docker/hashicorp/terraform 1.12.2@sha256:b3d13c9037d2bd858fe10060999aa7ca56d30daafe067d7715b29b3d4f5b162f # SEE: https://hub.docker.com/r/hashicorp/terraform/tags +# docker/hashicorp/terraform 1.14.5@sha256:96d2bc440714bf2b2f2998ac730fd4612f30746df43fca6f0892b2e2035b11bc # SEE: https://hub.docker.com/r/hashicorp/terraform/tags # docker/koalaman/shellcheck latest@sha256:e40388688bae0fcffdddb7e4dea49b900c18933b452add0930654b2dea3e7d5c # SEE: https://hub.docker.com/r/koalaman/shellcheck/tags # docker/mstruebing/editorconfig-checker 2.7.1@sha256:dd3ca9ea50ef4518efe9be018d669ef9cf937f6bb5cfe2ef84ff2a620b5ddc24 # SEE: https://hub.docker.com/r/mstruebing/editorconfig-checker/tags # docker/sonarsource/sonar-scanner-cli 10.0@sha256:0bc49076468d2955948867620b2d98d67f0d59c0fd4a5ef1f0afc55cf86f2079 # SEE: https://hub.docker.com/r/sonarsource/sonar-scanner-cli/tags From 1879d55173be93d6ffb3d31fb2ff2ce4e7c5a5ef Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:03:28 +0000 Subject: [PATCH 31/40] [GPCAPIM-275]: Add debug flask app task --- .vscode/launch.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..157b11d2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Flask", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/infrastructure/images/gateway-api/resources/build/gateway-api/gateway_api/app.py", + "env": { + "FLASK_HOST": "0.0.0.0", + "FLASK_PORT": "8080", + }, + "jinja": true, + "console": "integratedTerminal" + } + ] +} From 553e8a69f7ee357380f98b3102e8bfb313765c73 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:05:41 +0000 Subject: [PATCH 32/40] [GPCAPIM-275]: Move Conteroller.run() tests to integration test suite. --- gateway-api/openapi.yaml | 25 + gateway-api/src/gateway_api/common/error.py | 19 +- gateway-api/src/gateway_api/pds/client.py | 2 +- .../src/gateway_api/provider/client.py | 4 +- .../src/gateway_api/provider/test_client.py | 30 +- gateway-api/src/gateway_api/test_app.py | 2 +- .../src/gateway_api/test_controller.py | 617 ++---------------- .../stubs/stubs/data/patients/__init__.py | 11 +- .../blank_asid_sds_result_9000000011.json | 34 + .../blank_endpoint_sds_result_9000000013.json | 34 + .../induce_provider_error_9000000012.json | 34 + .../patients/no_sds_result_9000000010.json | 34 + .../none_consumer_sds_result_9000000014.json | 34 + gateway-api/stubs/stubs/pds/stub.py | 25 +- gateway-api/stubs/stubs/provider/stub.py | 40 +- .../integration/test_get_structured_record.py | 321 ++++++++- 16 files changed, 660 insertions(+), 606 deletions(-) create mode 100644 gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json create mode 100644 gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json create mode 100644 gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json create mode 100644 gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json create mode 100644 gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index b9c73434..ef29b2c1 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -247,3 +247,28 @@ paths: diagnostics: type: string example: "Internal server error" + + '502': + description: Received an error response from a downstream server + content: + application/fhir+json: + schema: + type: object + properties: + resourceType: + type: string + example: "OperationOutcome" + issue: + type: array + items: + type: object + properties: + severity: + type: string + example: "error" + code: + type: string + example: "exception" + diagnostics: + type: string + example: "PDS FHIR API request failed: Bad Gateway" diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 7a347653..fc4d5402 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -1,6 +1,7 @@ import json from dataclasses import dataclass -from http.client import BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND +from enum import StrEnum +from http.client import BAD_GATEWAY, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND from typing import TYPE_CHECKING from flask import Response @@ -9,12 +10,17 @@ from fhir.operation_outcome import OperationOutcome +class ErrorCode(StrEnum): + INVALID = "invalid" + EXCEPTION = "exception" + + @dataclass class BaseError(Exception): _message = "Internal Server Error" status_code: int = INTERNAL_SERVER_ERROR severity: str = "error" - error_code: str = "exception" + error_code: ErrorCode = ErrorCode.EXCEPTION def __init__(self, **additional_details: str): self.additional_details = additional_details @@ -56,6 +62,7 @@ class NoPatientFound(BaseError): class InvalidRequestJSON(BaseError): _message = "Invalid JSON body sent in request" + error_code = ErrorCode.INVALID status_code = BAD_REQUEST @@ -91,9 +98,9 @@ class NoCurrentEndpoint(BaseError): class PdsRequestFailed(BaseError): _message = "PDS FHIR API request failed: {error_reason}" - status_code = INTERNAL_SERVER_ERROR + status_code = BAD_GATEWAY -class SdsRequestFailed(BaseError): - _message = "SDS FHIR API request failed: {error_reason}" - status_code = INTERNAL_SERVER_ERROR +class ProviderRequestFailed(BaseError): + _message = "Provider request failed: {error_reason}" + status_code = BAD_GATEWAY diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 4eee2a48..29ff5b7f 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -244,7 +244,7 @@ def _extract_single_search_result( family_name = "" # Extract GP ODS code if a current GP record exists. - gp_ods_code = self._get_gp_ods_code(patient["generalPractitioner"]) + gp_ods_code = self._get_gp_ods_code(patient.get("generalPractitioner", [])) return PdsSearchResults( given_names=given_names, diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index cd88e8c1..89d36382 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -28,7 +28,7 @@ from requests import HTTPError, Response, post from stubs.provider.stub import stub_post -from gateway_api.common.error import SdsRequestFailed +from gateway_api.common.error import ProviderRequestFailed ARS_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:structured" @@ -127,6 +127,6 @@ def access_structured_record( try: response.raise_for_status() except HTTPError as err: - raise SdsRequestFailed(error_reason=err.response.reason) from err + raise ProviderRequestFailed(error_reason=err.response.reason) from err return response diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index d9a566ff..4a22a3ae 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -6,14 +6,16 @@ """ +import json from typing import Any import pytest +from fhir import Parameters from requests import Response from requests.structures import CaseInsensitiveDict from stubs.provider.stub import GpProviderStub -from gateway_api.common.error import SdsRequestFailed +from gateway_api.common.error import ProviderRequestFailed from gateway_api.provider import GpProviderClient, client ars_interactionId = ( @@ -66,6 +68,7 @@ def _fake_post( def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method constructs the correct URL @@ -85,7 +88,9 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos consumer_asid=consumer_asid, ) - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) captured_url = mock_request_post.get("url", provider_endpoint) @@ -98,6 +103,7 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method includes the correct headers @@ -126,7 +132,9 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-InteractionID": ars_interactionId, } - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) captured_headers = mock_request_post["headers"] @@ -136,6 +144,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 def test_valid_gpprovider_access_structured_record_with_correct_body_200( mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method includes the correct body @@ -149,7 +158,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( provider_endpoint = "https://test.com" trace_id = "some_uuid_value" - request_body = "some_FHIR_request_params" + request_body = json.dumps(valid_simple_request_payload) client = GpProviderClient( provider_endpoint=provider_endpoint, @@ -168,6 +177,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( def test_valid_gpprovider_access_structured_record_returns_stub_response_200( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) stub: GpProviderStub, + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method returns the same response @@ -187,9 +197,13 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( consumer_asid=consumer_asid, ) - expected_response = stub.access_record_structured(trace_id, "body") + expected_response = stub.access_record_structured( + trace_id, json.dumps(valid_simple_request_payload) + ) - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) assert result.status_code == 200 assert result.content == expected_response.content @@ -214,7 +228,7 @@ def test_access_structured_record_raises_external_service_error( ) with pytest.raises( - SdsRequestFailed, - match="SDS FHIR API request failed: Bad Request", + ProviderRequestFailed, + match="Provider request failed: Bad Request", ): client.access_structured_record(trace_id, "body") diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 4657f3c5..4f0d18d1 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -212,7 +212,7 @@ def test_get_structured_record_returns_internal_server_error_when_invalid_json_s "issue": [ { "severity": "error", - "code": "exception", + "code": "invalid", "diagnostics": "Invalid JSON body sent in request", } ], diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 2367ba13..8ca854a0 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -1,594 +1,81 @@ -""" -Unit tests for :mod:`gateway_api.controller`. -""" - -from collections.abc import Generator -from dataclasses import dataclass -from types import SimpleNamespace -from typing import Any +"""Unit tests for :mod:`gateway_api.controller`.""" import pytest -from flask import request as flask_request -from requests import Response +from pytest_mock import MockerFixture -import gateway_api.controller as controller_module -from gateway_api.app import app -from gateway_api.common.common import json_str -from gateway_api.common.error import ( - NoAsidFound, - NoCurrentEndpoint, - NoCurrentProvider, - NoOrganisationFound, - NoPatientFound, -) +from gateway_api.common.error import NoCurrentProvider from gateway_api.controller import Controller -from gateway_api.get_structured_record.request import GetStructuredRecordRequest +from gateway_api.pds import PdsSearchResults from gateway_api.sds import SdsSearchResults -# ----------------------------- -# Fake downstream dependencies -# ----------------------------- -def _make_pds_result(gp_ods_code: str | None) -> Any: - """ - Construct a minimal PDS-result-like object for tests. - - The controller only relies on the ``gp_ods_code`` attribute. - - :param gp_ods_code: Provider ODS code to expose on the result. - :returns: An object with a ``gp_ods_code`` attribute. - """ - return SimpleNamespace(gp_ods_code=gp_ods_code) - - -class FakePdsClient: - """ - Test double for :class:`gateway_api.pds_search.PdsClient`. - - The controller instantiates this class and calls ``search_patient_by_nhs_number``. - Tests configure the returned patient details using ``set_patient_details``. - """ - - last_init: dict[str, Any] | None = None - - def __init__(self, **kwargs: Any) -> None: - FakePdsClient.last_init = dict(kwargs) - self._patient_details: Any | None = None - - def set_patient_details(self, value: Any) -> None: - self._patient_details = value - - def search_patient_by_nhs_number( - self, - nhs_number: int, # noqa: ARG002 (unused in fake) - ) -> Any | None: - return self._patient_details - - -class FakeSdsClient: - """ - Test double for :class:`gateway_api.controller.SdsClient`. - - Tests configure per-ODS results using ``set_org_details`` and the controller - retrieves them via ``get_org_details``. - """ - - last_init: dict[str, Any] | None = None - - def __init__( - self, - auth_token: str | None = None, - base_url: str = "test_url", - timeout: int = 10, - ) -> None: - FakeSdsClient.last_init = { - "auth_token": auth_token, - "base_url": base_url, - "timeout": timeout, - } - self.auth_token = auth_token - self.base_url = base_url - self.timeout = timeout - self._org_details_by_ods: dict[str, SdsSearchResults | None] = {} - - def set_org_details( - self, ods_code: str, org_details: SdsSearchResults | None - ) -> None: - self._org_details_by_ods[ods_code] = org_details - - def get_org_details(self, ods_code: str) -> SdsSearchResults | None: - return self._org_details_by_ods.get(ods_code) - - -class FakeGpProviderClient: - """ - Test double for :class:`gateway_api.controller.GpProviderClient`. - - The controller instantiates this class and calls ``access_structured_record``. - Tests configure the returned HTTP response using class-level attributes. - """ - - last_init: dict[str, str] | None = None - last_call: dict[str, str] | None = None - - # Configure per-test. - return_none: bool = False - response_status_code: int = 200 - response_body: bytes = b"ok" - response_headers: dict[str, str] = {"Content-Type": "application/fhir+json"} - - def __init__( - self, provider_endpoint: str, provider_asid: str, consumer_asid: str - ) -> None: - FakeGpProviderClient.last_init = { - "provider_endpoint": provider_endpoint, - "provider_asid": provider_asid, - "consumer_asid": consumer_asid, - } - - def access_structured_record( - self, - trace_id: str, - body: json_str, - ) -> Response | None: - FakeGpProviderClient.last_call = {"trace_id": trace_id, "body": body} - - if FakeGpProviderClient.return_none: - return None - - resp = Response() - resp.status_code = FakeGpProviderClient.response_status_code - resp._content = FakeGpProviderClient.response_body # noqa: SLF001 - resp.encoding = "utf-8" - resp.headers.update(FakeGpProviderClient.response_headers) - resp.url = "https://example.invalid/fake" - return resp - - -@dataclass -class SdsSetup: - """ - Helper dataclass to hold SDS setup data for tests. - """ - - ods_code: str - search_results: SdsSearchResults - - -class sds_factory: - """ - Factory to create a :class:`FakeSdsClient` pre-configured with up to two - organisations. - """ - - def __init__( - self, - org1: SdsSetup | None = None, - org2: SdsSetup | None = None, - ) -> None: - self.org1 = org1 - self.org2 = org2 - - def __call__(self, **kwargs: Any) -> FakeSdsClient: - self.inst = FakeSdsClient(**kwargs) - if self.org1 is not None: - self.inst.set_org_details( - self.org1.ods_code, - SdsSearchResults( - asid=self.org1.search_results.asid, - endpoint=self.org1.search_results.endpoint, - ), - ) - - if self.org2 is not None: - self.inst.set_org_details( - self.org2.ods_code, - SdsSearchResults( - asid=self.org2.search_results.asid, - endpoint=self.org2.search_results.endpoint, - ), - ) - return self.inst - - -class pds_factory: - """ - Factory to create a :class:`FakePdsClient` pre-configured with patient details. - """ - - def __init__(self, ods_code: str | None) -> None: - self.ods_code = ods_code - - def __call__(self, **kwargs: Any) -> FakePdsClient: - self.inst = FakePdsClient(**kwargs) - self.inst.set_patient_details(_make_pds_result(self.ods_code)) - return self.inst - - -@pytest.fixture -def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: - """ - Patch controller dependencies to use test fakes. - """ - monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) - monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) - monkeypatch.setattr(controller_module, "GpProviderClient", FakeGpProviderClient) - - -@pytest.fixture -def controller() -> Controller: - """ - Construct a controller instance configured for unit tests. - """ - return Controller( - pds_base_url="https://pds.example", - sds_base_url="https://sds.example", - timeout=3, - ) - - -@pytest.fixture -def gp_provider_returns_none() -> Generator[None, None, None]: - """ - Configure FakeGpProviderClient to return None and reset after the test. - """ - FakeGpProviderClient.return_none = True - yield - FakeGpProviderClient.return_none = False - - -@pytest.fixture -def get_structured_record_request( - request: pytest.FixtureRequest, -) -> GetStructuredRecordRequest: - # Pass two dicts to this fixture that give dicts to add to - # header and body respectively. - header_update, body_update = request.param - - headers = { - "Ssp-TraceID": "3d7f2a6e-0f4e-4af3-9b7b-2a3d5f6a7b8c", - "ODS-from": "CONSUMER", - } - - headers.update(header_update) - - body = { - "resourceType": "Parameters", - "parameter": [ - { - "valueIdentifier": { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - }, - } - ], - } - - body.update(body_update) - - with app.test_request_context( - path="/patient/$gpc.getstructuredrecord", - method="POST", - headers=headers, - json=body, - ): - return GetStructuredRecordRequest(flask_request) - - -# ----------------------------- -# Unit tests -# ----------------------------- - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_controller_run_raises_error_when_request_body_is_empty( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If PDS returns no patient record, the controller should return 404. - """ - with pytest.raises( - NoPatientFound, match="No PDS patient found for NHS number 9999999999" - ): - _ = controller.run(get_structured_record_request) - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_gp_ods_code_missing( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If PDS returns a patient without a provider (GP) ODS code, return 404. - """ - pds = pds_factory(ods_code="") - monkeypatch.setattr(controller_module, "PdsClient", pds) - - with pytest.raises( - NoCurrentProvider, - match="PDS patient 9999999999 did not contain a current provider ODS code", - ): - _ = controller.run(get_structured_record_request) - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If SDS returns no provider org details, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds = sds_factory() - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - with pytest.raises( - NoOrganisationFound, - match="No SDS org found for provider ODS code PROVIDER", - ): - _ = controller.run(get_structured_record_request) - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If provider ASID is blank/whitespace, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid=" ", endpoint="https://provider.example/ep" - ), - ) - sds = sds_factory(org1=sds_org1) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - with pytest.raises( - NoAsidFound, - match=( - "SDS result for provider ODS code PROVIDER did not contain a current ASID" - ), - ): - _ = controller.run(get_structured_record_request) - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_502_when_gp_provider_returns_none( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, - gp_provider_returns_none: None, # NOQA ARG001 (Fixture handling setup/teardown) +def test_get_pds_details_returns_provider_ods_code_for_happy_path( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If GP provider returns no response object, the controller should return 502. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + nhs_number = "9000000009" + pds_search_result = PdsSearchResults( + given_names="Jane", + family_name="Smith", + nhs_number=nhs_number, + gp_ods_code="A12345", ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + mocker.patch( + "gateway_api.pds.PdsClient.search_patient_by_nhs_number", + return_value=pds_search_result, ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) + controller = Controller(pds_base_url="https://example.test/pds", timeout=7) - r = controller.run(get_structured_record_request) + actual = controller._get_pds_details(auth_token, nhs_number) # noqa: SLF001 - assert r.status_code == 502 - assert r.data == "GP provider service error" - assert r.headers is None - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {"parameter": [{"valueIdentifier": {"value": "1234567890"}}]})], - indirect=["get_structured_record_request"], -) -def test_controller_run_raises_patient_not_found_error_when_patient_doesnt_exist( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If PDS returns no patient record, error message should include NHS number parsed - from the FHIR Parameters request body. - """ - with pytest.raises( - NoPatientFound, match="No PDS patient found for NHS number 1234567890" - ): - _ = controller.run(get_structured_record_request) + assert actual == "A12345" -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_provider_endpoint_blank( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_pds_details_raises_no_current_provider_when_ods_code_missing_in_pds( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If provider endpoint is blank/whitespace, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults(asid="asid_PROV", endpoint=" "), + nhs_number = "9000000009" + pds_search_result_without_ods_code = PdsSearchResults( + given_names="Jane", + family_name="Smith", + nhs_number=nhs_number, + gp_ods_code=None, ) - sds = sds_factory(org1=sds_org1) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - with pytest.raises( - NoCurrentEndpoint, - match=( - "SDS result for provider ODS code PROVIDER did not contain " - "a current endpoint" - ), - ): - _ = controller.run(get_structured_record_request) - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If SDS returns no consumer org details, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + mocker.patch( + "gateway_api.pds.PdsClient.search_patient_by_nhs_number", + return_value=pds_search_result_without_ods_code, ) - sds = sds_factory(org1=sds_org1) - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) + controller = Controller() with pytest.raises( - NoOrganisationFound, match="No SDS org found for consumer ODS code CONSUMER" + NoCurrentProvider, + match="PDS patient 9000000009 did not contain a current provider ODS code", ): - _ = controller.run(get_structured_record_request) + _ = controller._get_pds_details(auth_token, nhs_number) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_returns_consumer_and_provider_deatils_for_happy_path( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If consumer ASID is blank/whitespace, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + provider_ods = "ProviderODS" + provider_sds_results = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid=" ", endpoint=None), + consumer_ods = "ConsumerODS" + consumer_sds_results = SdsSearchResults( + asid="ConsumerASID", endpoint="https://example.consumer.org/endpoint" ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - with pytest.raises( - NoAsidFound, - match=( - "SDS result for consumer ODS code CONSUMER did not contain a current ASID" - ), - ): - _ = controller.run(get_structured_record_request) - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_passthroughs_non_200_gp_provider_response( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - Validate that non-200 responses from GP provider are passed through. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + sds_results = [provider_sds_results, consumer_sds_results] + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=sds_results, ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), - ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - FakeGpProviderClient.response_status_code = 404 - FakeGpProviderClient.response_body = b"Not Found" - FakeGpProviderClient.response_headers = { - "Content-Type": "text/plain", - "X-Downstream": "gp-provider", - } - r = controller.run(get_structured_record_request) + controller = Controller() - assert r.status_code == 404 - assert r.data == "Not Found" - assert r.headers is not None - assert r.headers.get("Content-Type") == "text/plain" - assert r.headers.get("X-Downstream") == "gp-provider" + expected = ("ConsumerASID", "ProviderASID", "https://example.provider.org/endpoint") + actual = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 + assert actual == expected diff --git a/gateway-api/stubs/stubs/data/patients/__init__.py b/gateway-api/stubs/stubs/data/patients/__init__.py index 27cc2751..a1595f52 100644 --- a/gateway-api/stubs/stubs/data/patients/__init__.py +++ b/gateway-api/stubs/stubs/data/patients/__init__.py @@ -15,5 +15,14 @@ def load_patient(filename: str) -> dict[str, Any]: return patient JANE_SMITH_9000000009 = load_patient("jane_smith_9000000009.json") - + NO_SDS_RESULT_9000000010 = load_patient("no_sds_result_9000000010.json") + BLANK_ASID_SDS_RESULT_9000000011 = load_patient( + "blank_asid_sds_result_9000000011.json" + ) + INDUCE_PROVIDER_ERROR_9000000012 = load_patient( + "induce_provider_error_9000000012.json" + ) + BLANK_ENDPOINT_SDS_RESULT_9000000013 = load_patient( + "blank_endpoint_sds_result_9000000013.json" + ) ALICE_JONES_9999999999 = load_patient("alice_jones_9999999999.json") diff --git a/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json b/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json new file mode 100644 index 00000000..58b47242 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000011", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000011" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "BlankAsidInSDS", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json b/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json new file mode 100644 index 00000000..1e3645b6 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000013", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000013" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "BlankEndpointInSDS", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json b/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json new file mode 100644 index 00000000..d173540e --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000012", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000012" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "InduceProviderError", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json b/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json new file mode 100644 index 00000000..f43198ba --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000010", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000010" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "DoesNotExistInSDS", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json b/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json new file mode 100644 index 00000000..6834ebe6 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000013", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000013" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "BlankConsumerRequest", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/pds/stub.py b/gateway-api/stubs/stubs/pds/stub.py index e4b336c0..1a77b2f2 100644 --- a/gateway-api/stubs/stubs/pds/stub.py +++ b/gateway-api/stubs/stubs/pds/stub.py @@ -70,17 +70,20 @@ def __init__(self, strict_headers: bool = True) -> None: # Seed a deterministic example matching the spec's id example. # Tests may overwrite this record via upsert_patient. - self.upsert_patient( - nhs_number="9000000009", - patient=Patients.JANE_SMITH_9000000009, - version_id=1, - ) - - self.upsert_patient( - nhs_number="9999999999", - patient=Patients.ALICE_JONES_9999999999, - version_id=1, - ) + test_patients = [ + ("9999999999", Patients.ALICE_JONES_9999999999), + ("9000000009", Patients.JANE_SMITH_9000000009), + ("9000000010", Patients.NO_SDS_RESULT_9000000010), + ("9000000011", Patients.BLANK_ASID_SDS_RESULT_9000000011), + ("9000000012", Patients.INDUCE_PROVIDER_ERROR_9000000012), + ("9000000013", Patients.BLANK_ENDPOINT_SDS_RESULT_9000000013), + ] + for nhs_number, patient in test_patients: + self.upsert_patient( + nhs_number=nhs_number, + patient=patient, + version_id=1, + ) # --------------------------- # Public API for tests diff --git a/gateway-api/stubs/stubs/provider/stub.py b/gateway-api/stubs/stubs/provider/stub.py index cc723fa3..27ee9c41 100644 --- a/gateway-api/stubs/stubs/provider/stub.py +++ b/gateway-api/stubs/stubs/provider/stub.py @@ -78,13 +78,6 @@ def access_record_structured( Response: The stub patient bundle wrapped in a Response object. """ - stub_response = _create_response( - status_code=200, - headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), - content=json.dumps(Bundles.ALICE_JONES_9999999999).encode("utf-8"), - reason="OK", - ) - if trace_id == "invalid for test": return _create_response( status_code=400, @@ -97,7 +90,38 @@ def access_record_structured( reason="Bad Request", ) - return stub_response + try: + nhs_number = json.loads(body)["parameter"][0]["valueIdentifier"]["value"] + except (json.JSONDecodeError, KeyError, IndexError): + return _create_response( + status_code=400, + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + content=( + b'{"resourceType":"OperationOutcome","issue":[' + b'{"severity":"error","code":"invalid",' + b'"diagnostics":"Malformed request body"}]}' + ), + reason="Bad Request", + ) + + if nhs_number == "9999999999": + return _create_response( + status_code=200, + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + content=json.dumps(Bundles.ALICE_JONES_9999999999).encode("utf-8"), + reason="OK", + ) + + return _create_response( + status_code=404, + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + content=( + b'{"resourceType":"OperationOutcome","issue":[' + b'{"severity":"error","code":"not-found",' + b'"diagnostics":"Patient not found"}]}' + ), + reason="Not Found", + ) def stub_post( diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index a776f0a4..2402a651 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -1,8 +1,11 @@ """Integration tests for the gateway API using pytest.""" import json +from collections.abc import Callable +import pytest from fhir.parameters import Parameters +from requests import Response from stubs.data.bundles import Bundles from tests.conftest import Client @@ -12,7 +15,6 @@ class TestGetStructuredRecord: def test_happy_path_returns_200( self, client: Client, simple_request_payload: Parameters ) -> None: - """Test that the root endpoint returns a 200 status code.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) @@ -23,7 +25,6 @@ def test_happy_path_returns_correct_message( client: Client, simple_request_payload: Parameters, ) -> None: - """Test that the root endpoint returns the correct message.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) @@ -32,8 +33,322 @@ def test_happy_path_returns_correct_message( def test_happy_path_content_type( self, client: Client, simple_request_payload: Parameters ) -> None: - """Test that the response has the correct content type.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) assert "application/fhir+json" in response.headers["Content-Type"] + + def test_empty_request_body_returns_400_status_code( + self, response_from_sending_request_with_empty_body: Response + ) -> None: + assert response_from_sending_request_with_empty_body.status_code == 400 + + def test_empty_request_body_returns_invalid_request_json_message( + self, response_from_sending_request_with_empty_body: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Invalid JSON body sent in request", + } + ], + } + assert response_from_sending_request_with_empty_body.json() == expected + + def test_patient_without_gp_returns_404_status_code( + self, response_from_requesting_patient_without_gp: Response + ) -> None: + assert response_from_requesting_patient_without_gp.status_code == 404 + + def test_patient_without_gp_returns_no_current_provider_message( + self, response_from_requesting_patient_without_gp: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ( + "PDS patient 9000000009 did not contain a " + "current provider ODS code" + ), + } + ], + } + assert response_from_requesting_patient_without_gp.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_no_provider_from_sds_returns_404_status_code( + self, response_when_sds_returns_no_provider: Response + ) -> None: + assert response_when_sds_returns_no_provider.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_no_provider_from_sds_returns_no_organisation_found_error( + self, response_when_sds_returns_no_provider: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ( + "No organisation found for ODS code DoesNotExistInSDS" + ), + } + ], + } + assert response_when_sds_returns_no_provider.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_provider_asid_from_sds_returns_404_status_code( + self, response_when_sds_returns_blank_provider_asid: Response + ) -> None: + assert response_when_sds_returns_blank_provider_asid.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_provider_asid_from_sds_returns_no_asid_found_error( + self, response_when_sds_returns_blank_provider_asid: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ("No ASID found for ODS code DoesNotExistInSDS"), + } + ], + } + assert response_when_sds_returns_blank_provider_asid.json() == expected + + def test_502_status_code_return_when_provider_returns_error( + self, response_when_provider_returns_error: Response + ) -> None: + assert response_when_provider_returns_error.status_code == 502 + + def test_internal_server_error_message_returned_when_provider_returns_error( + self, response_when_provider_returns_error: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": "Provider request failed: Not Found", + } + ], + } + assert response_when_provider_returns_error.json() == expected + + def test_nhs_number_that_does_not_exist_returns_502_status_code( + self, response_when_nhs_number_does_not_exist: Response + ) -> None: + assert response_when_nhs_number_does_not_exist.status_code == 502 + + def test_nhs_number_that_does_not_exist_returns_no_patient_found_error( + self, response_when_nhs_number_does_not_exist: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": "PDS FHIR API request failed: Not Found", + } + ], + } + assert response_when_nhs_number_does_not_exist.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_sds_endpoint_blank_returns_404_status_code( + self, response_when_sds_endpoint_blank: Response + ) -> None: + assert response_when_sds_endpoint_blank.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_sds_endpoint_blank_returns_no_current_endpoint_error( + self, response_when_sds_endpoint_blank: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ( + "No current endpoint found for ODS code DoesNotExistInSDS" + ), + } + ], + } + assert response_when_sds_endpoint_blank.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_consumer_is_none_from_sds_returns_404_status_code( + self, response_when_consumer_is_none_from_sds: Response + ) -> None: + assert response_when_consumer_is_none_from_sds.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_consumer_is_none_from_sds_returns_no_organisation_found_error( + self, response_when_consumer_is_none_from_sds: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ("No SDS org found for consumer ODS code CONSUMER"), + } + ], + } + assert response_when_consumer_is_none_from_sds.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_consumer_asid_from_sds_returns_404_status_code( + self, response_when_blank_consumer_asid_from_sds: Response + ) -> None: + assert response_when_blank_consumer_asid_from_sds.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_consumer_asid_from_sds_returns_no_asid_found_error( + self, response_when_blank_consumer_asid_from_sds: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ("No ASID found for consumer ODS code CONSUMER"), + } + ], + } + assert response_when_blank_consumer_asid_from_sds.json() == expected + + @pytest.fixture + def response_from_sending_request_with_empty_body(self, client: Client) -> Response: + response = client.send_to_get_structured_record_endpoint(payload="") + return response + + @pytest.fixture + def response_from_requesting_patient_without_gp( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_unregistered_patient = "9000000009" + response = get_structured_record_requestor(nhs_number_for_unregistered_patient) + return response + + @pytest.fixture + def response_when_sds_returns_no_provider( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_patient_with_gp_not_in_sds = "9000000010" + response = get_structured_record_requestor( + nhs_number_for_patient_with_gp_not_in_sds + ) + return response + + @pytest.fixture + def response_when_sds_returns_blank_provider_asid( + self, client: Client, simple_request_payload: Parameters + ) -> Response: + ods_from_for_consumer_with_blank_provider_asid_in_sds = "BlankProviderAsidInSDS" + headers = {"Ods-From": ods_from_for_consumer_with_blank_provider_asid_in_sds} + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload), headers=headers + ) + return response + + @pytest.fixture + def response_when_sds_returns_blank_consumer_asid( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_patient_with_gp_with_blank_consumer_asid_in_sds = "9000000015" + response = get_structured_record_requestor( + nhs_number_for_patient_with_gp_with_blank_consumer_asid_in_sds + ) + return response + + @pytest.fixture + def response_when_provider_returns_error( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_inducing_error_in_provider = "9000000012" + response = get_structured_record_requestor( + nhs_number_for_inducing_error_in_provider + ) + return response + + @pytest.fixture + def response_when_nhs_number_does_not_exist( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_that_does_not_exist = "1234567890" + response = get_structured_record_requestor(nhs_number_that_does_not_exist) + return response + + @pytest.fixture + def response_when_sds_endpoint_blank( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_patient_with_gp_with_blank_endpoint = "9000000013" + response = get_structured_record_requestor( + nhs_number_for_patient_with_gp_with_blank_endpoint + ) + return response + + @pytest.fixture + def response_when_consumer_is_none_from_sds( + self, client: Client, simple_request_payload: Parameters + ) -> Response: + ods_from_for_consumer_with_none_consumer_in_sds = "ConsumerWithNoneInSDS" + headers = {"Ods-From": ods_from_for_consumer_with_none_consumer_in_sds} + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload), headers=headers + ) + return response + + @pytest.fixture + def get_structured_record_requestor( + self, client: Client, simple_request_payload: Parameters + ) -> Callable[[str], Response]: + def requestor(nhs_number: str) -> Response: + simple_request_payload["parameter"][0]["valueIdentifier"]["value"] = ( + nhs_number + ) + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + return response + + return requestor From fef17b399f1ccbf3657a9bbcdb5504dd79897258 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:53:44 +0000 Subject: [PATCH 33/40] [GPCAPIM-275]: Reduce reliance on mocking in unit tests; move STUB PDS into env var --- .vscode/launch.json | 5 +- gateway-api/poetry.lock | 109 +++-- gateway-api/src/fhir/patient.py | 4 +- gateway-api/src/gateway_api/conftest.py | 33 +- gateway-api/src/gateway_api/pds/client.py | 27 +- .../src/gateway_api/pds/test_client.py | 426 +++++------------- gateway-api/stubs/stubs/pds/stub.py | 2 +- infrastructure/images/gateway-api/Dockerfile | 1 + 8 files changed, 238 insertions(+), 369 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 157b11d2..c66de352 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,10 +10,7 @@ "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/infrastructure/images/gateway-api/resources/build/gateway-api/gateway_api/app.py", - "env": { - "FLASK_HOST": "0.0.0.0", - "FLASK_PORT": "8080", - }, + "envFile": "${workspaceFolder}/.env", "jinja": true, "console": "integratedTerminal" } diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index b4c4e471..cfb02e6d 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -51,18 +51,6 @@ files = [ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] -[[package]] -name = "backoff" -version = "2.2.1" -description = "Function decoration for backoff and retry" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["dev"] -files = [ - {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, - {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, -] - [[package]] name = "blinker" version = "1.9.0" @@ -628,14 +616,14 @@ zoneinfo = ["tzdata (>=2025.2) ; sys_platform == \"win32\" or sys_platform == \" [[package]] name = "hypothesis-graphql" -version = "0.11.1" +version = "0.12.0" description = "Hypothesis strategies for GraphQL queries" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "hypothesis_graphql-0.11.1-py3-none-any.whl", hash = "sha256:a6968f703bcdc31fbe1b26be69185aa2c824eb3b478057a66aa85967c81cadca"}, - {file = "hypothesis_graphql-0.11.1.tar.gz", hash = "sha256:bd49ab6804a3f488ecab2e39c20dba6dfc2101525c6742f5831cfa9eff95285a"}, + {file = "hypothesis_graphql-0.12.0-py3-none-any.whl", hash = "sha256:d200d3d4320e772248075f13c656f4b1de01e7f0f5e7d9fd6fea7da759b325f3"}, + {file = "hypothesis_graphql-0.12.0.tar.gz", hash = "sha256:15f5f69b6e0b9ad889f59d340e091d7d481471373eb6a8a8591d126aa56e7700"}, ] [package.dependencies] @@ -777,6 +765,48 @@ webcolors = {version = ">=1.11", optional = true, markers = "extra == \"format\" format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] +[[package]] +name = "jsonschema-rs" +version = "0.42.1" +description = "A high-performance JSON Schema validator for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jsonschema_rs-0.42.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7d4c2cf89fb1f49399be7f0e601526f189497f4f7bbefc4fac5f4447ca52609c"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:40d53eea48a17876d6802405edc6e0367f07260545713ac6d727054bce8c425f"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de86325cf5e0d1c35ec14e60ffe2ce4547c8385802ea69ac11540616b822cb2"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c664bd3ffb1cfd70d2b8b8b9587782184a81a8467a70bcc6c71a84cf573ecdf3"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d7dc31fa2b644205271ac1071aec005f88565b135ad1f983b8c1de2589266e1e"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d5602f07fe69f108f7dd9d2d05d940a2498517f07a230da2efb8f36bf06e0703"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:005cd79783a4980ad68d21f7a25c913778dc6a0fe8e3d3c76132eabb7a40287a"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-win32.whl", hash = "sha256:6817e5c1fcb10d80b4dda38cd106850c7e3e9dc06d5afe93668b9c99744723c9"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-win_amd64.whl", hash = "sha256:b508dd9a114352bf8fc20d8e6d01563fbb18f3f877d11e0ecbbb43c556ec4174"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:4849b65048e4fd53991424a827f8369d0b6c7ad1d9ff05bb854afa685131b954"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98ca39207afab8782149810b789c717d5a0bb7bbb6330bd537827eeecbbeccbb"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a363739a254c3990cbea18a7350de3bc06cdf02307ee1db60ff86fd13e1ff58"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:28b235ab6263f96ed2448f645291d446e4433c7fa6cc255bff2dcffd2a2b65a9"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f50e0baabb3d6d1b250ad776ade46c6bf3599c681e961f686a6a50e925322d64"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6abf63a5523e13257a0e3a8cf58d15a46e00fcaaafc6f92c34c9109575529"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e20154d59e843b36e2e7d6b7415954ae3374e23d97a2ad11670a75fe0884c246"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2fa26381601a32439ff46c7588476a0fc4d2a0b97a58da756196fd747b99bf01"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0d76660cd5b143e342c5d98bf9690258171dbe1beba7cdc6354bc736eb286f7e"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52afd2b1ebf7360a0e9ec40b23cda167b92d776ed8f14f1a6c78a0fc3070453d"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:1a7869a0c81a12bf7875235e0d4b95b68392655373c1f443fbbacd0e7cc9d289"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cdbed80ae956fff192bf32b5be902f07a46788b12a038c8093928c1e80035a73"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b6e795ee51b807eea75df5b43a4380f950c6ff3feb586e01b2423b4d31093881"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-win_amd64.whl", hash = "sha256:50bda44a74ddae8bdd1e35785d738c39847b54fcaf4e803f4b51ace095d94a51"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5844afa812db3a61b8994ec562f7a13a62208ec7f9806c34784fc22c945ac87c"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad9a5bfc34394d812f88d3e4b320236fbb9b66b34c88f9ec13f9143f97d562a6"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dce9ddf7084bc7d2fbd1bab4a81d69f99413d001971b56ca26403bafd6da5432"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3cf915dc8eeb304c045fb85e34a97d6d3f563aa75e13d8eb2ab6ebb145b3adc4"}, + {file = "jsonschema_rs-0.42.1.tar.gz", hash = "sha256:4144cd351d39ce457f2c7d45111e7225eb5ed1791e0226dec5b9099d78651e32"}, +] + +[package.extras] +bench = ["fastjsonschema (>=2.20.0)", "jsonschema (>=4.23.0)", "pytest-benchmark (>=4.0.0)"] +tests = ["flask (>=2.2.5)", "hypothesis (>=6.79.4)", "pytest (>=7.4.4)"] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -1482,20 +1512,20 @@ docs = ["furo (>=2022.3.4,<2023.0.0)", "myst-parser (>=0.17)", "sphinx (>=4.3.0, [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" @@ -1940,43 +1970,44 @@ files = [ [[package]] name = "schemathesis" -version = "4.4.1" +version = "4.10.2" description = "Property-based testing framework for Open API and GraphQL based apps" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "schemathesis-4.4.1-py3-none-any.whl", hash = "sha256:6b68170cef21b001cc43a244ed5aaaf62b5d6826984c8ae09a495047d47cf065"}, - {file = "schemathesis-4.4.1.tar.gz", hash = "sha256:e248edf5e6d5a47babf70133593064104ceddf45019e2bd89b1da5150eec4c61"}, + {file = "schemathesis-4.10.2-py3-none-any.whl", hash = "sha256:47a1f32a81dd237dbeb1da4374e48dd4402813c4c75fa23091a4f0986a8616be"}, + {file = "schemathesis-4.10.2.tar.gz", hash = "sha256:ad69508a9dd1a5b6fd6f4891abe86a9fc5f3f0d7a1133353359aadfd9522ac1f"}, ] [package.dependencies] -backoff = ">=2.1.2,<3.0" click = ">=8.0,<9" colorama = ">=0.4,<1.0" harfile = ">=0.4.0,<1.0" httpx = ">=0.22.0,<1.0" hypothesis = ">=6.108.0,<7" -hypothesis-graphql = ">=0.11.1,<1" +hypothesis-graphql = ">=0.12.0,<1" hypothesis-jsonschema = ">=0.23.1,<0.24" jsonschema = {version = ">=4.18.0,<5.0", extras = ["format"]} +jsonschema-rs = ">=0.41.0" junit-xml = ">=1.9,<2.0" pyrate-limiter = ">=3.0,<4.0" -pytest = ">=8,<9" -pytest-subtests = ">=0.11,<0.15.0" +pytest = ">=8,<10" +pytest-subtests = ">=0.11,<0.16.0" pyyaml = ">=5.1,<7.0" requests = ">=2.22,<3" rich = ">=13.9.4" starlette-testclient = ">=0.4.1,<1" +tenacity = ">=9.1.2,<10.0" typing-extensions = ">=4.12.2" werkzeug = ">=0.16.0,<4" [package.extras] bench = ["pytest-codspeed (==4.2.0)"] cov = ["coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "coverage-enable-subprocess", "coverage[toml] (>=5.3)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "mkdocs-material", "mkdocstrings[python]", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-codspeed (==4.2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=2,<5.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] +dev = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "coverage-enable-subprocess", "coverage[toml] (>=5.3)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "mkdocs-material", "mkdocstrings[python]", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-codspeed (==4.2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] docs = ["mkdocs-material", "mkdocstrings[python]"] -tests = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=2,<5.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] +tests = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] [[package]] name = "six" @@ -2048,6 +2079,22 @@ files = [ requests = "*" starlette = ">=0.20.1" +[[package]] +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "types-click" version = "7.1.8" diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py index f7d6e642..453a6f2a 100644 --- a/gateway-api/src/fhir/patient.py +++ b/gateway-api/src/fhir/patient.py @@ -1,6 +1,6 @@ """FHIR Patient resource.""" -from typing import TypedDict +from typing import NotRequired, TypedDict from fhir.general_practitioner import GeneralPractitioner from fhir.human_name import HumanName @@ -14,4 +14,4 @@ class Patient(TypedDict): name: list[HumanName] gender: str birthDate: str - generalPractitioner: list[GeneralPractitioner] + generalPractitioner: NotRequired[list[GeneralPractitioner]] diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 5da3a7e7..02008d55 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -1,8 +1,7 @@ """Pytest configuration and shared fixtures for gateway API tests.""" import pytest -from fhir.bundle import Bundle -from fhir.parameters import Parameters +from fhir import Bundle, Parameters, Patient @pytest.fixture @@ -73,6 +72,36 @@ def valid_headers() -> dict[str, str]: } +@pytest.fixture +def happy_path_pds_response_body() -> Patient: + return { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [{"value": "9999999999", "system": "urn:nhs:numbers"}], + "name": [ + { + "family": "Johnson", + "given": ["Alice"], + "use": "Ally", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + } + ], + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + ], + "gender": "female", + "birthDate": "1990-05-15", + } + + @pytest.fixture def auth_token() -> str: return "AUTH_TOKEN123" diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 29ff5b7f..d83b9592 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -18,6 +18,7 @@ malformed upstream data (or malformed test fixtures) and should be corrected at source. """ +import os import uuid from collections.abc import Callable from datetime import date, datetime, timezone @@ -25,11 +26,23 @@ import requests from fhir import Bundle, BundleEntry, GeneralPractitioner, HumanName, Patient -from stubs.pds.stub import PdsFhirApiStub from gateway_api.common.error import PdsRequestFailed from gateway_api.pds.search_results import PdsSearchResults +# TODO: Once stub servers/containers made for PDS, SDS and provider +# we should remove the STUB_PDS environment variable and just +# use the stub client +STUB_PDS = os.environ.get("STUB_PDS", "false").lower() == "true" +if not STUB_PDS: + post = requests.post +else: + from stubs.pds.stub import PdsFhirApiStub + + pds = PdsFhirApiStub() + post = pds.post # type: ignore + + # Type for stub get method type GetCallable = Callable[..., requests.Response] @@ -84,13 +97,6 @@ def __init__( self.base_url = base_url.rstrip("/") self.timeout = timeout self.ignore_dates = ignore_dates - self.stub = PdsFhirApiStub() - - # TODO: Put this back to using the environment variable - # if os.environ.get("STUB_PDS", None): - self.get_method: GetCallable = self.stub.get - # else: - # self.get_method: GetCallable = requests.get def _build_headers( self, @@ -148,8 +154,9 @@ def search_patient_by_nhs_number( url = f"{self.base_url}/Patient/{nhs_number}" # This normally calls requests.get, but if STUB_PDS is set it uses the stub. - response = self.get_method( - url, + response = post( + url, # TODO: URL points to sandbox env even when STUB_PDS + # is true, should we change this to point to the stub instead? headers=headers, params={}, timeout=timeout or self.timeout, diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index 71b565f9..88e543b1 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -5,12 +5,13 @@ from dataclasses import dataclass from datetime import date from typing import TYPE_CHECKING, Any -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest import requests +from fhir import OperationOutcome, Patient +from pytest_mock import MockerFixture from requests.structures import CaseInsensitiveDict -from stubs.pds.stub import PdsFhirApiStub from gateway_api.common.error import PdsRequestFailed from gateway_api.pds.client import PdsClient @@ -34,10 +35,10 @@ class FakeResponse: status_code: int headers: dict[str, str] | CaseInsensitiveDict[str] - _json: dict[str, Any] + _json: dict[str, Any] | Patient | OperationOutcome reason: str = "" - def json(self) -> dict[str, Any]: + def json(self) -> dict[str, Any] | Patient | OperationOutcome: """ Return the response JSON body. @@ -59,302 +60,114 @@ def raise_for_status(self) -> None: raise err -@pytest.fixture -def stub() -> PdsFhirApiStub: - """ - Create a stub backend instance. - - :return: A :class:`stubs.stub_pds.PdsFhirApiStub` with strict header validation - enabled. - """ - # Strict header validation helps ensure PdsClient sends X-Request-ID correctly. - return PdsFhirApiStub(strict_headers=True) - - -@pytest.fixture -def mock_requests_get( - monkeypatch: pytest.MonkeyPatch, stub: PdsFhirApiStub -) -> dict[str, Any]: - """ - Patch ``PdsFhirApiStub`` so the PdsClient uses the test stub fixture. - - The fixture returns a "capture" dict recording the most recent request information. - This is used by header-related tests. - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend used to serve GET requests. - :return: A capture dictionary containing the last call details - (url/headers/params/timeout). - """ - capture: dict[str, Any] = {} - - # Wrap the stub's get method to capture call parameters - original_stub_get = stub.get - - def _capturing_get( - url: str, - headers: dict[str, str] | None = None, - params: Any = None, - timeout: Any = None, - ) -> requests.Response: - """ - Wrapper around stub.get that captures parameters. - - :param url: URL passed by the client. - :param headers: Headers passed by the client. - :param params: Query parameters. - :param timeout: Timeout. - :return: Response from the stub. - """ - headers = headers or {} - capture["url"] = url - capture["headers"] = dict(headers) - capture["params"] = params - capture["timeout"] = timeout - - return original_stub_get(url, headers, params, timeout) - - stub.get = _capturing_get # type: ignore[method-assign] - - # Monkeypatch PdsFhirApiStub so PdsClient uses our test stub - import gateway_api.pds.client as pds_module - - monkeypatch.setattr( - pds_module, - "PdsFhirApiStub", - lambda *args, **kwargs: stub, # NOQA ARG005 (maintain signature) - ) - - return capture - - -def _insert_basic_patient( - stub: PdsFhirApiStub, - nhs_number: str, - family: str, - given: list[str], - general_practitioner: list[dict[str, Any]] | None = None, +def test_search_patient_by_nhs_number_happy_path( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, ) -> None: - """ - Insert a basic Patient record into the stub. - - :param stub: Stub backend to insert into. - :param nhs_number: NHS number (10-digit string). - :param family: Family name for the Patient.name record. - :param given: Given names for the Patient.name record. - :param general_practitioner: Optional list stored under - ``Patient.generalPractitioner``. - :return: ``None``. - """ - stub.upsert_patient( - nhs_number=nhs_number, - patient={ - "resourceType": "Patient", - "name": [ - { - "use": "official", - "family": family, - "given": given, - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "generalPractitioner": general_practitioner or [], - }, - version_id=1, + happy_path_response = FakeResponse( + status_code=200, headers={}, _json=happy_path_pds_response_body ) + mocker.patch("gateway_api.pds.client.post", return_value=happy_path_response) - -def test_search_patient_by_nhs_number_get_patient_success( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Verify ``GET /Patient/{nhs_number}`` returns 200 and demographics are extracted. - - This test explicitly inserts the patient into the stub and asserts that the client - returns a populated :class:`gateway_api.pds_search.PdsSearchResults`. - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture - (ensures patching is active). - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000009", - family="Smith", - given=["Jane"], - general_practitioner=[], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - result = client.search_patient_by_nhs_number("9000000009") + client = PdsClient(auth_token) + result = client.search_patient_by_nhs_number("9999999999") assert result is not None - assert result.nhs_number == "9000000009" - assert result.family_name == "Smith" - assert result.given_names == "Jane" - assert result.gp_ods_code is None + assert result.nhs_number == "9999999999" + assert result.family_name == "Johnson" + assert result.given_names == "Alice" + assert result.gp_ods_code == "A12345" -def test_search_patient_by_nhs_number_no_current_gp_returns_gp_ods_code_none( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) +def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, ) -> None: - """ - Verify that ``gp_ods_code`` is ``None`` when no GP record is current. - - The generalPractitioner list may be: - * empty - * non-empty with no current record - * non-empty with exactly one current record - - This test covers the "non-empty, none current" case by - inserting only a historical GP record. - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture. - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000018", - family="Taylor", - given=["Ben"], - general_practitioner=[ - { - "id": "1", - "type": "Organization", - "identifier": { - "value": "OLDGP", - "period": {"start": "2010-01-01", "end": "2012-01-01"}, - }, - } - ], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - base_url="https://example.test/personal-demographics/FHIR/R4", + gp_less_response_body = happy_path_pds_response_body.copy() + del gp_less_response_body["generalPractitioner"] + gp_less_response = FakeResponse( + status_code=200, headers={}, _json=gp_less_response_body ) + mocker.patch("gateway_api.pds.client.post", return_value=gp_less_response) - result = client.search_patient_by_nhs_number("9000000018") + client = PdsClient(auth_token) + result = client.search_patient_by_nhs_number("9999999999") assert result is not None - assert result.nhs_number == "9000000018" - assert result.family_name == "Taylor" - assert result.given_names == "Ben" + assert result.nhs_number == "9999999999" + assert result.family_name == "Johnson" + assert result.given_names == "Alice" assert result.gp_ods_code is None def test_search_patient_by_nhs_number_sends_expected_headers( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, ) -> None: - """ - Verify that the client sends the expected headers to PDS. - - Asserts that the request contains: - * Authorization header - * NHSD-End-User-Organisation-ODS header - * Accept header - * caller-provided X-Request-ID and X-Correlation-ID headers - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture capturing outbound - headers. - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000009", - family="Smith", - given=["Jane"], - general_practitioner=[], + happy_path_response = FakeResponse( + status_code=200, headers={}, _json=happy_path_pds_response_body ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 - base_url="https://example.test/personal-demographics/FHIR/R4", + mocked_post = mocker.patch( + "gateway_api.pds.client.post", return_value=happy_path_response ) - req_id = str(uuid4()) - corr_id = "corr-123" + request_id = str(uuid4()) + correlation_id = "corr-123" - result = client.search_patient_by_nhs_number( + client = PdsClient(auth_token) + _ = client.search_patient_by_nhs_number( "9000000009", - request_id=req_id, - correlation_id=corr_id, + request_id=request_id, + correlation_id=correlation_id, ) - assert result is not None - headers = mock_requests_get["headers"] - assert headers["Authorization"] == "Bearer test-token" - assert headers["Accept"] == "application/fhir+json" - assert headers["X-Request-ID"] == req_id - assert headers["X-Correlation-ID"] == corr_id + expected_headers = { + "Authorization": f"Bearer {auth_token}", + "Accept": "application/fhir+json", + "X-Request-ID": request_id, + "X-Correlation-ID": correlation_id, + } + + assert mocked_post.call_args.kwargs["headers"] == expected_headers def test_search_patient_by_nhs_number_generates_request_id( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, ) -> None: - """ - Verify that the client generates an X-Request-ID when not provided. - - The stub is in strict mode, so a missing or invalid X-Request-ID would cause a 400. - This test confirms a request ID is present and looks UUID-like. - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture capturing outbound - headers. - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000009", - family="Smith", - given=["Jane"], - general_practitioner=[], + happy_path_response = FakeResponse( + status_code=200, headers={}, _json=happy_path_pds_response_body ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 - base_url="https://example.test/personal-demographics/FHIR/R4", + mocked_post = mocker.patch( + "gateway_api.pds.client.post", return_value=happy_path_response ) - result = client.search_patient_by_nhs_number("9000000009") - assert result is not None + client = PdsClient(auth_token) + + _ = client.search_patient_by_nhs_number("9000000009") - headers = mock_requests_get["headers"] - assert "X-Request-ID" in headers - assert isinstance(headers["X-Request-ID"], str) - assert len(headers["X-Request-ID"]) >= 32 + try: + _ = UUID(mocked_post.call_args.kwargs["headers"]["X-Request-ID"], version=4) + except ValueError: + pytest.fail("X-Request-ID is not a valid UUID4") def test_search_patient_by_nhs_number_not_found_raises_error( - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) + auth_token: str, + mocker: MockerFixture, ) -> None: - """ - Verify that a 404 response results in :class:`PDSRequestFailed`. - - The stub returns a 404 OperationOutcome for unknown NHS numbers. The client calls - ``raise_for_status()``, which raises ``requests.HTTPError``; the client wraps that - into :class:`PDSRequestFailed`. - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture. - :return: ``None``. - """ - pds = PdsClient( - auth_token="test-token", # noqa: S106 - base_url="https://example.test/personal-demographics/FHIR/R4", + not_found_response = FakeResponse( + status_code=404, + headers={}, + _json={"resourceType": "OperationOutcome", "issue": []}, + reason="Not Found", ) + mocker.patch("gateway_api.pds.client.post", return_value=not_found_response) + pds = PdsClient(auth_token) with pytest.raises( PdsRequestFailed, match="PDS FHIR API request failed: Not Found" @@ -362,68 +175,43 @@ def test_search_patient_by_nhs_number_not_found_raises_error( pds.search_patient_by_nhs_number("9900000001") -def test_search_patient_by_nhs_number_extracts_current_gp_ods_code( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) +def test_search_patient_by_nhs_number_finds_current_gp_ods_code_when_pds_returns_two( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, ) -> None: - """ - Verify that a current GP record is selected and its ODS code returned. - - The test inserts a patient with two GP records: - * one historical (not current) - * one current (period covers today) - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture. - :return: ``None``. - """ - stub.upsert_patient( - nhs_number="9000000017", - patient={ - "resourceType": "Patient", - "name": [ - { - "use": "official", - "family": "Taylor", - "given": ["Ben", "A."], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "generalPractitioner": [ - # Old - { - "id": "1", - "type": "Organization", - "identifier": { - "value": "OLDGP", - "period": {"start": "2010-01-01", "end": "2012-01-01"}, - }, - }, - # Current - { - "id": "2", - "type": "Organization", - "identifier": { - "value": "CURRGP", - "period": {"start": "2020-01-01", "end": "9999-01-01"}, - }, - }, - ], + old_gp: GeneralPractitioner = { + "id": "1", + "type": "Organization", + "identifier": { + "value": "OLDGP", + "period": {"start": "2010-01-01", "end": "2012-01-01"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", }, - version_id=1, + } + current_gp: GeneralPractitioner = { + "id": "2", + "type": "Organization", + "identifier": { + "value": "CURRGP", + "period": {"start": "2020-01-01", "end": "9999-01-01"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + pds_response_body_with_two_gps = happy_path_pds_response_body.copy() + pds_response_body_with_two_gps["generalPractitioner"] = [old_gp, current_gp] + pds_response_with_two_gps = FakeResponse( + status_code=200, headers={}, _json=pds_response_body_with_two_gps ) + mocker.patch("gateway_api.pds.client.post", return_value=pds_response_with_two_gps) - client = PdsClient( - auth_token="test-token", # noqa: S106 - base_url="https://example.test/personal-demographics/FHIR/R4", - ) + client = PdsClient(auth_token) - result = client.search_patient_by_nhs_number("9000000017") + result = client.search_patient_by_nhs_number("9999999999") assert result is not None - assert result.nhs_number == "9000000017" - assert result.family_name == "Taylor" - assert result.given_names == "Ben A." + assert result.nhs_number == "9999999999" + assert result.family_name == "Johnson" + assert result.given_names == "Alice" assert result.gp_ods_code == "CURRGP" diff --git a/gateway-api/stubs/stubs/pds/stub.py b/gateway-api/stubs/stubs/pds/stub.py index 1a77b2f2..c3c01293 100644 --- a/gateway-api/stubs/stubs/pds/stub.py +++ b/gateway-api/stubs/stubs/pds/stub.py @@ -202,7 +202,7 @@ def get_patient( headers_out["ETag"] = f'W/"{version_id}"' return _create_response(status_code=200, headers=headers_out, json_data=patient) - def get( + def post( self, url: str, headers: dict[str, Any] | None = None, diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 54824a4b..1e312dc8 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -12,6 +12,7 @@ WORKDIR /resources/build/gateway-api ENV PYTHONPATH=/resources/build/gateway-api ENV FLASK_HOST="0.0.0.0" ENV FLASK_PORT="8080" +ENV STUB_PDS="true" ARG COMMIT_VERSION ENV COMMIT_VERSION=$COMMIT_VERSION From 847a65a48aeeb6adb84aaddcce34c0305acdada8 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:55:39 +0000 Subject: [PATCH 34/40] [GPCAPIM-275]: Add STUB PROVIDER to env vars --- gateway-api/src/gateway_api/pds/client.py | 5 ---- .../src/gateway_api/provider/client.py | 25 +++++++++++-------- gateway-api/stubs/stubs/provider/stub.py | 22 ++++++++-------- infrastructure/images/gateway-api/Dockerfile | 1 + 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index d83b9592..5e296be2 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -20,7 +20,6 @@ import os import uuid -from collections.abc import Callable from datetime import date, datetime, timezone from typing import cast @@ -43,10 +42,6 @@ post = pds.post # type: ignore -# Type for stub get method -type GetCallable = Callable[..., requests.Response] - - class PdsClient: """ Simple client for PDS FHIR R4 patient retrieval. diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 89d36382..974ba65d 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -22,14 +22,25 @@ The response from the provider FHIR API. """ -from collections.abc import Callable +import os from urllib.parse import urljoin -from requests import HTTPError, Response, post -from stubs.provider.stub import stub_post +from requests import HTTPError, Response from gateway_api.common.error import ProviderRequestFailed +# TODO: Once stub servers/containers made for PDS, SDS and provider +# we should remove the STUB_PROVIDER environment variable and just +# use the stub client +STUB_PROVIDER = os.environ.get("STUB_PROVIDER", "false").lower() == "true" +if not STUB_PROVIDER: + from requests import post +else: + from stubs.provider.stub import GpProviderStub + + provider_stub = GpProviderStub() + post = provider_stub.post # type: ignore + ARS_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:structured" ":fhir:operation:gpc.getstructuredrecord-1" @@ -39,14 +50,6 @@ ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" TIMEOUT: int | None = None # None used for quicker dev, adjust as needed -# TODO: Put the environment variable check back in -# if os.environ.get("STUB_PROVIDER", None): -if True: # NOSONAR S5797 (Yes, I know it's always true, this is temporary) - # Direct all requests to the stub provider for steel threading in dev. - # Replace with `from requests import post` for real requests. - PostCallable = Callable[..., Response] - post: PostCallable = stub_post # type: ignore[no-redef] - class GpProviderClient: """ diff --git a/gateway-api/stubs/stubs/provider/stub.py b/gateway-api/stubs/stubs/provider/stub.py index 27ee9c41..7f557e61 100644 --- a/gateway-api/stubs/stubs/provider/stub.py +++ b/gateway-api/stubs/stubs/provider/stub.py @@ -123,14 +123,14 @@ def access_record_structured( reason="Not Found", ) - -def stub_post( - url: str, # NOQA ARG001 # NOSONAR S1172 (unused in stub) - headers: dict[str, Any], - data: json_str, - timeout: int, # NOQA ARG001 # NOSONAR S1172 (unused in stub) -) -> Response: - """A stubbed requests.post function that routes to the GPProviderStub.""" - _provider_stub = GpProviderStub() - trace_id = headers.get("Ssp-TraceID", "no-trace-id") - return _provider_stub.access_record_structured(trace_id, data) + def post( + self, + url: str, # NOQA ARG001 # NOSONAR S1172 (unused in stub) + headers: dict[str, Any], + data: json_str, + timeout: int, # NOQA ARG001 # NOSONAR S1172 (unused in stub) + ) -> Response: + """A stubbed requests.post function that routes to the GPProviderStub.""" + _provider_stub = GpProviderStub() + trace_id = headers.get("Ssp-TraceID", "no-trace-id") + return _provider_stub.access_record_structured(trace_id, data) diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 1e312dc8..d67dfe4e 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -13,6 +13,7 @@ ENV PYTHONPATH=/resources/build/gateway-api ENV FLASK_HOST="0.0.0.0" ENV FLASK_PORT="8080" ENV STUB_PDS="true" +ENV STUB_PROVIDER="true" ARG COMMIT_VERSION ENV COMMIT_VERSION=$COMMIT_VERSION From 5a1c445664c952a7ab1eb0e2ef6f86ce200fd07f Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:57:11 +0000 Subject: [PATCH 35/40] [GPCAPIM-275]: Tidying up --- gateway-api/poetry.lock | 20 ++++++++++---------- gateway-api/pyproject.toml | 1 - gateway-api/src/gateway_api/controller.py | 3 +-- gateway-api/src/gateway_api/pds/client.py | 1 + gateway-api/src/gateway_api/test_app.py | 1 - 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index cfb02e6d..ab1aa9fa 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "anyio" @@ -337,7 +337,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "platform_system == \"Windows\""} [[package]] name = "coverage" @@ -672,7 +672,7 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, @@ -753,7 +753,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format\""} idna = {version = "*", optional = true, markers = "extra == \"format\""} isoduration = {version = "*", optional = true, markers = "extra == \"format\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format\""} -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format\""} rfc3987 = {version = "*", optional = true, markers = "extra == \"format\""} @@ -1226,7 +1226,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1324,7 +1324,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1485,7 +1485,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -1516,7 +1516,7 @@ version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, @@ -1618,7 +1618,7 @@ version = "3.15.1" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, @@ -2425,4 +2425,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "9646e1adfb86cc4e07b149bc1a93f1e32921f0cd50c57603cdb6fe907092ce7a" +content-hash = "6e4b608d881e6c840cd58d0522f2064ad1aa6fbc4eda74b06be61fd77b9e1eb7" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index b95d627c..a841d21e 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -13,7 +13,6 @@ clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-comm flask = "^3.1.2" types-flask = "^1.1.6" requests = "^2.32.5" -pytest-mock = "^3.15.1" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 90e1cc67..c6301403 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -122,8 +122,7 @@ def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: ) if pds_result is None: - error = NoPatientFound(nhs_number=nhs_number) - raise error + raise NoPatientFound(nhs_number=nhs_number) if pds_result.gp_ods_code: provider_ods_code = pds_result.gp_ods_code diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 5e296be2..a2c8489e 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -295,6 +295,7 @@ def find_current_gp( for record in gerneral_practitioners: period = record["identifier"]["period"] # TODO: spell check lint start = date.fromisoformat(period["start"]) + # TODO: period is not required to have end end = date.fromisoformat(period["end"]) if start <= today <= end: return record diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 4f0d18d1..50f13051 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -139,7 +139,6 @@ def test_get_structured_record_returns_400_when_required_header_missing( self, get_structured_record_response_from_missing_header: Flask, ) -> None: - assert get_structured_record_response_from_missing_header.status_code == 400 @pytest.mark.parametrize( From f4b026b60c68b0e803bc89cdce35bf30a5a171c1 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:34:43 +0000 Subject: [PATCH 36/40] [GPCAPIM-275]: Add spell checker extension - flagging errors only; not preventing committing. --- .devcontainer/devcontainer.json | 3 ++- .vscode/settings.json | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ccfbeb7c..2dcf360c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,7 +18,8 @@ "ms-python.vscode-python-envs", "ms-python.mypy-type-checker", "sonarsource.sonarlint-vscode", - "alexkrechik.cucumberautocomplete" + "alexkrechik.cucumberautocomplete", + "streetsidesoftware.code-spell-checker" ], "extensions.ignoreRecommendations": true, "settings": { diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c5f1eea..4fe7dfbe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,5 +60,13 @@ "projectKey": "NHSDigital_clinical-data-gateway-api" }, // Disabling automatic port forwarding as the devcontainer should already have access to any required ports. - "remote.autoForwardPorts": false + "remote.autoForwardPorts": false, + + // Code spell checker configuration + "cSpell.language": "en-GB", + "cSpell.words": [ + "usefixtures", + "fhir", + "asid" + ] } From 33f0e81ec41b2af563ddff7cb8fec04aafa25e68 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:35:10 +0000 Subject: [PATCH 37/40] [GPCAPIM-275]: None will not be returned from the Provider client. --- gateway-api/src/gateway_api/controller.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index c6301403..ac8f8277 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -81,13 +81,10 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: body=request.request_body, ) - # If we get a None from the GP provider, that means that either the service did - # not respond or we didn't make the request to the service in the first place. - # Therefore a None is a 502, any real response just pass straight back. return FlaskResponse( - status_code=response.status_code if response is not None else 502, - data=response.text if response is not None else "GP provider service error", - headers=dict(response.headers) if response is not None else None, + status_code=response.status_code, + data=response.text, + headers=dict(response.headers), ) def get_auth_token(self) -> str: From 332c64caaa8b844780c325bbe153ce1588a25b7e Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:04:03 +0000 Subject: [PATCH 38/40] [GPCAPIM-275]: Reduce doc strings. --- gateway-api/src/gateway_api/controller.py | 22 ----- gateway-api/src/gateway_api/pds/client.py | 86 ++----------------- .../src/gateway_api/pds/search_results.py | 8 -- .../src/gateway_api/pds/test_client.py | 19 ---- .../src/gateway_api/provider/client.py | 15 ---- .../src/gateway_api/provider/test_client.py | 3 - gateway-api/src/gateway_api/sds/client.py | 7 -- .../src/gateway_api/sds/search_results.py | 3 - 8 files changed, 5 insertions(+), 158 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index ac8f8277..1749766a 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -19,9 +19,6 @@ class Controller: """ Orchestrates calls to PDS -> SDS -> GP provider. - - Entry point: - - ``call_gp_provider(request_body_json, headers, auth_token) -> FlaskResponse`` """ gp_provider_client: GpProviderClient | None @@ -34,10 +31,6 @@ def __init__( ) -> None: """ Create a controller instance. - - :param pds_base_url: Base URL for PDS client. - :param sds_base_url: Base URL for SDS client. - :param timeout: Timeout in seconds for downstream calls. """ self.pds_base_url = pds_base_url self.sds_base_url = sds_base_url @@ -56,10 +49,6 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. 3) Call SDS using consumer ODS to obtain consumer ASID. 4) Call GP provider to obtain patient records. - - :param request: A GetStructuredRecordRequest instance. - :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the - outcome. """ auth_token = self.get_auth_token() @@ -93,18 +82,12 @@ def get_auth_token(self) -> str: This is a placeholder implementation. Replace with actual logic to obtain the auth token as needed. - - :returns: Authorization token as a string. """ return "AUTH_TOKEN123" def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: """ Call PDS to find the provider ODS code (GP ODS code) for a patient. - - :param auth_token: Authorization token to use for PDS. - :param nhs_number: NHS number - :returns: Provider ODS code (GP ODS code). """ # PDS: find patient and extract GP ODS code (provider ODS) pds = PdsClient( @@ -137,11 +120,6 @@ def _get_sds_details( This method performs two SDS lookups: - provider details (ASID + endpoint) - consumer details (ASID) - - :param auth_token: Authorization token to use for SDS. - :param consumer_ods: Consumer organisation ODS code (from request headers). - :param provider_ods: Provider organisation ODS code (from PDS). - :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint). """ # SDS: Get provider details (ASID + endpoint) for provider ODS sds = SdsClient( diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index a2c8489e..19d864a6 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -78,16 +78,6 @@ def __init__( timeout: int = 10, ignore_dates: bool = False, ) -> None: - """ - Create a PDS client. - - :param auth_token: OAuth2 bearer token (without the ``"Bearer "`` prefix). - :param base_url: Base URL for the PDS API (one of :attr:`SANDBOX_URL`, - :attr:`INT_URL`, :attr:`PROD_URL`). Trailing slashes are stripped. - :param timeout: Default timeout in seconds for HTTP calls. - :param ignore_dates: If ``True`` just get the most recent name or GP record, - ignoring the date ranges. - """ self.auth_token = auth_token self.base_url = base_url.rstrip("/") self.timeout = timeout @@ -100,11 +90,6 @@ def _build_headers( ) -> dict[str, str]: """ Build mandatory and optional headers for a PDS request. - - :param request_id: Optional ``X-Request-ID``. If not supplied a new UUID is - generated. - :param correlation_id: Optional ``X-Correlation-ID`` for cross-system tracing. - :return: Dictionary of HTTP headers for the outbound request. """ headers = { "X-Request-ID": request_id or str(uuid.uuid4()), @@ -112,8 +97,6 @@ def _build_headers( "Authorization": f"Bearer {self.auth_token}", } - # Correlation ID is used to track the same request across multiple systems. - # Can be safely omitted, mirrored back in response if included. if correlation_id: headers["X-Correlation-ID"] = correlation_id @@ -131,15 +114,6 @@ def search_patient_by_nhs_number( Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient resource on success, then extracts a single :class:`PdsSearchResults`. - - :param nhs_number: NHS number to search for. - :param request_id: Optional request ID to reuse for retries; if not supplied a - UUID is generated. - :param correlation_id: Optional correlation ID for tracing. - :param timeout: Optional per-call timeout in seconds. If not provided, - :attr:`timeout` is used. - :return: A :class:`PdsSearchResults` instance if a patient can be extracted, - otherwise ``None``. """ headers = self._build_headers( request_id=request_id, @@ -180,11 +154,6 @@ def _get_gp_ods_code( * If exactly one record is current, return its ``identifier.value``. In future this may change to return the most recent record if none is current. - - :param general_practitioners: List of ``generalPractitioner`` records from a - Patient resource. - :return: ODS code string if a current record exists, otherwise ``None``. - :raises KeyError: If a record is missing required ``identifier.period`` fields. """ if len(general_practitioners) == 0: return None @@ -209,10 +178,6 @@ def _extract_single_search_result( For Bundle inputs, the code assumes either zero matches (empty entry list) or a single match; if multiple entries are present, the first entry is used. - :param body: Parsed JSON body containing either a Patient resource or a Bundle - whose first entry contains a Patient resource under ``resource``. - :return: A populated :class:`PdsSearchResults` if extraction succeeds, otherwise - ``None``. """ # Accept either: # 1) Patient (GET /Patient/{id}) @@ -257,43 +222,20 @@ def _extract_single_search_result( def find_current_gp( self, - gerneral_practitioners: list[GeneralPractitioner], + general_practitioners: list[GeneralPractitioner], today: date | None = None, ) -> GeneralPractitioner | None: - """ - Select the current record from a ``generalPractitioner`` list. - - A record is "current" if its ``identifier.period`` covers ``today`` (inclusive): - - ``start <= today <= end`` - - Or else if self.ignore_dates is True, the last record in the list is returned. - - The list may be in any of the following states: - - * empty - * contains one or more records, none current - * contains one or more records, exactly one current - - :param records: List of ``generalPractitioner`` records. - :param today: Optional override date, intended for deterministic tests. - If not supplied, the current UTC date is used. - :return: The first record whose ``identifier.period`` covers ``today``, or - ``None`` if no record is current. - :raises KeyError: If required keys are missing for a record being evaluated. - :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. - """ if today is None: today = datetime.now(timezone.utc).date() if self.ignore_dates: - if len(gerneral_practitioners) > 0: - return gerneral_practitioners[-1] + if len(general_practitioners) > 0: + return general_practitioners[-1] else: return None - for record in gerneral_practitioners: - period = record["identifier"]["period"] # TODO: spell check lint + for record in general_practitioners: + period = record["identifier"]["period"] start = date.fromisoformat(period["start"]) # TODO: period is not required to have end end = date.fromisoformat(period["end"]) @@ -305,24 +247,6 @@ def find_current_gp( def find_current_name_record( self, names: list[HumanName], today: date | None = None ) -> HumanName | None: - """ - Select the current record from a ``Patient.name`` list. - - A record is "current" if its ``period`` covers ``today`` (inclusive): - - ``start <= today <= end`` - - Or else if self.ignore_dates is True, the last record in the list is returned. - - :param records: List of ``Patient.name`` records. - :param today: Optional override date, intended for deterministic tests. - If not supplied, the current UTC date is used. - :return: The first name record whose ``period`` covers ``today``, or ``None`` if - no record is current. - :raises KeyError: If required keys (``period.start`` / ``period.end``) are - missing. - :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. - """ if today is None: today = datetime.now(timezone.utc).date() diff --git a/gateway-api/src/gateway_api/pds/search_results.py b/gateway-api/src/gateway_api/pds/search_results.py index fc6e929e..331a476d 100644 --- a/gateway-api/src/gateway_api/pds/search_results.py +++ b/gateway-api/src/gateway_api/pds/search_results.py @@ -10,14 +10,6 @@ class PdsSearchResults: Only a small subset of the PDS Patient fields are currently required by this gateway. More will be added in later phases. - - :param given_names: Given names from the *current* ``Patient.name`` record, - concatenated with spaces. - :param family_name: Family name from the *current* ``Patient.name`` record. - :param nhs_number: NHS number (``Patient.id``). - :param gp_ods_code: The ODS code of the *current* GP, extracted from - ``Patient.generalPractitioner[].identifier.value`` if a current GP record exists - otherwise ``None``. """ given_names: str diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index 88e543b1..e8c5469b 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -27,10 +27,6 @@ class FakeResponse: Only the methods accessed by :class:`gateway_api.pds_search.PdsClient` are implemented. - - :param status_code: HTTP status code. - :param headers: Response headers (dict or CaseInsensitiveDict). - :param _json: Parsed JSON body returned by :meth:`json`. """ status_code: int @@ -39,20 +35,9 @@ class FakeResponse: reason: str = "" def json(self) -> dict[str, Any] | Patient | OperationOutcome: - """ - Return the response JSON body. - - :return: Parsed JSON body. - """ return self._json def raise_for_status(self) -> None: - """ - Emulate :meth:`requests.Response.raise_for_status`. - - :return: ``None``. - :raises requests.HTTPError: If the response status is not 200. - """ if self.status_code != 200: err = requests.HTTPError(f"{self.status_code} Error") # requests attaches a Response to HTTPError.response; the client expects it @@ -218,8 +203,6 @@ def test_search_patient_by_nhs_number_finds_current_gp_ods_code_when_pds_returns def test_find_current_gp_with_today_override() -> None: """ Verify that ``find_current_gp`` honours an explicit ``today`` value. - - :return: ``None``. """ pds = PdsClient("test-token", "A12345") pds_ignore_dates = PdsClient("test-token", "A12345", ignore_dates=True) @@ -255,8 +238,6 @@ def test_find_current_name_record_no_current_name() -> None: """ Verify that ``find_current_name_record`` returns ``None`` when no current name exists. - - :return: ``None``. """ pds = PdsClient("test-token", "A12345") pds_ignore_date = PdsClient("test-token", "A12345", ignore_dates=True) diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 974ba65d..62f68043 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -81,14 +81,6 @@ def __init__( def _build_headers(self, trace_id: str) -> dict[str, str]: """ Build the headers required for the GPProvider FHIR API request. - - Args: - trace_id (str): A unique identifier for the request. - - Returns: - dict[str, str]: A dictionary containing the headers for the request, - including content type, interaction ID, and ASIDs for the provider - and consumer. """ return { "Content-Type": "application/fhir+json", @@ -106,13 +98,6 @@ def access_structured_record( ) -> Response: """ Fetch a structured patient record from the GPProvider FHIR API. - - Args: - trace_id (str): A unique identifier for the request, passed in the headers. - body (str): The request body in FHIR format. - - Returns: - Response: The response from the GPProvider FHIR API. """ headers = self._build_headers(trace_id) diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 4a22a3ae..c7368c6c 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -39,9 +39,6 @@ def mock_request_post( This fixture intercepts calls to `requests.post` and routes them to the stub provider. It also captures the most recent request details, such as headers, body, and URL, for verification in tests. - - Returns: - dict[str, Any]: A dictionary containing the captured request details. """ capture: dict[str, Any] = {} diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index a78ab923..e2c16224 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -18,10 +18,6 @@ def __init__( ) -> None: """ Create an SDS client. - - :param auth_token: Authentication token to present to SDS. - :param base_url: Base URL for SDS. - :param timeout: Timeout in seconds for SDS calls. """ self.auth_token = auth_token self.base_url = base_url @@ -32,9 +28,6 @@ def get_org_details(self, ods_code: str) -> SdsSearchResults | None: Retrieve SDS org details for a given ODS code. This is a placeholder implementation that always returns an ASID and endpoint. - - :param ods_code: ODS code to look up. - :returns: SDS search results or ``None`` if not found. """ # Placeholder implementation return SdsSearchResults( diff --git a/gateway-api/src/gateway_api/sds/search_results.py b/gateway-api/src/gateway_api/sds/search_results.py index ad956b89..55dd3287 100644 --- a/gateway-api/src/gateway_api/sds/search_results.py +++ b/gateway-api/src/gateway_api/sds/search_results.py @@ -7,9 +7,6 @@ class SdsSearchResults: Stub SDS search results dataclass. Replace this with the real one once it's implemented. - - :param asid: Accredited System ID. - :param endpoint: Endpoint URL associated with the organisation, if applicable. """ asid: str From 8af842370eac1740dca090c9445575d755c4012d Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:06:49 +0000 Subject: [PATCH 39/40] [GPCAPIM-275]: Method does not return None and so will never raise the NoPatientFound error. This will be raised by a raise_from_status call following a 404 from PDS. --- gateway-api/src/gateway_api/common/error.py | 5 ----- gateway-api/src/gateway_api/controller.py | 14 +++----------- gateway-api/src/gateway_api/pds/client.py | 6 ++---- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index fc4d5402..a2779848 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -55,11 +55,6 @@ def __str__(self) -> str: return self.message -class NoPatientFound(BaseError): - _message = "No PDS patient found for NHS number {nhs_number}" - status_code = BAD_REQUEST - - class InvalidRequestJSON(BaseError): _message = "Invalid JSON body sent in request" error_code = ErrorCode.INVALID diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 1749766a..44be9502 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -8,7 +8,6 @@ NoCurrentEndpoint, NoCurrentProvider, NoOrganisationFound, - NoPatientFound, ) from gateway_api.get_structured_record.request import GetStructuredRecordRequest from gateway_api.pds import PdsClient, PdsSearchResults @@ -97,19 +96,12 @@ def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: ignore_dates=True, ) - pds_result: PdsSearchResults | None = pds.search_patient_by_nhs_number( - nhs_number - ) - - if pds_result is None: - raise NoPatientFound(nhs_number=nhs_number) + pds_result: PdsSearchResults = pds.search_patient_by_nhs_number(nhs_number) - if pds_result.gp_ods_code: - provider_ods_code = pds_result.gp_ods_code - else: + if not pds_result.gp_ods_code: raise NoCurrentProvider(nhs_number=nhs_number) - return provider_ods_code + return pds_result.gp_ods_code def _get_sds_details( self, auth_token: str, consumer_ods: str, provider_ods: str diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 19d864a6..65d00c70 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -108,7 +108,7 @@ def search_patient_by_nhs_number( request_id: str | None = None, correlation_id: str | None = None, timeout: int | None = None, - ) -> PdsSearchResults | None: + ) -> PdsSearchResults: """ Retrieve a patient by NHS number. @@ -166,9 +166,7 @@ def _get_gp_ods_code( return None if ods_code == "None" else ods_code - def _extract_single_search_result( - self, body: Patient | Bundle - ) -> PdsSearchResults | None: + def _extract_single_search_result(self, body: Patient | Bundle) -> PdsSearchResults: """ Extract a single :class:`PdsSearchResults` from a Patient response. From a2117e3c02cbb2f915159b841422aec58eb7a681 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:06:41 +0000 Subject: [PATCH 40/40] [GPCAPIM-275]: Make number go up - integration tests do cover this but coverage doesn't capture that. --- .../src/gateway_api/test_controller.py | 132 +++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 8ca854a0..776f2912 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -3,7 +3,12 @@ import pytest from pytest_mock import MockerFixture -from gateway_api.common.error import NoCurrentProvider +from gateway_api.common.error import ( + NoAsidFound, + NoCurrentEndpoint, + NoCurrentProvider, + NoOrganisationFound, +) from gateway_api.controller import Controller from gateway_api.pds import PdsSearchResults from gateway_api.sds import SdsSearchResults @@ -79,3 +84,128 @@ def test_get_sds_details_returns_consumer_and_provider_deatils_for_happy_path( expected = ("ConsumerASID", "ProviderASID", "https://example.provider.org/endpoint") actual = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 assert actual == expected + + +def test_get_sds_details_raises_no_organisation_found_when_sds_returns_none( + mocker: MockerFixture, + auth_token: str, +) -> None: + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + return_value=None, + ) + + controller = Controller() + + with pytest.raises( + NoOrganisationFound, + match="No SDS org found for provider ODS code ProviderODS", + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 + + +def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_asid( + mocker: MockerFixture, + auth_token: str, +) -> None: + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + blank_asid_sds_result = SdsSearchResults( + asid=" ", endpoint="https://example.provider.org/endpoint" + ) + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + return_value=blank_asid_sds_result, + ) + + controller = Controller() + + with pytest.raises( + NoAsidFound, + match=( + "SDS result for provider ODS code ProviderODS did not contain " + "a current ASID" + ), + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 + + +def test_get_sds_details_raises_no_current_endpoint_when_sds_returns_empty_endpoint( + mocker: MockerFixture, + auth_token: str, +) -> None: + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + blank_endpoint_sds_result = SdsSearchResults(asid="ProviderASID", endpoint=" ") + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + return_value=blank_endpoint_sds_result, + ) + + controller = Controller() + + with pytest.raises( + NoCurrentEndpoint, + match=( + "SDS result for provider ODS code ProviderODS did " + "not contain a current endpoint" + ), + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 + + +def test_get_sds_details_raises_no_org_found_when_sds_returns_none_for_consumer( + mocker: MockerFixture, + auth_token: str, +) -> None: + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + + happy_path_provider_sds_result = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" + ) + none_result_for_consumer = None + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=[happy_path_provider_sds_result, none_result_for_consumer], + ) + + controller = Controller() + + with pytest.raises( + NoOrganisationFound, + match="No SDS org found for consumer ODS code ConsumerODS", + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 + + +def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_consumer_asid( + mocker: MockerFixture, + auth_token: str, +) -> None: + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + + happy_path_provider_sds_result = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" + ) + consumer_asid_blank_sds_result = SdsSearchResults( + asid=" ", endpoint="https://example.consumer.org/endpoint" + ) + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=[happy_path_provider_sds_result, consumer_asid_blank_sds_result], + ) + + controller = Controller() + + with pytest.raises( + NoAsidFound, + match=( + "SDS result for consumer ODS code ConsumerODS did not contain " + "a current ASID" + ), + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001