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 0000000..3a2c70f --- /dev/null +++ b/gateway-api/src/gateway_api/provider_request.py @@ -0,0 +1,142 @@ +""" +Module: gateway_api.provider_request + +This module contains the GpProviderClient class, which provides a +simple client for interacting with the GPProvider FHIR GP System. + +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 + +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 # None used for quicker dev, adjust as needed + + +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. + """ + + +class GpProviderClient: + """ + 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__( + 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. + + 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 _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", + "Accept": "application/fhir+json", + "Ssp-InteractionID": ars_interactionId, + "Ssp-To": self.provider_asid, + "Ssp-From": self.consumer_asid, + "Ssp-TraceID": trace_id, + } + + def access_structured_record( + self, + trace_id: str, + body: str, + ) -> 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. + + Raises: + ExternalServiceError: If the API request fails with an HTTP error. + """ + + headers = self._build_headers(trace_id) + + response = requests.post( + self.provider_endpoint + ars_fhir_base + "/patient/" + ars_fhir_operation, + headers=headers, + data=body, + timeout=timeout, + ) + + 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 new file mode 100644 index 0000000..6e92fca --- /dev/null +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -0,0 +1,240 @@ +""" +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. + +""" + +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.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 + + +@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, + ) -> Response: + """A fake requests.post implementation.""" + + capture["headers"] = dict(headers) + capture["data"] = data + capture["url"] = url + + return stub.access_record_structured() + + monkeypatch.setattr(requests, "post", _fake_post) + return capture + + +def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( + mock_request_post: dict[str, Any], + stub: GpProviderStub, +) -> 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://invalid.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], + stub: GpProviderStub, +) -> 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://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, + } + + result = client.access_structured_record(trace_id, "body") + + captured_headers = mock_request_post["headers"] + + for key, value in expected_headers.items(): + assert captured_headers.get(key) == value + assert result.status_code == 200 + + +def test_valid_gpprovider_access_structured_record_with_correct_body_200( + mock_request_post: dict[str, Any], + stub: GpProviderStub, +) -> 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://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, + ) + + 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], + 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://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() + + result = client.access_structured_record(trace_id, "body") + + 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") diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py new file mode 100644 index 0000000..dd8f15e --- /dev/null +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -0,0 +1,118 @@ +""" +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 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 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.""" + + 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: + """ + 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 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 = { + "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) -> StubResponse: + """ + Simulate accessRecordStructured operation of GPConnect FHIR API. + + returns: + Response: The stub patient bundle wrapped in a Response object. + """ + + stub_response = StubResponse( + status_code=200, + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + reason="OK", + _content=json.dumps(self.patient_bundle).encode("utf-8"), + ) + + return stub_response