From d6b5fc902e209f0d0a46fb15c18048408f054115 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:14:17 +0000 Subject: [PATCH 01/20] [GPCAPIM-251] initial commit with basic file structure and descriptions --- .../src/gateway_api/provider_request.py | 20 ++++++ .../src/gateway_api/test_provider_request.py | 10 +++ gateway-api/stubs/stubs/stub_provider.py | 61 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 gateway-api/src/gateway_api/provider_request.py create mode 100644 gateway-api/src/gateway_api/test_provider_request.py create mode 100644 gateway-api/stubs/stubs/stub_provider.py diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py new file mode 100644 index 00000000..c0e41f04 --- /dev/null +++ b/gateway-api/src/gateway_api/provider_request.py @@ -0,0 +1,20 @@ +""" +Module: gateway_api.provider_request + +This module contains the GPProvider class, which provides a +simple client for GPProvider FHIR GP System. +The GPProvider class has a sigle method to get_structure_record which +can be used to fetch patient records from a GPProvider FHIR API endpoint. +Usage: + + instantiate a GPProvider with: + provider_endpoint + provider_ASID + consumer_ASID + + method get_structured_record with (may add optional parameters later): + Parameters: parameters resource + + returns the response from the provider FHIR API. + +""" 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..12d202ec --- /dev/null +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -0,0 +1,10 @@ +# makes valid requests to stub provider and checks responses + +# recieved 200 OK for valid request + +# (throws if not 200 OK) + +# returns what is received from stub provider (if valid) + +# ~~throws if invalid response from stub provider~~ + diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py new file mode 100644 index 00000000..75adee39 --- /dev/null +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -0,0 +1,61 @@ +""" +Minimal in-memory stub for a Provider GP System FHIR API, +implementing only accessRecordStructured to read basic +demographic data for a single patient. + + Contract elements for direct provider call inferred from from + GPConnect documentation: + https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development_retrieve_patient_record.html + - Method: POST + - fhir_base: /FHIR/STU3 + - resource: /Patient + - fhir_operation: $gpc.getstructruredrecord + + Headers: + Ssp-TraceID: Consumer's Trace ID (a GUID or UUID) + Ssp-From: Consumer's ASID + Ssp-To: Provider's ASID + Ssp-InteractionID: + urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1 + + Request Body JSON (FHIR STU3 Parameters resource with patient NHS number. + Later add optional parameters such as `includeAllergies`): + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + } + ] + } + + + return stubResponse with FHIR STU3 Patient resource JSON with only + administrative data: + { + "resourceType": "Patient", + "id": "example", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "1234567890" + } + ], + "name": [ + { + "use": "official", + "family": "Doe", + "given": [ + "John" + ] + } + ], + "gender": "male", + "birthDate": "1980-01-01" + } + +""" From e1244061bef25389abb98d9e4e477de0c9a3db13 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:35:25 +0000 Subject: [PATCH 02/20] [GPCAPIM-251]: Implement GPProviderClient for FHIR API interaction - Create GPProviderClient class to interact with GPProvider FHIR API - Add initialization method with provider and consumer ASIDs - Implement get_structured_record method to fetch patient records - Update unit tests to verify successful response from GPProviderClient from example.com --- .../src/gateway_api/provider_request.py | 65 +++++++++++++++++++ .../src/gateway_api/test_provider_request.py | 29 ++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index c0e41f04..2970048a 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -18,3 +18,68 @@ returns the response from the provider FHIR API. """ + +# imports +import requests + + +# definitions +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. + """ + + +class GPProviderClient: + """ + A simple client for GPProvider FHIR GP System. + """ + + def __init__( + self, + provider_endpoint: str, + provider_asid: str, + consumer_asid: str, + ) -> None: + """ + Create a GPProviderClient instance. + + Args: + provider_endpoint (str): The FHIR API endpoint for the provider. + provider_asid (str): The ASID for the provider. + consumer_asid (str): The ASID for the consumer. + """ + self.provider_endpoint = provider_endpoint + self.provider_asid = provider_asid + self.consumer_asid = consumer_asid + + def get_structured_record(self) -> requests.Response: + """ + Fetch a structured patient record from the GPProvider FHIR API. + + Args: + parameters (dict): The parameters resource to send in the request. + returns: + dict: The response from the GPProvider FHIR API. + """ + response = requests.get( + self.provider_endpoint, + headers={ + "Provider-ASID": self.provider_asid, + "Consumer-ASID": self.consumer_asid, + }, + timeout=5, + ) + + try: + response.raise_for_status() + except requests.HTTPError as err: + raise ExternalServiceError( + f"GPProvider FHIR API request failed:{err.response.reason}" + ) from err + + return response diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 12d202ec..fa13d253 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -1,6 +1,9 @@ +""" +Unit tests for :mod:`gateway_api.provider_request`. +""" + # makes valid requests to stub provider and checks responses -# recieved 200 OK for valid request # (throws if not 200 OK) @@ -8,3 +11,27 @@ # ~~throws if invalid response from stub provider~~ +from gateway_api.provider_request import GPProviderClient + + +# receives 200 OK from example.com for valid request +def test_valid_gpprovider_request_get_200() -> None: + """ + Verify that a valid request to the GPProvider returns a 200 OK response. + """ + # Arrange + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "http://example.com" # this will be monkeypatched in real tests + + client = GPProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + # Act + result = client.get_structured_record() + + # Assert + assert result.status_code == 200 From 296403f39afd35883f284f5188e061735d44f80b Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:00:32 +0000 Subject: [PATCH 03/20] [GPCAPIM-251]: Refactor GPProviderClient and enhance test coverage - Change return type of get_structured_record method to Response - Update timeout parameter to None for quicker development - Add mock_request_get fixture to unit tests for simulating API responses --- .../src/gateway_api/provider_request.py | 5 +-- .../src/gateway_api/test_provider_request.py | 33 ++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index 2970048a..db51b0ec 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -21,6 +21,7 @@ # imports import requests +from requests import Response # definitions @@ -57,7 +58,7 @@ def __init__( self.provider_asid = provider_asid self.consumer_asid = consumer_asid - def get_structured_record(self) -> requests.Response: + def get_structured_record(self) -> Response: """ Fetch a structured patient record from the GPProvider FHIR API. @@ -72,7 +73,7 @@ def get_structured_record(self) -> requests.Response: "Provider-ASID": self.provider_asid, "Consumer-ASID": self.consumer_asid, }, - timeout=5, + timeout=None, # noqa: S113 quicker dev cycle; adjust as needed ) try: diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index fa13d253..a416a5a5 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -2,27 +2,50 @@ Unit tests for :mod:`gateway_api.provider_request`. """ -# makes valid requests to stub provider and checks responses +# imports +import pytest +import requests +from requests import Response + +from gateway_api.provider_request import GPProviderClient + +# definitions +# fixtures +@pytest.fixture +def mock_request_get(monkeypatch: pytest.MonkeyPatch) -> Response: + """ + Patch requests.get method so calls are routed here. + """ + response = Response() + response.status_code = 200 + + monkeypatch.setattr(requests, "get", lambda *args, **kwargs: response) + return response + + +# pseudo-code for tests: +# makes valid requests to stub provider and checks responses + # (throws if not 200 OK) # returns what is received from stub provider (if valid) # ~~throws if invalid response from stub provider~~ -from gateway_api.provider_request import GPProviderClient - # receives 200 OK from example.com for valid request -def test_valid_gpprovider_request_get_200() -> None: +def test_valid_gpprovider_request_get_200( + mock_request_get: Response, +) -> None: """ Verify that a valid request to the GPProvider returns a 200 OK response. """ # Arrange provider_asid = "200000001154" consumer_asid = "200000001152" - provider_endpoint = "http://example.com" # this will be monkeypatched in real tests + provider_endpoint = "http://invalid.com" # this will be monkeypatched in real tests client = GPProviderClient( provider_endpoint=provider_endpoint, From 02fa73e00e1aaa84f0a389585363b2155d3771fc Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:50:17 +0000 Subject: [PATCH 04/20] [GPCAPIM-251]: Update GPProviderClient methods for FHIR API interaction - Rename method `get_structured_record` to `access_structured_record` - Change HTTP method from GET to POST for fetching structured patient records - Update request headers to match GP Connect specifications - Enhance mock request handling in tests for POST requests - Add a GPProviderStub class to simulate structured patient record responses --- .../src/gateway_api/provider_request.py | 12 ++- .../src/gateway_api/test_provider_request.py | 34 ++++--- gateway-api/stubs/stubs/stub_provider.py | 90 ++++++++++++++----- 3 files changed, 98 insertions(+), 38 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index db51b0ec..d665d148 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -53,12 +53,16 @@ def __init__( provider_endpoint (str): The FHIR API endpoint for the provider. provider_asid (str): The ASID for the provider. consumer_asid (str): The ASID for the consumer. + + methods: + access_structured_record: fetch structured patient record + from GPProvider FHIR API. """ self.provider_endpoint = provider_endpoint self.provider_asid = provider_asid self.consumer_asid = consumer_asid - def get_structured_record(self) -> Response: + def access_structured_record(self) -> Response: """ Fetch a structured patient record from the GPProvider FHIR API. @@ -67,11 +71,11 @@ def get_structured_record(self) -> Response: returns: dict: The response from the GPProvider FHIR API. """ - response = requests.get( + response = requests.post( self.provider_endpoint, headers={ - "Provider-ASID": self.provider_asid, - "Consumer-ASID": self.consumer_asid, + "SSP-To": self.provider_asid, # alias here to match GP connect header + "SSP-From": self.consumer_asid, # alias here to match GP connect header }, timeout=None, # noqa: S113 quicker dev cycle; adjust as needed ) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index a416a5a5..05096849 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -14,30 +14,42 @@ # fixtures @pytest.fixture -def mock_request_get(monkeypatch: pytest.MonkeyPatch) -> Response: +def mock_request_post(monkeypatch: pytest.MonkeyPatch) -> None: """ - Patch requests.get method so calls are routed here. + Patch requests.post method so calls are routed here. """ - response = Response() - response.status_code = 200 - monkeypatch.setattr(requests, "get", lambda *args, **kwargs: response) - return response + def _fake_post( + url: str, + headers: dict[str, str], # TODO: define a class 'GPConnectHeaders' for this + timeout: int, + ) -> Response: + """A fake requests.post implementation.""" + + # replace with stub + response = Response() + response.status_code = 200 + + # stub_response = stub.accessRecordStructured() + + return response + + monkeypatch.setattr(requests, "post", _fake_post) # pseudo-code for tests: -# makes valid requests to stub provider and checks responses +# makes valid requests to stub provider and checks responses using a capture -# (throws if not 200 OK) # returns what is received from stub provider (if valid) +# (throws if not 200 OK) # ~~throws if invalid response from stub provider~~ # receives 200 OK from example.com for valid request -def test_valid_gpprovider_request_get_200( - mock_request_get: Response, +def test_valid_gpprovider_access_structured_record_post_200( + mock_request_post: Response, ) -> None: """ Verify that a valid request to the GPProvider returns a 200 OK response. @@ -54,7 +66,7 @@ def test_valid_gpprovider_request_get_200( ) # Act - result = client.get_structured_record() + result = client.access_structured_record() # Assert assert result.status_code == 200 diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index 75adee39..50a793c9 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -34,28 +34,72 @@ } - return stubResponse with FHIR STU3 Patient resource JSON with only - administrative data: - { - "resourceType": "Patient", - "id": "example", - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "1234567890" - } - ], - "name": [ - { - "use": "official", - "family": "Doe", - "given": [ - "John" - ] - } - ], - "gender": "male", - "birthDate": "1980-01-01" - } + return """ + +from typing import Any + + +class GPProviderStub: + """ + A minimal in-memory stub for a Provider GP System FHIR API, + implementing only accessRecordStructured to read basic + demographic data for a single patient. + """ + + def __init__(self) -> None: + """Create a GPProviderStub instance.""" + # Seed a deterministic example matching the spec's id example stubResponse + # FHIR/STU3 Patient resource with only administrative data based on Example 2 + # https://simplifier.net/guide/gp-connect-access-record-structured/Home/Examples/Allergy-examples?version=1.6.2 + self.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) -> dict[str, Any]: + """ + Simulate accessRecordStructured operation of GPConnect FHIR API. + + returns: + dict: The stub patient bundle. + """ + return self.patient_bundle From 790802815ee43950be174f28ef4b49c4a6864be0 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:54:22 +0000 Subject: [PATCH 05/20] [GPCAPIM-251]: Update GPProviderClient and stub for FHIR API interaction - Correct header casing in GPProviderClient for FHIR API requests - Enhance GPProviderStub to return a Response object - Refactor mock_request_post to utilize GPProviderStub --- .../src/gateway_api/provider_request.py | 4 ++-- .../src/gateway_api/test_provider_request.py | 20 ++++++++++--------- gateway-api/stubs/stubs/stub_provider.py | 14 ++++++++----- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index d665d148..c1baf23f 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -74,8 +74,8 @@ def access_structured_record(self) -> Response: response = requests.post( self.provider_endpoint, headers={ - "SSP-To": self.provider_asid, # alias here to match GP connect header - "SSP-From": self.consumer_asid, # alias here to match GP connect header + "Ssp-To": self.provider_asid, # alias here to match GP connect header + "Ssp-From": self.consumer_asid, # alias here to match GP connect header }, timeout=None, # noqa: S113 quicker dev cycle; adjust as needed ) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 05096849..b9f2b6ff 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -6,6 +6,7 @@ import pytest import requests from requests import Response +from stubs.stub_provider import GPProviderStub from gateway_api.provider_request import GPProviderClient @@ -14,7 +15,12 @@ # fixtures @pytest.fixture -def mock_request_post(monkeypatch: pytest.MonkeyPatch) -> None: +def stub() -> GPProviderStub: + return GPProviderStub() + + +@pytest.fixture +def mock_request_post(monkeypatch: pytest.MonkeyPatch, stub: GPProviderStub) -> None: """ Patch requests.post method so calls are routed here. """ @@ -26,13 +32,9 @@ def _fake_post( ) -> Response: """A fake requests.post implementation.""" - # replace with stub - response = Response() - response.status_code = 200 + stub_response = stub.access_record_structured() - # stub_response = stub.accessRecordStructured() - - return response + return stub_response monkeypatch.setattr(requests, "post", _fake_post) @@ -40,7 +42,6 @@ def _fake_post( # pseudo-code for tests: # makes valid requests to stub provider and checks responses using a capture - # returns what is received from stub provider (if valid) # (throws if not 200 OK) @@ -50,6 +51,7 @@ def _fake_post( # receives 200 OK from example.com for valid request def test_valid_gpprovider_access_structured_record_post_200( mock_request_post: Response, + stub: GPProviderStub, ) -> None: """ Verify that a valid request to the GPProvider returns a 200 OK response. @@ -57,7 +59,7 @@ def test_valid_gpprovider_access_structured_record_post_200( # Arrange provider_asid = "200000001154" consumer_asid = "200000001152" - provider_endpoint = "http://invalid.com" # this will be monkeypatched in real tests + provider_endpoint = "http://invalid.com" client = GPProviderClient( provider_endpoint=provider_endpoint, diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index 50a793c9..cca5347f 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -38,7 +38,7 @@ """ -from typing import Any +from requests import Response class GPProviderStub: @@ -50,7 +50,7 @@ class GPProviderStub: def __init__(self) -> None: """Create a GPProviderStub instance.""" - # Seed a deterministic example matching the spec's id example stubResponse + # Seed an example matching the spec's id example stubResponse # FHIR/STU3 Patient resource with only administrative data based on Example 2 # https://simplifier.net/guide/gp-connect-access-record-structured/Home/Examples/Allergy-examples?version=1.6.2 self.patient_bundle = { @@ -95,11 +95,15 @@ def __init__(self) -> None: ], } - def access_record_structured(self) -> dict[str, Any]: + def access_record_structured(self) -> Response: """ Simulate accessRecordStructured operation of GPConnect FHIR API. returns: - dict: The stub patient bundle. + Response: The stub patient bundle wrapped in a Response object. """ - return self.patient_bundle + + response = Response() + response.status_code = 200 + + return response From f6680eed4bbb009f03f35c79e947298d6c65d5a1 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:31:35 +0000 Subject: [PATCH 06/20] [GPCAPIM-251]: Refactor GPProvider class names for consistency and swap to https for dummy url --- gateway-api/src/gateway_api/provider_request.py | 2 +- .../src/gateway_api/test_provider_request.py | 16 ++++++++-------- gateway-api/stubs/stubs/stub_provider.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index c1baf23f..19528282 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -35,7 +35,7 @@ class ExternalServiceError(Exception): """ -class GPProviderClient: +class GpProviderClient: """ A simple client for GPProvider FHIR GP System. """ diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index b9f2b6ff..a333f360 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -6,21 +6,21 @@ import pytest import requests from requests import Response -from stubs.stub_provider import GPProviderStub +from stubs.stub_provider import GpProviderStub -from gateway_api.provider_request import GPProviderClient +from gateway_api.provider_request import GpProviderClient # definitions # fixtures @pytest.fixture -def stub() -> GPProviderStub: - return GPProviderStub() +def stub() -> GpProviderStub: + return GpProviderStub() @pytest.fixture -def mock_request_post(monkeypatch: pytest.MonkeyPatch, stub: GPProviderStub) -> None: +def mock_request_post(monkeypatch: pytest.MonkeyPatch, stub: GpProviderStub) -> None: """ Patch requests.post method so calls are routed here. """ @@ -51,7 +51,7 @@ def _fake_post( # receives 200 OK from example.com for valid request def test_valid_gpprovider_access_structured_record_post_200( mock_request_post: Response, - stub: GPProviderStub, + stub: GpProviderStub, ) -> None: """ Verify that a valid request to the GPProvider returns a 200 OK response. @@ -59,9 +59,9 @@ def test_valid_gpprovider_access_structured_record_post_200( # Arrange provider_asid = "200000001154" consumer_asid = "200000001152" - provider_endpoint = "http://invalid.com" + provider_endpoint = "https://invalid.com" - client = GPProviderClient( + client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index cca5347f..fa47b303 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -41,7 +41,7 @@ from requests import Response -class GPProviderStub: +class GpProviderStub: """ A minimal in-memory stub for a Provider GP System FHIR API, implementing only accessRecordStructured to read basic From deae9e625b9f4ec251a04ee2b72498fe2aefed97 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:36:15 +0000 Subject: [PATCH 07/20] [GPCAPIM-251]: Enhance GPProviderClient to include headers for FHIR API requests - Implement _build_headers method to construct request headers - Update access_structured_record method to utilize new headers - Modify unit tests to verify correct headers are sent in requests --- .../src/gateway_api/provider_request.py | 36 +++++++++++-- .../src/gateway_api/test_provider_request.py | 51 +++++++++++++++---- 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index 19528282..f9003ac1 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -20,6 +20,7 @@ """ # imports + import requests from requests import Response @@ -62,7 +63,32 @@ def __init__( self.provider_asid = provider_asid self.consumer_asid = consumer_asid - def access_structured_record(self) -> Response: + def _build_headers(self, trace_id: str) -> dict[str, str]: + """ + Build the headers required for the GPProvider FHIR API request. + + Args: + provider_asid (str): The ASID for the provider. + consumer_asid (str): The ASID for the consumer. + + Returns: + dict: Headers for the request. + """ + return { + "Content-Type": "application/fhir+json", + "Accept": "application/fhir+json", + "Ssp-InteractionID": "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1", # noqa: E501 this is standard InteractionID for accessRecordStructured + "Ssp-To": self.provider_asid, + "Ssp-From": self.consumer_asid, + "Ssp-TraceID": trace_id, + } + + def access_structured_record( + self, + # body: str # forwarded from consumer + trace_id: str, # from consumer header + # nhsnumber: str, # from request + ) -> Response: """ Fetch a structured patient record from the GPProvider FHIR API. @@ -71,12 +97,12 @@ def access_structured_record(self) -> Response: returns: dict: The response from the GPProvider FHIR API. """ + + headers = self._build_headers(trace_id) + response = requests.post( self.provider_endpoint, - headers={ - "Ssp-To": self.provider_asid, # alias here to match GP connect header - "Ssp-From": self.consumer_asid, # alias here to match GP connect header - }, + headers=headers, timeout=None, # noqa: S113 quicker dev cycle; adjust as needed ) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index a333f360..21d9955a 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -3,6 +3,8 @@ """ # imports +from typing import Any + import pytest import requests from requests import Response @@ -11,6 +13,7 @@ from gateway_api.provider_request import GpProviderClient # definitions +ars_InteractionID = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured # fixtures @@ -20,10 +23,16 @@ def stub() -> GpProviderStub: @pytest.fixture -def mock_request_post(monkeypatch: pytest.MonkeyPatch, stub: GpProviderStub) -> None: +def mock_request_post( + monkeypatch: pytest.MonkeyPatch, stub: GpProviderStub +) -> dict[str, Any]: """ Patch requests.post method so calls are routed here. + + The fixture returns a "capture" dict recording the most recent request header + information. This is used by header-related tests. """ + capture: dict[str, dict[str, str]] = {} def _fake_post( url: str, @@ -32,43 +41,63 @@ def _fake_post( ) -> Response: """A fake requests.post implementation.""" + capture["headers"] = dict(headers) + stub_response = stub.access_record_structured() return stub_response monkeypatch.setattr(requests, "post", _fake_post) + return capture # pseudo-code for tests: -# makes valid requests to stub provider and checks responses using a capture +# Test: makes valid requests to stub provider and checks responses using a capture -# returns what is received from stub provider (if valid) +# Test: returns what is received from stub provider (if valid) -# (throws if not 200 OK) -# ~~throws if invalid response from stub provider~~ +# Test: (throws if not 200 OK) +# Test: ~~throws if invalid response from stub provider~~ -# receives 200 OK from example.com for valid request -def test_valid_gpprovider_access_structured_record_post_200( - mock_request_post: Response, +def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( + mock_request_post: dict[str, Any], stub: GpProviderStub, ) -> None: """ - Verify that a valid request to the GPProvider returns a 200 OK response. + Verify that a request to the GPProvider is made with the correct headers, + and receives a 200 OK response. """ # Arrange provider_asid = "200000001154" consumer_asid = "200000001152" provider_endpoint = "https://invalid.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, + } # Act - result = client.access_structured_record() + result = client.access_structured_record(trace_id=trace_id) + + # Extract + captured_headers = mock_request_post["headers"] # Assert + for key, value in expected_headers.items(): + assert captured_headers.get(key) == value assert result.status_code == 200 + + +# Test: the expected headers are returned - this would be testing the behaviour of the +# (stub) provider so not in scope here From 2ea0376da606f7b0473a9d222e5d4eab99c5eb4c Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:03:13 +0000 Subject: [PATCH 08/20] [GPCAPIM-251]: Update GpProviderClient to handle request body - Modify access_structured_record method to accept a body parameter - Update request handling to include body in POST requests - Add unit test to verify correct body is sent in requests --- .../src/gateway_api/provider_request.py | 3 +- .../src/gateway_api/test_provider_request.py | 45 ++++++++++++++++--- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index f9003ac1..282581bc 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -85,8 +85,8 @@ def _build_headers(self, trace_id: str) -> dict[str, str]: def access_structured_record( self, - # body: str # forwarded from consumer trace_id: str, # from consumer header + body: str, # forwarded from consumer_request # nhsnumber: str, # from request ) -> Response: """ @@ -103,6 +103,7 @@ def access_structured_record( response = requests.post( self.provider_endpoint, headers=headers, + data=body, timeout=None, # noqa: S113 quicker dev cycle; adjust as needed ) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 21d9955a..899d4273 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -32,16 +32,18 @@ def mock_request_post( The fixture returns a "capture" dict recording the most recent request header information. This is used by header-related tests. """ - capture: dict[str, dict[str, str]] = {} + capture: dict[str, Any] = {} def _fake_post( url: str, - headers: dict[str, str], # TODO: define a class 'GPConnectHeaders' for this + headers: dict[str, str], + data: str, timeout: int, ) -> Response: """A fake requests.post implementation.""" capture["headers"] = dict(headers) + capture["data"] = data stub_response = stub.access_record_structured() @@ -59,6 +61,9 @@ def _fake_post( # Test: (throws if not 200 OK) # Test: ~~throws if invalid response from stub provider~~ +# Test: the expected headers are returned - this would be testing the behaviour of the +# (stub) provider so not in scope here + def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], @@ -88,7 +93,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-InteractionID": ars_InteractionID, } # Act - result = client.access_structured_record(trace_id=trace_id) + result = client.access_structured_record(trace_id, "body") # Extract captured_headers = mock_request_post["headers"] @@ -99,5 +104,35 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 assert result.status_code == 200 -# Test: the expected headers are returned - this would be testing the behaviour of the -# (stub) provider so not in scope here +# Test: makes request with correct body +def test_valid_gpprovider_access_structured_record_with_correct_body_200( + mock_request_post: dict[str, Any], + stub: GpProviderStub, +) -> None: + """ + Verify that a request to the GPProvider is made with the correct body, + and receives a 200 OK response. + """ + # Arrange + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://invalid.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, + ) + + # Act + result = client.access_structured_record(trace_id, request_body) + + # Extract + captured_body = mock_request_post["data"] + + # Assert + assert result.status_code == 200 + assert captured_body == request_body From 74f0db7ee607d661fbe844112199021613d80c31 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:23:30 +0000 Subject: [PATCH 09/20] [GPCAPIM-251]: Add tests for GPProviderClient response handling - Implement test to verify GPProviderClient returns correct response from stub provider - Remove outdated pseudo-code comments from test file - Update stub provider response handling for initial implementation --- .../src/gateway_api/test_provider_request.py | 34 +++++++++++++++++-- gateway-api/stubs/stubs/stub_provider.py | 2 ++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 899d4273..a8603e91 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -54,9 +54,6 @@ def _fake_post( # pseudo-code for tests: -# Test: makes valid requests to stub provider and checks responses using a capture - -# Test: returns what is received from stub provider (if valid) # Test: (throws if not 200 OK) # Test: ~~throws if invalid response from stub provider~~ @@ -136,3 +133,34 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( # Assert assert result.status_code == 200 assert captured_body == request_body + + +# Test: returns what is received from stub provider (if valid) +def test_valid_gpprovider_access_structured_record_returns_stub_response_200( + mock_request_post: dict[str, Any], + stub: GpProviderStub, +) -> None: + """ + Verify that a request to the GPProvider returns the same response + as provided by the stub provider. + """ + # Arrange + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://invalid.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() + + # Act + result = client.access_structured_record(trace_id, "body") + + # Assert + assert result.status_code == expected_response.status_code + assert result.content == expected_response.content diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index fa47b303..23848e21 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -105,5 +105,7 @@ def access_record_structured(self) -> Response: response = Response() response.status_code = 200 + # this is not prefered but may suffice for first implementaion of the stub + response._content = str.encode(str(self.patient_bundle)) # noqa: SLF001 return response From e0e80be4b3039662eb8b4ab7ce119953e694944f Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:45:36 +0000 Subject: [PATCH 10/20] [GPCAPIM-251]: Refactor response handling in GPProviderStub and tests - Introduce a StubResponse class for simplified response representation - Update access_record_structured method to return StubResponse instead of Response - Enhance test cases to utilize FakeResponse for better testing of HTTP interactions --- .../src/gateway_api/test_provider_request.py | 32 ++++++++++++++++--- gateway-api/stubs/stubs/stub_provider.py | 22 +++++++++---- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index a8603e91..e0a0337e 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -8,6 +8,7 @@ import pytest import requests from requests import Response +from requests.structures import CaseInsensitiveDict from stubs.stub_provider import GpProviderStub from gateway_api.provider_request import GpProviderClient @@ -16,6 +17,23 @@ ars_InteractionID = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured +class FakeResponse(Response): + """A fake requests.Response object for testing.""" + + def __init__( + self, + status_code: int, + _content: bytes, + headers: CaseInsensitiveDict[str], + reason: str, + ) -> None: + """Create a FakeResponse instance.""" + self.status_code = status_code + self.headers = CaseInsensitiveDict(headers) + self._content = _content + self.reason = reason + + # fixtures @pytest.fixture def stub() -> GpProviderStub: @@ -36,18 +54,24 @@ def mock_request_post( def _fake_post( url: str, - headers: dict[str, str], + headers: CaseInsensitiveDict[str], data: str, timeout: int, - ) -> Response: + ) -> FakeResponse: """A fake requests.post implementation.""" capture["headers"] = dict(headers) capture["data"] = data stub_response = stub.access_record_structured() + fake_response = FakeResponse( + status_code=stub_response.status_code, + _content=stub_response.content.encode(), + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + reason="OK", + ) - return stub_response + return fake_response monkeypatch.setattr(requests, "post", _fake_post) return capture @@ -163,4 +187,4 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( # Assert assert result.status_code == expected_response.status_code - assert result.content == expected_response.content + assert result.content == expected_response.content.encode() diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index 23848e21..9ab0a30b 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -38,7 +38,15 @@ """ -from requests import Response +from dataclasses import dataclass + + +@dataclass(frozen=True) +class StubResponse: + """A stub response object representing a minimal FHIR + JSON response.""" + + status_code: int + content: str class GpProviderStub: @@ -95,7 +103,7 @@ def __init__(self) -> None: ], } - def access_record_structured(self) -> Response: + def access_record_structured(self) -> StubResponse: """ Simulate accessRecordStructured operation of GPConnect FHIR API. @@ -103,9 +111,9 @@ def access_record_structured(self) -> Response: Response: The stub patient bundle wrapped in a Response object. """ - response = Response() - response.status_code = 200 - # this is not prefered but may suffice for first implementaion of the stub - response._content = str.encode(str(self.patient_bundle)) # noqa: SLF001 + stub_response = StubResponse( + status_code=200, + content=str(self.patient_bundle), + ) - return response + return stub_response From 5ecc05c5b49f59cf806736b64e8a41904105a5db Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:25:23 +0000 Subject: [PATCH 11/20] [GPCAPIM-251]: Refactor response handling in stub provider and tests - Replace FakeResponse with a more generic StubResponse class - Update mock_request_post to return the actual stub response - Adjust content handling in tests to match new response structure --- .../src/gateway_api/test_provider_request.py | 31 ++----------------- gateway-api/stubs/stubs/stub_provider.py | 26 +++++++++++++--- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index e0a0337e..e1bd4b09 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -17,23 +17,6 @@ ars_InteractionID = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured -class FakeResponse(Response): - """A fake requests.Response object for testing.""" - - def __init__( - self, - status_code: int, - _content: bytes, - headers: CaseInsensitiveDict[str], - reason: str, - ) -> None: - """Create a FakeResponse instance.""" - self.status_code = status_code - self.headers = CaseInsensitiveDict(headers) - self._content = _content - self.reason = reason - - # fixtures @pytest.fixture def stub() -> GpProviderStub: @@ -57,21 +40,13 @@ def _fake_post( headers: CaseInsensitiveDict[str], data: str, timeout: int, - ) -> FakeResponse: + ) -> Response: """A fake requests.post implementation.""" capture["headers"] = dict(headers) capture["data"] = data - stub_response = stub.access_record_structured() - fake_response = FakeResponse( - status_code=stub_response.status_code, - _content=stub_response.content.encode(), - headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), - reason="OK", - ) - - return fake_response + return stub.access_record_structured() monkeypatch.setattr(requests, "post", _fake_post) return capture @@ -187,4 +162,4 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( # Assert assert result.status_code == expected_response.status_code - assert result.content == expected_response.content.encode() + assert result.content == expected_response.content diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index 9ab0a30b..16e00adb 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -38,15 +38,29 @@ """ +import json from dataclasses import dataclass +from requests import Response +from requests.structures import CaseInsensitiveDict -@dataclass(frozen=True) -class StubResponse: + +@dataclass() +class StubResponse(Response): """A stub response object representing a minimal FHIR + JSON response.""" - status_code: int - content: str + def __init__( + self, + status_code: int, + _content: bytes, + headers: CaseInsensitiveDict[str], + reason: str, + ) -> None: + """Create a FakeResponse instance.""" + self.status_code = status_code + self._content = _content + self.headers = CaseInsensitiveDict(headers) + self.reason = reason class GpProviderStub: @@ -113,7 +127,9 @@ def access_record_structured(self) -> StubResponse: stub_response = StubResponse( status_code=200, - content=str(self.patient_bundle), + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + reason="OK", + _content=json.dumps(self.patient_bundle).encode("utf-8"), ) return stub_response From ecb046d49b7eb3f6f34fb285e72cb33bd1d9c032 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:55:50 +0000 Subject: [PATCH 12/20] [GPCAPIM-251]: Refactor GPProviderClient to improve request handling - Update interaction ID and base URL definitions for clarity - Adjust request timeout handling for better development flexibility - Enhance unit tests to verify correct URL formation and response status --- .../src/gateway_api/provider_request.py | 13 ++++-- .../src/gateway_api/test_provider_request.py | 41 ++++++++++++++++++- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index 282581bc..cd59c64a 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -24,8 +24,13 @@ import requests from requests import Response - # definitions +ars_interactionId = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured +ars_fhir_base = "/FHIR/STU3" +ars_fhir_operation = "$gpc.getstructuredrecord" +timeout = None # TODO: None used for quicker dev, adjust as needed + + class ExternalServiceError(Exception): """ Raised when the downstream PDS request fails. @@ -77,7 +82,7 @@ def _build_headers(self, trace_id: str) -> dict[str, str]: return { "Content-Type": "application/fhir+json", "Accept": "application/fhir+json", - "Ssp-InteractionID": "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1", # noqa: E501 this is standard InteractionID for accessRecordStructured + "Ssp-InteractionID": ars_interactionId, "Ssp-To": self.provider_asid, "Ssp-From": self.consumer_asid, "Ssp-TraceID": trace_id, @@ -101,10 +106,10 @@ def access_structured_record( headers = self._build_headers(trace_id) response = requests.post( - self.provider_endpoint, + self.provider_endpoint + ars_fhir_base + "/patient/" + ars_fhir_operation, headers=headers, data=body, - timeout=None, # noqa: S113 quicker dev cycle; adjust as needed + timeout=timeout, ) try: diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index e1bd4b09..70a2a0e7 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -14,7 +14,7 @@ from gateway_api.provider_request import GpProviderClient # definitions -ars_InteractionID = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured +ars_interactionId = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured # fixtures @@ -45,6 +45,7 @@ def _fake_post( capture["headers"] = dict(headers) capture["data"] = data + capture["url"] = url return stub.access_record_structured() @@ -61,6 +62,42 @@ def _fake_post( # (stub) provider so not in scope here +# Test: make request to correct URL +def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( + mock_request_post: dict[str, Any], + stub: GpProviderStub, +) -> None: + """ + Verify that a request to the GPProvider is made to the correct URL, + and receives a 200 OK response. + """ + # Arrange + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://invalid.com" + trace_id = "some_uuid_value" + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + # Act + result = client.access_structured_record(trace_id, "body") + + # Extract + captured_url = mock_request_post.get("url", provider_endpoint) + + # Assert + assert ( + captured_url + == provider_endpoint + "/FHIR/STU3/patient/$gpc.getstructuredrecord" + ) + assert result.status_code == 200 + + +# Test: makes request with correct headers def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], stub: GpProviderStub, @@ -86,7 +123,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-TraceID": str(trace_id), "Ssp-From": consumer_asid, "Ssp-To": provider_asid, - "Ssp-InteractionID": ars_InteractionID, + "Ssp-InteractionID": ars_interactionId, } # Act result = client.access_structured_record(trace_id, "body") From b5448c547a37239dd046e7e831514a25582de0e4 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:26:57 +0000 Subject: [PATCH 13/20] [GPCAPIM-251]: cleanup and update documentation --- .../src/gateway_api/provider_request.py | 78 ++++++++++++------- .../src/gateway_api/test_provider_request.py | 78 +++++++++---------- gateway-api/stubs/stubs/stub_provider.py | 49 ++++-------- 3 files changed, 102 insertions(+), 103 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index cd59c64a..3a2c70fc 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -1,22 +1,25 @@ """ Module: gateway_api.provider_request -This module contains the GPProvider class, which provides a -simple client for GPProvider FHIR GP System. -The GPProvider class has a sigle method to get_structure_record which -can be used to fetch patient records from a GPProvider FHIR API endpoint. -Usage: - - instantiate a GPProvider with: - provider_endpoint - provider_ASID - consumer_ASID - - method get_structured_record with (may add optional parameters later): - Parameters: parameters resource +This module contains the GpProviderClient class, which provides a +simple client for interacting with the GPProvider FHIR GP System. - returns the response from the provider FHIR API. +The GpProviderClient class includes methods to fetch structured patient +records from a GPProvider FHIR API endpoint. +Usage: + Instantiate a GpProviderClient with: + - provider_endpoint: The FHIR API endpoint for the provider. + - provider_asid: The ASID for the provider. + - consumer_asid: The ASID for the consumer. + + Use the `access_structured_record` method to fetch a structured patient record: + Parameters: + - trace_id (str): A unique identifier for the request. + - body (str): The request body in FHIR format. + + Returns: + The response from the provider FHIR API. """ # imports @@ -28,22 +31,34 @@ ars_interactionId = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured ars_fhir_base = "/FHIR/STU3" ars_fhir_operation = "$gpc.getstructuredrecord" -timeout = None # TODO: None used for quicker dev, adjust as needed +timeout = None # None used for quicker dev, adjust as needed class ExternalServiceError(Exception): """ - Raised when the downstream PDS request fails. + Exception raised when the downstream GPProvider FHIR API 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. + This exception wraps :class:`requests.HTTPError` thrown by + ``response.raise_for_status()`` and re-raises it as ``ExternalServiceError`` + to decouple callers from the underlying ``requests`` library exception types. """ class GpProviderClient: """ - A simple client for GPProvider FHIR GP System. + A client for interacting with the GPProvider FHIR GP System. + + This class provides methods to interact with the GPProvider FHIR API, + including fetching structured patient records. + + Attributes: + provider_endpoint (str): The FHIR API endpoint for the provider. + provider_asid (str): The ASID for the provider. + consumer_asid (str): The ASID for the consumer. + + Methods: + access_structured_record(trace_id: str, body: str) -> Response: + Fetch a structured patient record from the GPProvider FHIR API. """ def __init__( @@ -73,11 +88,12 @@ def _build_headers(self, trace_id: str) -> dict[str, str]: Build the headers required for the GPProvider FHIR API request. Args: - provider_asid (str): The ASID for the provider. - consumer_asid (str): The ASID for the consumer. + trace_id (str): A unique identifier for the request. Returns: - dict: Headers for the request. + 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", @@ -90,17 +106,21 @@ def _build_headers(self, trace_id: str) -> dict[str, str]: def access_structured_record( self, - trace_id: str, # from consumer header - body: str, # forwarded from consumer_request - # nhsnumber: str, # from request + trace_id: str, + body: str, ) -> Response: """ Fetch a structured patient record from the GPProvider FHIR API. Args: - parameters (dict): The parameters resource to send in the request. - returns: - dict: The response from the GPProvider FHIR API. + 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. + + Raises: + ExternalServiceError: If the API request fails with an HTTP error. """ headers = self._build_headers(trace_id) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 70a2a0e7..98720eeb 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -1,8 +1,16 @@ """ 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. + +Fixtures: + - `stub`: Provides an instance of the `GpProviderStub` for simulating the GPProvider + - `mock_request_post`: Patches the `requests.post` method to intercept API calls and + route them to the stub provider. Captures request details for verification. + """ -# imports from typing import Any import pytest @@ -13,11 +21,9 @@ from gateway_api.provider_request import GpProviderClient -# definitions ars_interactionId = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured -# fixtures @pytest.fixture def stub() -> GpProviderStub: return GpProviderStub() @@ -28,10 +34,14 @@ def mock_request_post( monkeypatch: pytest.MonkeyPatch, stub: GpProviderStub ) -> dict[str, Any]: """ - Patch requests.post method so calls are routed here. + 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. - The fixture returns a "capture" dict recording the most recent request header - information. This is used by header-related tests. + Returns: + dict[str, Any]: A dictionary containing the captured request details. """ capture: dict[str, Any] = {} @@ -53,25 +63,17 @@ def _fake_post( return capture -# pseudo-code for tests: - -# Test: (throws if not 200 OK) -# Test: ~~throws if invalid response from stub provider~~ - -# Test: the expected headers are returned - this would be testing the behaviour of the -# (stub) provider so not in scope here - - -# Test: make request to correct URL def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], stub: GpProviderStub, ) -> None: """ - Verify that a request to the GPProvider is made to the correct URL, - and receives a 200 OK response. + 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. """ - # Arrange provider_asid = "200000001154" consumer_asid = "200000001152" provider_endpoint = "https://invalid.com" @@ -83,13 +85,10 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos consumer_asid=consumer_asid, ) - # Act result = client.access_structured_record(trace_id, "body") - # Extract captured_url = mock_request_post.get("url", provider_endpoint) - # Assert assert ( captured_url == provider_endpoint + "/FHIR/STU3/patient/$gpc.getstructuredrecord" @@ -97,16 +96,18 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos assert result.status_code == 200 -# Test: makes request with correct headers def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], stub: GpProviderStub, ) -> None: """ - Verify that a request to the GPProvider is made with the correct headers, - and receives a 200 OK response. + 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. """ - # Arrange provider_asid = "200000001154" consumer_asid = "200000001152" provider_endpoint = "https://invalid.com" @@ -125,28 +126,27 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-To": provider_asid, "Ssp-InteractionID": ars_interactionId, } - # Act + result = client.access_structured_record(trace_id, "body") - # Extract captured_headers = mock_request_post["headers"] - # Assert for key, value in expected_headers.items(): assert captured_headers.get(key) == value assert result.status_code == 200 -# Test: makes request with correct body def test_valid_gpprovider_access_structured_record_with_correct_body_200( mock_request_post: dict[str, Any], stub: GpProviderStub, ) -> None: """ - Verify that a request to the GPProvider is made with the correct body, - and receives a 200 OK response. + 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. """ - # Arrange provider_asid = "200000001154" consumer_asid = "200000001152" provider_endpoint = "https://invalid.com" @@ -160,27 +160,25 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( consumer_asid=consumer_asid, ) - # Act result = client.access_structured_record(trace_id, request_body) - # Extract captured_body = mock_request_post["data"] - # Assert assert result.status_code == 200 assert captured_body == request_body -# Test: returns what is received from stub provider (if valid) def test_valid_gpprovider_access_structured_record_returns_stub_response_200( mock_request_post: dict[str, Any], stub: GpProviderStub, ) -> None: """ - Verify that a request to the GPProvider returns the same response + 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. """ - # Arrange provider_asid = "200000001154" consumer_asid = "200000001152" provider_endpoint = "https://invalid.com" @@ -194,9 +192,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( expected_response = stub.access_record_structured() - # Act result = client.access_structured_record(trace_id, "body") - # Assert assert result.status_code == expected_response.status_code assert result.content == expected_response.content diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index 16e00adb..dd8f15e9 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -3,39 +3,22 @@ implementing only accessRecordStructured to read basic demographic data for a single patient. - Contract elements for direct provider call inferred from from - GPConnect documentation: - https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development_retrieve_patient_record.html - - Method: POST - - fhir_base: /FHIR/STU3 - - resource: /Patient - - fhir_operation: $gpc.getstructruredrecord - - Headers: - Ssp-TraceID: Consumer's Trace ID (a GUID or UUID) - Ssp-From: Consumer's ASID - Ssp-To: Provider's ASID - Ssp-InteractionID: - urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1 - - Request Body JSON (FHIR STU3 Parameters resource with patient NHS number. - Later add optional parameters such as `includeAllergies`): - { - "resourceType": "Parameters", - "parameter": [ - { - "name": "patientNHSNumber", - "valueIdentifier": { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999" - } - } - ] - } - - - return - +Contract elements for direct provider calls are inferred from +GPConnect documentation: +https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development_retrieve_patient_record.html + - Method: POST + - fhir_base: /FHIR/STU3 + - resource: /Patient + - fhir_operation: $gpc.getstructuredrecord + +Headers: + Ssp-TraceID: Consumer's Trace ID (a GUID or UUID) + Ssp-From: Consumer's ASID + Ssp-To: Provider's ASID + Ssp-InteractionID: + urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1 + +Request Body JSON (FHIR STU3 Parameters resource with patient NHS number. """ import json From 7b94e69107f6be5863fb5d740b161ca053a4a2a1 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:48:16 +0000 Subject: [PATCH 14/20] [GPCAPIM-251]: Add error handling test for access_structured_record - Implement test to verify ExternalServiceError is raised on HTTP error - Simulate a 500 Internal Server Error response from the GPProvider API - Enhance test coverage for GpProviderClient error handling --- .../src/gateway_api/test_provider_request.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 98720eeb..6e92fcaa 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -19,7 +19,7 @@ from requests.structures import CaseInsensitiveDict from stubs.stub_provider import GpProviderStub -from gateway_api.provider_request import GpProviderClient +from gateway_api.provider_request import ExternalServiceError, GpProviderClient ars_interactionId = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured @@ -196,3 +196,45 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( assert result.status_code == expected_response.status_code assert result.content == expected_response.content + + +def test_access_structured_record_raises_external_service_error( + mock_request_post: dict[str, Any], + stub: GpProviderStub, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Test that the `access_structured_record` method raises an `ExternalServiceError` + when the GPProvider FHIR API request fails with an HTTP error. + """ + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://invalid.com" + trace_id = "some_uuid_value" + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + # Simulate an error response from the stub + def _fake_post_error( + url: str, + headers: CaseInsensitiveDict[str], + data: str, + timeout: int, + ) -> Response: + response = Response() + response.status_code = 500 + response._content = b"Internal Server Error" # noqa: SLF001 TODO: push this back into the stub? + response.reason = "Internal Server Error" + return response + + monkeypatch.setattr(requests, "post", _fake_post_error) + + with pytest.raises( + ExternalServiceError, + match="GPProvider FHIR API request failed:Internal Server Error", + ): + client.access_structured_record(trace_id, "body") From 2026888ee8c4d81a91507c64c25b91b544083dbd Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:18:12 +0000 Subject: [PATCH 15/20] [GPCAPIM-251]: Address peer feedback --- .../src/gateway_api/provider_request.py | 27 ++++++++++--------- .../src/gateway_api/test_provider_request.py | 16 +++++------ 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index 3a2c70fc..3edd2801 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -22,14 +22,16 @@ The response from the provider FHIR API. """ -# imports +from urllib.parse import urljoin -import requests -from requests import Response +from requests import HTTPError, Response, post -# definitions -ars_interactionId = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured -ars_fhir_base = "/FHIR/STU3" +ars_interactionId = ( + "urn:nhs:names:services:gpconnect:structured" + ":fhir:operation:gpc.getstructuredrecord-1" +) +ars_fhir_base = "FHIR/STU3" +fhir_resource = "patient" ars_fhir_operation = "$gpc.getstructuredrecord" timeout = None # None used for quicker dev, adjust as needed @@ -37,10 +39,6 @@ class ExternalServiceError(Exception): """ Exception raised when the downstream GPProvider FHIR API request fails. - - This exception wraps :class:`requests.HTTPError` thrown by - ``response.raise_for_status()`` and re-raises it as ``ExternalServiceError`` - to decouple callers from the underlying ``requests`` library exception types. """ @@ -125,8 +123,11 @@ def access_structured_record( headers = self._build_headers(trace_id) - response = requests.post( - self.provider_endpoint + ars_fhir_base + "/patient/" + ars_fhir_operation, + endpoint_path = "/".join([ars_fhir_base, fhir_resource, ars_fhir_operation]) + url = urljoin(self.provider_endpoint, endpoint_path) + + response = post( + url, headers=headers, data=body, timeout=timeout, @@ -134,7 +135,7 @@ def access_structured_record( try: response.raise_for_status() - except requests.HTTPError as err: + except HTTPError as err: raise ExternalServiceError( f"GPProvider FHIR API request failed:{err.response.reason}" ) from err diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 6e92fcaa..5edd3a09 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -4,24 +4,22 @@ This module contains unit tests for the `GpProviderClient` class, which is responsible for interacting with the GPProvider FHIR API. -Fixtures: - - `stub`: Provides an instance of the `GpProviderStub` for simulating the GPProvider - - `mock_request_post`: Patches the `requests.post` method to intercept API calls and - route them to the stub provider. Captures request details for verification. - """ from typing import Any import pytest -import requests from requests import Response from requests.structures import CaseInsensitiveDict from stubs.stub_provider import GpProviderStub +from gateway_api import provider_request from gateway_api.provider_request import ExternalServiceError, GpProviderClient -ars_interactionId = "urn:nhs:names:services:gpconnect:structured:fhir:operation:gpc.getstructuredrecord-1" # noqa: E501 this is standard InteractionID for accessRecordStructured +ars_interactionId = ( + "urn:nhs:names:services:gpconnect:structured" + ":fhir:operation:gpc.getstructuredrecord-1" +) @pytest.fixture @@ -59,7 +57,7 @@ def _fake_post( return stub.access_record_structured() - monkeypatch.setattr(requests, "post", _fake_post) + monkeypatch.setattr(provider_request, "post", _fake_post) return capture @@ -231,7 +229,7 @@ def _fake_post_error( response.reason = "Internal Server Error" return response - monkeypatch.setattr(requests, "post", _fake_post_error) + monkeypatch.setattr(provider_request, "post", _fake_post_error) with pytest.raises( ExternalServiceError, From a25d46338dc1ca2e156bb96676ddee58d6ac2202 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:37:45 +0000 Subject: [PATCH 16/20] [GPCAPIM-251]: PR feedback --- gateway-api/src/gateway_api/provider_request.py | 12 ++++++------ gateway-api/src/gateway_api/test_provider_request.py | 5 ++--- gateway-api/stubs/stubs/stub_provider.py | 8 +++----- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index 3edd2801..b42231e1 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -30,10 +30,10 @@ "urn:nhs:names:services:gpconnect:structured" ":fhir:operation:gpc.getstructuredrecord-1" ) -ars_fhir_base = "FHIR/STU3" -fhir_resource = "patient" -ars_fhir_operation = "$gpc.getstructuredrecord" -timeout = None # None used for quicker dev, adjust as needed +ARS_FHIR_BASE = "FHIR/STU3" +FHIR_RESOURCE = "patient" +ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" +TIMEOUT = None # None used for quicker dev, adjust as needed class ExternalServiceError(Exception): @@ -123,14 +123,14 @@ def access_structured_record( headers = self._build_headers(trace_id) - endpoint_path = "/".join([ars_fhir_base, fhir_resource, ars_fhir_operation]) + endpoint_path = "/".join([ARS_FHIR_BASE, FHIR_RESOURCE, ARS_FHIR_OPERATION]) url = urljoin(self.provider_endpoint, endpoint_path) response = post( url, headers=headers, data=body, - timeout=timeout, + timeout=TIMEOUT, ) try: diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 5edd3a09..b4b5a16e 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -74,7 +74,7 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos """ provider_asid = "200000001154" consumer_asid = "200000001152" - provider_endpoint = "https://invalid.com" + provider_endpoint = "https://test.com" trace_id = "some_uuid_value" client = GpProviderClient( @@ -129,8 +129,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 captured_headers = mock_request_post["headers"] - for key, value in expected_headers.items(): - assert captured_headers.get(key) == value + assert expected_headers == captured_headers assert result.status_code == 200 diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index dd8f15e9..32308c80 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -22,13 +22,11 @@ """ import json -from dataclasses import dataclass from requests import Response from requests.structures import CaseInsensitiveDict -@dataclass() class StubResponse(Response): """A stub response object representing a minimal FHIR + JSON response.""" @@ -54,10 +52,10 @@ class GpProviderStub: """ def __init__(self) -> None: - """Create a GPProviderStub instance.""" - # Seed an example matching the spec's id example stubResponse - # FHIR/STU3 Patient resource with only administrative data based on Example 2 + """Create a GPProviderStub instance which is seeded with an example + FHIR/STU3 Patient resource with only administrative data based on Example 2 # https://simplifier.net/guide/gp-connect-access-record-structured/Home/Examples/Allergy-examples?version=1.6.2 + """ self.patient_bundle = { "resourceType": "Bundle", "type": "collection", From fc4aa3435c74fbbbce85246b487b34cee70aedcd Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Tue, 20 Jan 2026 13:16:28 +0000 Subject: [PATCH 17/20] [GPCAPIM-251]: Update remaining provider endpoint in tests --- gateway-api/src/gateway_api/test_provider_request.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index b4b5a16e..9614314e 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -108,7 +108,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 """ provider_asid = "200000001154" consumer_asid = "200000001152" - provider_endpoint = "https://invalid.com" + provider_endpoint = "https://test.com" trace_id = "some_uuid_value" client = GpProviderClient( @@ -146,7 +146,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( """ provider_asid = "200000001154" consumer_asid = "200000001152" - provider_endpoint = "https://invalid.com" + provider_endpoint = "https://test.com" trace_id = "some_uuid_value" request_body = "some_FHIR_request_params" @@ -178,7 +178,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( """ provider_asid = "200000001154" consumer_asid = "200000001152" - provider_endpoint = "https://invalid.com" + provider_endpoint = "https://test.com" trace_id = "some_uuid_value" client = GpProviderClient( @@ -206,7 +206,7 @@ def test_access_structured_record_raises_external_service_error( """ provider_asid = "200000001154" consumer_asid = "200000001152" - provider_endpoint = "https://invalid.com" + provider_endpoint = "https://test.com" trace_id = "some_uuid_value" client = GpProviderClient( From a10527075889bd9c6e725e54da2918782066b61e Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Tue, 20 Jan 2026 13:36:15 +0000 Subject: [PATCH 18/20] [GPCAPIM-251]: Fix constant naming for interaction ID --- gateway-api/src/gateway_api/provider_request.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index b42231e1..360c8ec5 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -26,7 +26,7 @@ from requests import HTTPError, Response, post -ars_interactionId = ( +ARS_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:structured" ":fhir:operation:gpc.getstructuredrecord-1" ) @@ -96,7 +96,7 @@ def _build_headers(self, trace_id: str) -> dict[str, str]: return { "Content-Type": "application/fhir+json", "Accept": "application/fhir+json", - "Ssp-InteractionID": ars_interactionId, + "Ssp-InteractionID": ARS_INTERACTION_ID, "Ssp-To": self.provider_asid, "Ssp-From": self.consumer_asid, "Ssp-TraceID": trace_id, From e790853ce38c43aca90abe245fd844de68c380d7 Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Tue, 20 Jan 2026 13:38:34 +0000 Subject: [PATCH 19/20] [GPCAPIM-251]: Implement stub provider for development - Introduced GpProviderStub to simulate provider responses - Updated post method to use stub for structured record access - Enhanced StubResponse to set encoding to UTF-8 --- gateway-api/src/gateway_api/provider_request.py | 16 +++++++++++++++- gateway-api/stubs/stubs/stub_provider.py | 2 ++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index 360c8ec5..28ba8091 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -22,9 +22,11 @@ The response from the provider FHIR API. """ +from collections.abc import Callable from urllib.parse import urljoin -from requests import HTTPError, Response, post +from requests import HTTPError, Response +from stubs.stub_provider import GpProviderStub ARS_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:structured" @@ -35,6 +37,18 @@ ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" TIMEOUT = None # None used for quicker dev, adjust as needed +# Direct all requests to the stub provider for steel threading in dev. +# Replace with `from requests import post` for real requests. +PostCallable = Callable[..., Response] +_provider_stub = GpProviderStub() + + +def _stubbed_post(*_args: object, **_kwargs: object) -> Response: + return _provider_stub.access_record_structured() + + +post: PostCallable = _stubbed_post + class ExternalServiceError(Exception): """ diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index 32308c80..337df0ec 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -38,10 +38,12 @@ def __init__( reason: str, ) -> None: """Create a FakeResponse instance.""" + super().__init__() self.status_code = status_code self._content = _content self.headers = CaseInsensitiveDict(headers) self.reason = reason + self.encoding = "utf-8" class GpProviderStub: From fddb69ea0eb1641fdd100fa664e4c381443158ad Mon Sep 17 00:00:00 2001 From: DWolfsNHS <229101201+DWolfsNHS@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:50:21 +0000 Subject: [PATCH 20/20] [GPCAPIM-251]: Refactor provider request handling and update tests - Update TIMEOUT type annotation for clarity - Modify stubbed_post function to accept trace_id and body parameters - Adjust test cases to match new stub signature and expected responses - Implement error handling for invalid trace_id in stub provider --- .../src/gateway_api/provider_request.py | 19 +++---------- .../src/gateway_api/test_provider_request.py | 28 ++++++------------- gateway-api/stubs/stubs/stub_provider.py | 18 +++++++++++- 3 files changed, 29 insertions(+), 36 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index 28ba8091..b43e4069 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -35,7 +35,7 @@ ARS_FHIR_BASE = "FHIR/STU3" FHIR_RESOURCE = "patient" ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" -TIMEOUT = None # None used for quicker dev, adjust as needed +TIMEOUT: int | None = None # None used for quicker dev, adjust as needed # Direct all requests to the stub provider for steel threading in dev. # Replace with `from requests import post` for real requests. @@ -43,8 +43,9 @@ _provider_stub = GpProviderStub() -def _stubbed_post(*_args: object, **_kwargs: object) -> Response: - return _provider_stub.access_record_structured() +def _stubbed_post(trace_id: str, body: str) -> Response: + """A stubbed requests.post function that routes to the GPProviderStub.""" + return _provider_stub.access_record_structured(trace_id, body) post: PostCallable = _stubbed_post @@ -79,18 +80,6 @@ def __init__( provider_asid: str, consumer_asid: str, ) -> None: - """ - Create a GPProviderClient instance. - - Args: - provider_endpoint (str): The FHIR API endpoint for the provider. - provider_asid (str): The ASID for the provider. - consumer_asid (str): The ASID for the consumer. - - methods: - access_structured_record: fetch structured patient record - from GPProvider FHIR API. - """ self.provider_endpoint = provider_endpoint self.provider_asid = provider_asid self.consumer_asid = consumer_asid diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 9614314e..99b92f70 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -55,7 +55,10 @@ def _fake_post( capture["data"] = data capture["url"] = url - return stub.access_record_structured() + # 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(provider_request, "post", _fake_post) return capture @@ -187,11 +190,11 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( consumer_asid=consumer_asid, ) - expected_response = stub.access_record_structured() + expected_response = stub.access_record_structured(trace_id, "body") result = client.access_structured_record(trace_id, "body") - assert result.status_code == expected_response.status_code + assert result.status_code == 200 assert result.content == expected_response.content @@ -207,7 +210,7 @@ def test_access_structured_record_raises_external_service_error( provider_asid = "200000001154" consumer_asid = "200000001152" provider_endpoint = "https://test.com" - trace_id = "some_uuid_value" + trace_id = "invalid for test" client = GpProviderClient( provider_endpoint=provider_endpoint, @@ -215,23 +218,8 @@ def test_access_structured_record_raises_external_service_error( consumer_asid=consumer_asid, ) - # Simulate an error response from the stub - def _fake_post_error( - url: str, - headers: CaseInsensitiveDict[str], - data: str, - timeout: int, - ) -> Response: - response = Response() - response.status_code = 500 - response._content = b"Internal Server Error" # noqa: SLF001 TODO: push this back into the stub? - response.reason = "Internal Server Error" - return response - - monkeypatch.setattr(provider_request, "post", _fake_post_error) - with pytest.raises( ExternalServiceError, - match="GPProvider FHIR API request failed:Internal Server Error", + match="GPProvider FHIR API request failed:Bad Request", ): client.access_structured_record(trace_id, "body") diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index 337df0ec..3b7c9dd9 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -100,7 +100,11 @@ def __init__(self) -> None: ], } - def access_record_structured(self) -> StubResponse: + def access_record_structured( + self, + trace_id: str, + body: str, # noqa: ARG002 Unused parameter for interface compatibility + ) -> StubResponse: """ Simulate accessRecordStructured operation of GPConnect FHIR API. @@ -115,4 +119,16 @@ def access_record_structured(self) -> StubResponse: _content=json.dumps(self.patient_bundle).encode("utf-8"), ) + if trace_id == "invalid for test": + return StubResponse( + status_code=400, + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + reason="Bad Request", + _content=( + b'{"resourceType":"OperationOutcome","issue":[' + b'{"severity":"error","code":"invalid",' + b'"diagnostics":"Invalid for testing"}]}' + ), + ) + return stub_response