Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d6b5fc9
[GPCAPIM-251] initial commit with basic file structure and descriptions
DWolfsNHS Jan 8, 2026
e124406
[GPCAPIM-251]: Implement GPProviderClient for FHIR API interaction
DWolfsNHS Jan 14, 2026
296403f
[GPCAPIM-251]: Refactor GPProviderClient and enhance test coverage
DWolfsNHS Jan 14, 2026
02fa73e
[GPCAPIM-251]: Update GPProviderClient methods for FHIR API interaction
DWolfsNHS Jan 14, 2026
7908028
[GPCAPIM-251]: Update GPProviderClient and stub for FHIR API interaction
DWolfsNHS Jan 15, 2026
f6680ee
[GPCAPIM-251]: Refactor GPProvider class names for consistency and sw…
DWolfsNHS Jan 15, 2026
deae9e6
[GPCAPIM-251]: Enhance GPProviderClient to include headers for FHIR A…
DWolfsNHS Jan 15, 2026
2ea0376
[GPCAPIM-251]: Update GpProviderClient to handle request body
DWolfsNHS Jan 15, 2026
74f0db7
[GPCAPIM-251]: Add tests for GPProviderClient response handling
DWolfsNHS Jan 15, 2026
e0e80be
[GPCAPIM-251]: Refactor response handling in GPProviderStub and tests
DWolfsNHS Jan 16, 2026
5ecc05c
[GPCAPIM-251]: Refactor response handling in stub provider and tests
DWolfsNHS Jan 16, 2026
ecb046d
[GPCAPIM-251]: Refactor GPProviderClient to improve request handling
DWolfsNHS Jan 16, 2026
b5448c5
[GPCAPIM-251]: cleanup and update documentation
DWolfsNHS Jan 16, 2026
7b94e69
[GPCAPIM-251]: Add error handling test for access_structured_record
DWolfsNHS Jan 16, 2026
2026888
[GPCAPIM-251]: Address peer feedback
DWolfsNHS Jan 19, 2026
a25d463
[GPCAPIM-251]: PR feedback
DWolfsNHS Jan 20, 2026
fc4aa34
[GPCAPIM-251]: Update remaining provider endpoint in tests
DWolfsNHS Jan 20, 2026
a105270
[GPCAPIM-251]: Fix constant naming for interaction ID
DWolfsNHS Jan 20, 2026
e790853
[GPCAPIM-251]: Implement stub provider for development
DWolfsNHS Jan 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions gateway-api/src/gateway_api/provider_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""
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.
"""

from collections.abc import Callable
from urllib.parse import urljoin

from requests import HTTPError, Response
from stubs.stub_provider import GpProviderStub

ARS_INTERACTION_ID = (
"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

# 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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could move this into a common library. I've started one in my current ticket anyway.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - happy to pattern here, or integrate this as we merge/rebase

"""
Exception raised when the downstream GPProvider FHIR API request fails.
"""


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.
Comment on lines +85 to +88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest we write docstrings with Sphinx-style argument listing. Then we can auto-generate our documentation (though that said I don't know if the likes of ChatGPT are going to obsolete the likes of Sphinx). Still, it's standard and it gives us the option, and it's easy enough to just ask the AI to do. Don't know if @davidhamill1-nhs and @nhsd-jack-wainwright agree?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a starter for ten, I'm okay with this.

My preference is to avoid primitives in the parameters, and define ProviderEndpoint, etc. types some other way (rather than saying they can be any string). By doing so, your type hinting is your documentation.

I assume any LLM running over the codebase will correctly pull the type and any validations/restrictions, and thus you can still gain the AI's summary of the code.

But happy to go with the consensus.


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_INTERACTION_ID,
"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)

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,
)

try:
response.raise_for_status()
except HTTPError as err:
raise ExternalServiceError(
f"GPProvider FHIR API request failed:{err.response.reason}"
) from err

return response
237 changes: 237 additions & 0 deletions gateway-api/src/gateway_api/test_provider_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
"""
Unit tests for :mod:`gateway_api.provider_request`.

This module contains unit tests for the `GpProviderClient` class, which is responsible
for interacting with the GPProvider FHIR API.

"""

from typing import Any

import pytest
from requests import Response
from requests.structures import CaseInsensitiveDict
from stubs.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"
)


@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(provider_request, "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://test.com"
trace_id = "some_uuid_value"

client = GpProviderClient(
provider_endpoint=provider_endpoint,
provider_asid=provider_asid,
consumer_asid=consumer_asid,
)

result = client.access_structured_record(trace_id, "body")

captured_url = mock_request_post.get("url", provider_endpoint)

assert (
captured_url
== provider_endpoint + "/FHIR/STU3/patient/$gpc.getstructuredrecord"
)
assert result.status_code == 200


def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200(
mock_request_post: dict[str, Any],
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://test.com"
trace_id = "some_uuid_value"

client = GpProviderClient(
provider_endpoint=provider_endpoint,
provider_asid=provider_asid,
consumer_asid=consumer_asid,
)
expected_headers = {
"Content-Type": "application/fhir+json",
"Accept": "application/fhir+json",
"Ssp-TraceID": str(trace_id),
"Ssp-From": consumer_asid,
"Ssp-To": provider_asid,
"Ssp-InteractionID": ars_interactionId,
}

result = client.access_structured_record(trace_id, "body")

captured_headers = mock_request_post["headers"]

assert expected_headers == captured_headers
assert result.status_code == 200


def test_valid_gpprovider_access_structured_record_with_correct_body_200(
mock_request_post: dict[str, Any],
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://test.com"
trace_id = "some_uuid_value"

request_body = "some_FHIR_request_params"

client = GpProviderClient(
provider_endpoint=provider_endpoint,
provider_asid=provider_asid,
consumer_asid=consumer_asid,
)

result = client.access_structured_record(trace_id, request_body)

captured_body = mock_request_post["data"]

assert result.status_code == 200
assert captured_body == request_body


def test_valid_gpprovider_access_structured_record_returns_stub_response_200(
mock_request_post: dict[str, Any],
stub: GpProviderStub,
) -> None:
"""
Test that the `access_structured_record` method returns the same response
as provided by the stub provider.

This test verifies that the response from the GPProvider FHIR API matches
the expected response, including the status code and content.
"""
provider_asid = "200000001154"
consumer_asid = "200000001152"
provider_endpoint = "https://test.com"
trace_id = "some_uuid_value"

client = GpProviderClient(
provider_endpoint=provider_endpoint,
provider_asid=provider_asid,
consumer_asid=consumer_asid,
)

expected_response = stub.access_record_structured()

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://test.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(provider_request, "post", _fake_post_error)

with pytest.raises(
ExternalServiceError,
match="GPProvider FHIR API request failed:Internal Server Error",
):
client.access_structured_record(trace_id, "body")
Loading
Loading