From 93d1124c6f41b7d5f0810ff65fe3233d3df93cf8 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:07:41 +0000 Subject: [PATCH 01/22] Start of SDS module --- gateway-api/src/gateway_api/sds_search.py | 282 ++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 gateway-api/src/gateway_api/sds_search.py diff --git a/gateway-api/src/gateway_api/sds_search.py b/gateway-api/src/gateway_api/sds_search.py new file mode 100644 index 00000000..3026bf7d --- /dev/null +++ b/gateway-api/src/gateway_api/sds_search.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +import requests + +# Recursive JSON-like structure typing (mirrors the approach in pds_search.py). +ResultStructure = ( + str + | int + | float + | bool + | None + | dict[str, "ResultStructure"] + | list["ResultStructure"] +) +ResultStructureDict = dict[str, ResultStructure] +ResultList = list[ResultStructureDict] + + +class ExternalServiceError(Exception): + """ + Raised when the downstream SDS request fails. + + Wraps requests.HTTPError so callers are not coupled to requests exception types. + """ + + +@dataclass(frozen=True) +class DeviceLookupResult: + """ + Result of an SDS /Device lookup. + + :param asid: Accredited System Identifier (ASID), if found. + :param endpoint_url: Endpoint URL, if found. + """ + + asid: str | None + endpoint_url: str | None + + +class SdsClient: + """ + Simple client for SDS FHIR R4 /Device lookup. + + Calls GET /Device (returns a FHIR Bundle) and extracts: + - ASID from Device.identifier[].value + - Endpoint URL from Device.extension[] (best-effort) + + Notes: + - /Device requires both 'organization' and 'identifier' query params. + - 'identifier' must include a service interaction ID; may also include an MHS party + key. + """ + + SANDBOX_URL = "https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4" + INT_URL = "https://int.api.service.nhs.uk/spine-directory/FHIR/R4" + DEP_UAT_URL = "https://dep.api.service.nhs.uk/spine-directory/FHIR/R4" + PROD_URL = "https://api.service.nhs.uk/spine-directory/FHIR/R4" + + # Default taken from the OpenAPI example. In real usage you should pass the + # interaction ID relevant to the service you are routing to. + DEFAULT_SERVICE_INTERACTION_ID = "urn:nhs:names:services:psis:REPC_IN150016UK05" + + ODS_SYSTEM = "https://fhir.nhs.uk/Id/ods-organization-code" + INTERACTION_SYSTEM = "https://fhir.nhs.uk/Id/nhsServiceInteractionId" + PARTYKEY_SYSTEM = "https://fhir.nhs.uk/Id/nhsMhsPartyKey" + + def __init__( + self, + api_key: str, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ) -> None: + """ + :param api_key: SDS subscription key value (header 'apikey'). In Sandbox, any + value works. + :param base_url: Base URL for the SDS API. Trailing slashes are stripped. + :param timeout: Default timeout in seconds for HTTP calls. + """ + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def _build_headers(self, correlation_id: str | None = None) -> dict[str, str]: + headers = { + "Accept": "application/fhir+json", + "apikey": self.api_key, + } + if correlation_id: + headers["X-Correlation-Id"] = correlation_id + return headers + + def lookup_device_asid_and_endpoint( + self, + device_ods_code: str, + service_interaction_id: str | None = None, + party_key: str | None = None, + manufacturing_ods_code: str | None = None, + correlation_id: str | None = None, + timeout: int | None = None, + ) -> DeviceLookupResult: + """ + Look up an accredited system by organisation ODS code plus service interaction + ID (and optionally party key/manufacturing org), returning ASID and endpoint URL + + :param device_ods_code: ODS code used in the required 'organization' query + parameter. + :param service_interaction_id: Interaction ID for the target service (required + by SDS /Device). If not supplied, a default from the OpenAPI example is used + :param party_key: Optional MHS party key (included as a second 'identifier' + occurrence). + :param manufacturing_ods_code: Optional manufacturing organisation ODS code. + :param correlation_id: Optional correlation ID for tracing. + :param timeout: Optional per-call timeout in seconds. + """ + bundle = self._get_device_bundle( + organization_ods=device_ods_code, + service_interaction_id=service_interaction_id + or self.DEFAULT_SERVICE_INTERACTION_ID, + party_key=party_key, + manufacturing_ods=manufacturing_ods_code, + correlation_id=correlation_id, + timeout=timeout, + ) + + entries = cast("list[dict[str, Any]]", bundle.get("entry", [])) + if not entries: + return DeviceLookupResult(asid=None, endpoint_url=None) + + # Best-effort: return first entry that yields an ASID; else fall back to first + # TODO: Look at this again. If we don't get a hit then should return None + best: DeviceLookupResult | None = None + for entry in entries: + device = cast("dict[str, Any]", entry.get("resource", {})) + asid = self._extract_asid(device) + endpoint_url = self._extract_endpoint_url(device) + candidate = DeviceLookupResult(asid=asid, endpoint_url=endpoint_url) + if asid: + return candidate + best = best or candidate + + return best or DeviceLookupResult(asid=None, endpoint_url=None) + + def _get_device_bundle( + self, + organization_ods: str, + service_interaction_id: str, + party_key: str | None, + manufacturing_ods: str | None, + correlation_id: str | None, + timeout: int | None, + ) -> dict[str, Any]: + headers = self._build_headers(correlation_id=correlation_id) + + url = f"{self.base_url}/Device" + + params: dict[str, Any] = { + "organization": f"{self.ODS_SYSTEM}|{organization_ods}", + # Explode=true means repeating identifier=... is acceptable; requests + # will encode a list as repeated query params. + "identifier": [f"{self.INTERACTION_SYSTEM}|{service_interaction_id}"], + } + + if party_key: + params["identifier"].append(f"{self.PARTYKEY_SYSTEM}|{party_key}") + + if manufacturing_ods: + params["manufacturing-organization"] = ( + f"{self.ODS_SYSTEM}|{manufacturing_ods}" + ) + + response = requests.get( + url, + headers=headers, + params=params, + timeout=timeout or self.timeout, + ) + + try: + response.raise_for_status() + except requests.HTTPError as err: + raise ExternalServiceError( + f"SDS /Device request failed: {err.response.status_code} " + f"{err.response.reason}" + ) from err + + body = response.json() + return cast("dict[str, Any]", body) + + @staticmethod + def _extract_asid(device: dict[str, Any]) -> str | None: + """ + ASID is described by the caller as: "the value field in the identifier array". + + The schema is generic (identifier[] elements are {system, value}), so this uses + a best-effort heuristic: + 1) Prefer an identifier whose system mentions 'asid' + 2) Else return the first identifier.value present + """ + # TODO: No, just take identifier.value, not system + # TODO: But check that identifier.value is actually the ASID + identifiers = cast("list[dict[str, Any]]", device.get("identifier", [])) + if not identifiers: + return None + + def value_of(item: dict[str, Any]) -> str | None: + v = item.get("value") + return str(v).strip() if v is not None and str(v).strip() else None + + # TODO: No! + # Prefer system containing "asid" + for ident in identifiers: + system = str(ident.get("system", "") or "").lower() + if "asid" in system: + v = value_of(ident) + if v: + return v + + # TODO: Also No! + # Fallback: first non-empty value + for ident in identifiers: + v = value_of(ident) + if v: + return v + + return None + + @staticmethod + def _extract_endpoint_url(device: dict[str, Any]) -> str | None: + """ + The caller asked for: "endpoint URL, which is the 'url' field in the 'extension' + array". + + In the schema, each extension item has: + - url + - valueReference.identifier.{system,value} + + Best-effort strategy: + 1) If valueReference.identifier.value looks like a URL, return that + 2) Else return extension.url if it looks like a URL + """ + # TODO: Stupid AI. I said extension.url, not identifier.value + extensions = cast("list[dict[str, Any]]", device.get("extension", [])) + if not extensions: + return None + + def looks_like_url(s: str) -> bool: + return s.startswith("http://") or s.startswith("https://") + + for ext in extensions: + vr = cast("dict[str, Any]", ext.get("valueReference", {}) or {}) + ident = cast("dict[str, Any]", vr.get("identifier", {}) or {}) + v = str(ident.get("value", "") or "").strip() + if v and looks_like_url(v): + return v + + for ext in extensions: + u = str(ext.get("url", "") or "").strip() + if u and looks_like_url(u): + return u + + return None + + +# TODO: Delete this but leave for now to make sure I'm calling right +# ---------------- example usage ---------------- +if __name__ == "__main__": + sds = SdsClient( + api_key="any-value-works-in-sandbox", + base_url=SdsClient.SANDBOX_URL, + ) + + result = sds.lookup_device_asid_and_endpoint( + device_ods_code="YES", + # Optionally override these: + # service_interaction_id="urn:nhs:names:services:psis:REPC_IN150016UK05", + # party_key="YES-0000806", + ) + + print(result) From f6236e8e46b4e08e2282dbb30d46a05864c65624 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:39:51 +0000 Subject: [PATCH 02/22] First-cut SDS access module --- gateway-api/src/gateway_api/controller.py | 63 +- gateway-api/src/gateway_api/sds_search.py | 356 ++++---- .../src/gateway_api/test_sds_search.py | 428 ++++++++++ gateway-api/stubs/stubs/stub_sds.py | 785 ++++++++++++++++++ 4 files changed, 1397 insertions(+), 235 deletions(-) create mode 100644 gateway-api/src/gateway_api/test_sds_search.py create mode 100644 gateway-api/stubs/stubs/stub_sds.py diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 4a17d08c..f186bcb2 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -18,6 +18,7 @@ from gateway_api.common.common import FlaskResponse from gateway_api.pds_search import PdsClient, PdsSearchResults +from gateway_api.sds_search import SdsClient, SdsSearchResults @dataclass @@ -44,62 +45,6 @@ def __str__(self) -> str: return self.message -@dataclass -class SdsSearchResults: - """ - Stub SDS search results dataclass. - - Replace this with the real one once it's implemented. - - :param asid: Accredited System ID. - :param endpoint: Endpoint URL associated with the organisation, if applicable. - """ - - asid: str - endpoint: str | None - - -class SdsClient: - """ - Stub SDS client for obtaining ASID from ODS code. - - Replace this with the real one once it's implemented. - """ - - SANDBOX_URL = "https://example.invalid/sds" - - def __init__( - self, - auth_token: str, - base_url: str = SANDBOX_URL, - timeout: int = 10, - ) -> None: - """ - Create an SDS client. - - :param auth_token: Authentication token to present to SDS. - :param base_url: Base URL for SDS. - :param timeout: Timeout in seconds for SDS calls. - """ - self.auth_token = auth_token - self.base_url = base_url - self.timeout = timeout - - def get_org_details(self, ods_code: str) -> SdsSearchResults | None: - """ - Retrieve SDS org details for a given ODS code. - - This is a placeholder implementation that always returns an ASID and endpoint. - - :param ods_code: ODS code to look up. - :returns: SDS search results or ``None`` if not found. - """ - # Placeholder implementation - return SdsSearchResults( - asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" - ) - - class Controller: """ Orchestrates calls to PDS -> SDS -> GP provider. @@ -113,7 +58,7 @@ class Controller: def __init__( self, pds_base_url: str = PdsClient.SANDBOX_URL, - sds_base_url: str = "https://example.invalid/sds", + sds_base_url: str = SdsClient.SANDBOX_URL, nhsd_session_urid: str | None = None, timeout: int = 10, ) -> None: @@ -252,7 +197,7 @@ def _get_sds_details( - provider details (ASID + endpoint) - consumer details (ASID) - :param auth_token: Authorization token to use for SDS. + :param auth_token: Authorization token to use for SDS (used as API key). :param consumer_ods: Consumer organisation ODS code (from request headers). :param provider_ods: Provider organisation ODS code (from PDS). :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint). @@ -260,7 +205,7 @@ def _get_sds_details( """ # SDS: Get provider details (ASID + endpoint) for provider ODS sds = SdsClient( - auth_token=auth_token, + api_key=auth_token, base_url=self.sds_base_url, timeout=self.timeout, ) diff --git a/gateway-api/src/gateway_api/sds_search.py b/gateway-api/src/gateway_api/sds_search.py index 3026bf7d..f6c70d75 100644 --- a/gateway-api/src/gateway_api/sds_search.py +++ b/gateway-api/src/gateway_api/sds_search.py @@ -1,177 +1,233 @@ +""" +SDS (Spine Directory Service) FHIR R4 device and endpoint lookup client. + +This module provides a client for querying the Spine Directory Service to retrieve: +- Device records (including ASID - Accredited System ID) +- Endpoint records (including endpoint URLs for routing) + +The client is structured similarly to :mod:`gateway_api.pds_search` and supports +stubbing for testing purposes. +""" + from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any, Literal, cast import requests +from stubs.stub_sds import SdsFhirApiStub + +# Recursive JSON-like structure typing used for parsed FHIR bodies. +type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] +type ResultStructureDict = dict[str, ResultStructure] +type ResultList = list[ResultStructureDict] -# Recursive JSON-like structure typing (mirrors the approach in pds_search.py). -ResultStructure = ( - str - | int - | float - | bool - | None - | dict[str, "ResultStructure"] - | list["ResultStructure"] -) -ResultStructureDict = dict[str, ResultStructure] -ResultList = list[ResultStructureDict] +# Type for stub get method +type GetCallable = Callable[..., requests.Response] class ExternalServiceError(Exception): """ Raised when the downstream SDS request fails. - Wraps requests.HTTPError so callers are not coupled to requests exception types. + This module catches :class:`requests.HTTPError` thrown by + ``response.raise_for_status()`` and re-raises it as ``ExternalServiceError`` so + callers are not coupled to ``requests`` exception types. """ -@dataclass(frozen=True) -class DeviceLookupResult: +@dataclass +class SdsSearchResults: """ - Result of an SDS /Device lookup. + SDS lookup results containing ASID and endpoint information. - :param asid: Accredited System Identifier (ASID), if found. - :param endpoint_url: Endpoint URL, if found. + :param asid: Accredited System ID extracted from the Device resource. + :param endpoint: Endpoint URL extracted from the Endpoint resource, or ``None`` + if no endpoint is available. """ asid: str | None - endpoint_url: str | None + endpoint: str | None class SdsClient: """ - Simple client for SDS FHIR R4 /Device lookup. + Simple client for SDS FHIR R4 device and endpoint retrieval. - Calls GET /Device (returns a FHIR Bundle) and extracts: - - ASID from Device.identifier[].value - - Endpoint URL from Device.extension[] (best-effort) + The client supports: - Notes: - - /Device requires both 'organization' and 'identifier' query params. - - 'identifier' must include a service interaction ID; may also include an MHS party - key. + * :meth:`get_org_details` - Retrieves ASID and endpoint for an organization + + This method returns a :class:`SdsSearchResults` instance when data can be + extracted, otherwise ``None``. + + **Usage example**:: + + sds = SdsClient( + api_key="YOUR_API_KEY", + base_url="https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4", + ) + + result = sds.get_org_details("A12345") + + if result: + print(f"ASID: {result.asid}, Endpoint: {result.endpoint}") """ + # URLs for different SDS environments SANDBOX_URL = "https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4" INT_URL = "https://int.api.service.nhs.uk/spine-directory/FHIR/R4" DEP_UAT_URL = "https://dep.api.service.nhs.uk/spine-directory/FHIR/R4" PROD_URL = "https://api.service.nhs.uk/spine-directory/FHIR/R4" - # Default taken from the OpenAPI example. In real usage you should pass the - # interaction ID relevant to the service you are routing to. - DEFAULT_SERVICE_INTERACTION_ID = "urn:nhs:names:services:psis:REPC_IN150016UK05" - + # FHIR identifier systems ODS_SYSTEM = "https://fhir.nhs.uk/Id/ods-organization-code" INTERACTION_SYSTEM = "https://fhir.nhs.uk/Id/nhsServiceInteractionId" PARTYKEY_SYSTEM = "https://fhir.nhs.uk/Id/nhsMhsPartyKey" + ASID_SYSTEM = "https://fhir.nhs.uk/Id/nhsSpineASID" + + # SDS resource types + DEVICE: Literal["Device"] = "Device" + ENDPOINT: Literal["Endpoint"] = "Endpoint" + + # Default service interaction ID for GP Connect + DEFAULT_SERVICE_INTERACTION_ID = ( + "urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1" + ) def __init__( self, api_key: str, base_url: str = SANDBOX_URL, timeout: int = 10, + service_interaction_id: str | None = None, ) -> None: """ - :param api_key: SDS subscription key value (header 'apikey'). In Sandbox, any - value works. + Create an SDS client. + + :param api_key: API key for SDS authentication (header 'apikey'). :param base_url: Base URL for the SDS API. Trailing slashes are stripped. :param timeout: Default timeout in seconds for HTTP calls. + :param service_interaction_id: Service interaction ID to use for lookups. + If not provided, uses :attr:`DEFAULT_SERVICE_INTERACTION_ID`. """ self.api_key = api_key self.base_url = base_url.rstrip("/") self.timeout = timeout + self.service_interaction_id = ( + service_interaction_id or self.DEFAULT_SERVICE_INTERACTION_ID + ) + self.stub = SdsFhirApiStub() + + # Use stub for now - use environment variable once we have one + # TODO: Put this back to using the environment variable + # if os.environ.get("STUB_SDS", None): + self.get_method: GetCallable = self.stub.get + # else: + # self.get_method: GetCallable = requests.get def _build_headers(self, correlation_id: str | None = None) -> dict[str, str]: + """ + Build mandatory and optional headers for an SDS request. + + :param correlation_id: Optional ``X-Correlation-Id`` for cross-system tracing. + :return: Dictionary of HTTP headers for the outbound request. + """ headers = { "Accept": "application/fhir+json", "apikey": self.api_key, } + if correlation_id: headers["X-Correlation-Id"] = correlation_id + return headers - def lookup_device_asid_and_endpoint( + def get_org_details( self, - device_ods_code: str, - service_interaction_id: str | None = None, - party_key: str | None = None, - manufacturing_ods_code: str | None = None, + ods_code: str, correlation_id: str | None = None, timeout: int | None = None, - ) -> DeviceLookupResult: + ) -> SdsSearchResults | None: """ - Look up an accredited system by organisation ODS code plus service interaction - ID (and optionally party key/manufacturing org), returning ASID and endpoint URL - - :param device_ods_code: ODS code used in the required 'organization' query - parameter. - :param service_interaction_id: Interaction ID for the target service (required - by SDS /Device). If not supplied, a default from the OpenAPI example is used - :param party_key: Optional MHS party key (included as a second 'identifier' - occurrence). - :param manufacturing_ods_code: Optional manufacturing organisation ODS code. + Retrieve ASID and endpoint for an organization by ODS code. + + This method performs two SDS queries: + 1. Query /Device to get the ASID for the organization + 2. Query /Endpoint to get the endpoint URL (if available) + + :param ods_code: ODS code of the organization to look up. :param correlation_id: Optional correlation ID for tracing. - :param timeout: Optional per-call timeout in seconds. + :param timeout: Optional per-call timeout in seconds. If not provided, + :attr:`timeout` is used. + :return: A :class:`SdsSearchResults` instance if data can be extracted, + otherwise ``None``. + :raises ExternalServiceError: If the HTTP request returns an error status. """ - bundle = self._get_device_bundle( - organization_ods=device_ods_code, - service_interaction_id=service_interaction_id - or self.DEFAULT_SERVICE_INTERACTION_ID, - party_key=party_key, - manufacturing_ods=manufacturing_ods_code, + # Step 1: Get Device to obtain ASID + device_bundle = self._query_sds( + ods_code=ods_code, correlation_id=correlation_id, timeout=timeout, + querytype=self.DEVICE, ) - entries = cast("list[dict[str, Any]]", bundle.get("entry", [])) - if not entries: - return DeviceLookupResult(asid=None, endpoint_url=None) - - # Best-effort: return first entry that yields an ASID; else fall back to first - # TODO: Look at this again. If we don't get a hit then should return None - best: DeviceLookupResult | None = None - for entry in entries: - device = cast("dict[str, Any]", entry.get("resource", {})) - asid = self._extract_asid(device) - endpoint_url = self._extract_endpoint_url(device) - candidate = DeviceLookupResult(asid=asid, endpoint_url=endpoint_url) - if asid: - return candidate - best = best or candidate - - return best or DeviceLookupResult(asid=None, endpoint_url=None) - - def _get_device_bundle( + device = self._extract_first_entry(device_bundle) + if device is None: + return None + + asid = self._extract_identifier(device, self.ASID_SYSTEM) + party_key = self._extract_identifier(device, self.PARTYKEY_SYSTEM) + + # Step 2: Get Endpoint to obtain endpoint URL + endpoint_url: str | None = None + if party_key: + endpoint_bundle = self._query_sds( + ods_code=ods_code, + party_key=party_key, + correlation_id=correlation_id, + timeout=timeout, + querytype=self.ENDPOINT, + ) + endpoint = self._extract_first_entry(endpoint_bundle) + if endpoint: + address = endpoint.get("address") + if address: + endpoint_url = str(address).strip() + + return SdsSearchResults(asid=asid, endpoint=endpoint_url) + + def _query_sds( self, - organization_ods: str, - service_interaction_id: str, - party_key: str | None, - manufacturing_ods: str | None, - correlation_id: str | None, - timeout: int | None, - ) -> dict[str, Any]: + ods_code: str, + party_key: str | None = None, + correlation_id: str | None = None, + timeout: int | None = 10, + querytype: Literal["Device", "Endpoint"] = DEVICE, + ) -> ResultStructureDict: + """ + Query SDS /Device or /Endpoint endpoint. + + :param ods_code: ODS code to search for. + :param party_key: Party key to search for. + :param correlation_id: Optional correlation ID. + :param timeout: Optional timeout. + :return: Parsed JSON response as a dictionary. + :raises ExternalServiceError: If the request fails. + """ headers = self._build_headers(correlation_id=correlation_id) - - url = f"{self.base_url}/Device" + url = f"{self.base_url}/{querytype}" params: dict[str, Any] = { - "organization": f"{self.ODS_SYSTEM}|{organization_ods}", - # Explode=true means repeating identifier=... is acceptable; requests - # will encode a list as repeated query params. - "identifier": [f"{self.INTERACTION_SYSTEM}|{service_interaction_id}"], + "organization": f"{self.ODS_SYSTEM}|{ods_code}", + "identifier": [f"{self.INTERACTION_SYSTEM}|{self.service_interaction_id}"], } - if party_key: + if party_key is not None: params["identifier"].append(f"{self.PARTYKEY_SYSTEM}|{party_key}") - if manufacturing_ods: - params["manufacturing-organization"] = ( - f"{self.ODS_SYSTEM}|{manufacturing_ods}" - ) - - response = requests.get( + response = self.get_method( url, headers=headers, params=params, @@ -182,101 +238,49 @@ def _get_device_bundle( response.raise_for_status() except requests.HTTPError as err: raise ExternalServiceError( - f"SDS /Device request failed: {err.response.status_code} " + f"SDS /{querytype} request failed: {err.response.status_code} " f"{err.response.reason}" ) from err body = response.json() - return cast("dict[str, Any]", body) + return cast("ResultStructureDict", body) + + # --------------- internal helpers for result extraction ----------------- @staticmethod - def _extract_asid(device: dict[str, Any]) -> str | None: + def _extract_first_entry( + bundle: ResultStructureDict, + ) -> ResultStructureDict | None: """ - ASID is described by the caller as: "the value field in the identifier array". + Extract the first Device resource from a Bundle. - The schema is generic (identifier[] elements are {system, value}), so this uses - a best-effort heuristic: - 1) Prefer an identifier whose system mentions 'asid' - 2) Else return the first identifier.value present + :param bundle: FHIR Bundle containing Device resources. + :return: First Device resource, or ``None`` if the bundle is empty. """ - # TODO: No, just take identifier.value, not system - # TODO: But check that identifier.value is actually the ASID - identifiers = cast("list[dict[str, Any]]", device.get("identifier", [])) - if not identifiers: + entries = cast("ResultList", bundle.get("entry", [])) + if not entries: return None - def value_of(item: dict[str, Any]) -> str | None: - v = item.get("value") - return str(v).strip() if v is not None and str(v).strip() else None - - # TODO: No! - # Prefer system containing "asid" - for ident in identifiers: - system = str(ident.get("system", "") or "").lower() - if "asid" in system: - v = value_of(ident) - if v: - return v - - # TODO: Also No! - # Fallback: first non-empty value - for ident in identifiers: - v = value_of(ident) - if v: - return v - - return None + first_entry = entries[0] + return cast("ResultStructureDict", first_entry.get("resource", {})) - @staticmethod - def _extract_endpoint_url(device: dict[str, Any]) -> str | None: + def _extract_identifier( + self, device: ResultStructureDict, system: str + ) -> str | None: """ - The caller asked for: "endpoint URL, which is the 'url' field in the 'extension' - array". + Extract an identifier value from a Device resource for a given system. - In the schema, each extension item has: - - url - - valueReference.identifier.{system,value} - - Best-effort strategy: - 1) If valueReference.identifier.value looks like a URL, return that - 2) Else return extension.url if it looks like a URL + :param device: Device resource dictionary. + :param system: The identifier system to look for. + :return: Identifier value if found, otherwise ``None``. """ - # TODO: Stupid AI. I said extension.url, not identifier.value - extensions = cast("list[dict[str, Any]]", device.get("extension", [])) - if not extensions: - return None - - def looks_like_url(s: str) -> bool: - return s.startswith("http://") or s.startswith("https://") + identifiers = cast("ResultList", device.get("identifier", [])) - for ext in extensions: - vr = cast("dict[str, Any]", ext.get("valueReference", {}) or {}) - ident = cast("dict[str, Any]", vr.get("identifier", {}) or {}) - v = str(ident.get("value", "") or "").strip() - if v and looks_like_url(v): - return v - - for ext in extensions: - u = str(ext.get("url", "") or "").strip() - if u and looks_like_url(u): - return u + for identifier in identifiers: + id_system = str(identifier.get("system", "")) + if id_system == system: + value = identifier.get("value") + if value: + return str(value).strip() return None - - -# TODO: Delete this but leave for now to make sure I'm calling right -# ---------------- example usage ---------------- -if __name__ == "__main__": - sds = SdsClient( - api_key="any-value-works-in-sandbox", - base_url=SdsClient.SANDBOX_URL, - ) - - result = sds.lookup_device_asid_and_endpoint( - device_ods_code="YES", - # Optionally override these: - # service_interaction_id="urn:nhs:names:services:psis:REPC_IN150016UK05", - # party_key="YES-0000806", - ) - - print(result) diff --git a/gateway-api/src/gateway_api/test_sds_search.py b/gateway-api/src/gateway_api/test_sds_search.py new file mode 100644 index 00000000..b723287f --- /dev/null +++ b/gateway-api/src/gateway_api/test_sds_search.py @@ -0,0 +1,428 @@ +""" +Unit tests for :mod:`gateway_api.sds_search`. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from stubs.stub_sds import SdsFhirApiStub + +from gateway_api.sds_search import SdsClient, SdsSearchResults + + +@pytest.fixture +def stub() -> SdsFhirApiStub: + """ + Create a stub backend instance. + + :return: A :class:`stubs.stub_sds.SdsFhirApiStub` instance. + """ + return SdsFhirApiStub() + + +@pytest.fixture +def mock_requests_get( + monkeypatch: pytest.MonkeyPatch, stub: SdsFhirApiStub +) -> dict[str, Any]: + """ + Patch ``SdsFhirApiStub`` so the SdsClient uses the test stub fixture. + + The fixture returns a "capture" dict recording the most recent request information. + + :param monkeypatch: Pytest monkeypatch fixture. + :param stub: Stub backend used to serve GET requests. + :param return: A capture dictionary containing the last call details. + """ + capture: dict[str, Any] = {} + + # Wrap the stub's get method to capture call parameters + original_stub_get = stub.get + + def _capturing_get( + url: str, + headers: dict[str, str] | None = None, + params: Any = None, + timeout: Any = None, + ) -> Any: + """ + Wrapper around stub.get that captures parameters. + + :param url: URL passed by the client. + :param headers: Headers passed by the client. + :param params: Query parameters. + :param timeout: Timeout. + :return: Response from the stub. + """ + headers = headers or {} + capture["url"] = url + capture["headers"] = dict(headers) + capture["params"] = params + capture["timeout"] = timeout + + return original_stub_get(url, headers, params, timeout) + + stub.get = _capturing_get # type: ignore[method-assign] + + # Monkeypatch SdsFhirApiStub so SdsClient uses our test stub + import gateway_api.sds_search as sds_module + + monkeypatch.setattr( + sds_module, + "SdsFhirApiStub", + lambda *args, **kwargs: stub, # NOQA ARG005 (maintain signature) + ) + + return capture + + +def test_sds_client_get_org_details_success( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test SdsClient can successfully look up organization details. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + + result = client.get_org_details(ods_code="PROVIDER") + + assert result is not None + assert isinstance(result, SdsSearchResults) + assert result.asid == "asid_PROV" + assert result.endpoint is not None + + +def test_sds_client_get_org_details_with_endpoint( + stub: SdsFhirApiStub, + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test SdsClient retrieves endpoint when available. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + # Add a device with party key so we can get an endpoint + stub.upsert_device( + organization_ods="TESTORG", + service_interaction_id="urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1", + party_key="TESTORG-123456", + device={ + "resourceType": "Device", + "id": "test-device-id", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "999999999999", + }, + { + "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", + "value": "TESTORG-123456", + }, + ], + "owner": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "TESTORG", + } + }, + }, + ) + + stub.upsert_endpoint( + organization_ods="TESTORG", + service_interaction_id="urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1", + party_key="TESTORG-123456", + endpoint={ + "resourceType": "Endpoint", + "id": "test-endpoint-id", + "status": "active", + "address": "https://testorg.example.com/fhir", + "managingOrganization": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "TESTORG", + } + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", + "value": "TESTORG-123456", + } + ], + }, + ) + + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + result = client.get_org_details(ods_code="TESTORG") + + assert result is not None + assert result.asid == "999999999999" + assert result.endpoint == "https://testorg.example.com/fhir" + + +def test_sds_client_get_org_details_no_endpoint( + stub: SdsFhirApiStub, + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test SdsClient handles missing endpoint gracefully. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + # Add a device without a party key (so no endpoint will be found) + stub.upsert_device( + organization_ods="NOENDPOINT", + service_interaction_id="urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1", + party_key=None, + device={ + "resourceType": "Device", + "id": "noendpoint-device-id", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "888888888888", + } + ], + "owner": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "NOENDPOINT", + } + }, + }, + ) + + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + result = client.get_org_details(ods_code="NOENDPOINT") + + assert result is not None + assert result.asid == "888888888888" + assert result.endpoint is None + + +def test_sds_client_get_org_details_not_found( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test SdsClient returns None when organization is not found. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + + result = client.get_org_details(ods_code="NONEXISTENT") + + assert result is None + + +def test_sds_client_sends_correlation_id( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test that SdsClient sends X-Correlation-Id header when provided. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + + correlation_id = "test-correlation-123" + client.get_org_details(ods_code="PROVIDER", correlation_id=correlation_id) + + # Check that the header was sent + assert mock_requests_get["headers"]["X-Correlation-Id"] == correlation_id + + +def test_sds_client_sends_apikey( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test that SdsClient sends apikey header. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + api_key = "my-secret-key" + client = SdsClient(api_key=api_key, base_url=SdsClient.SANDBOX_URL) + + client.get_org_details(ods_code="PROVIDER") + + # Check that the apikey header was sent + assert mock_requests_get["headers"]["apikey"] == api_key + + +def test_sds_client_timeout_parameter( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test that SdsClient passes timeout parameter to requests. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL, timeout=30) + + client.get_org_details(ods_code="PROVIDER", timeout=60) + + # Check that the custom timeout was passed + assert mock_requests_get["timeout"] == 60 + + +def test_sds_client_default_service_interaction_id( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test that SdsClient uses default interaction ID when not provided. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + + client.get_org_details(ods_code="PROVIDER") + + # Check that the default interaction ID was used in params + params = mock_requests_get["params"] + assert any( + SdsClient.DEFAULT_SERVICE_INTERACTION_ID in str(ident) + for ident in params.get("identifier", []) + ) + + +def test_sds_client_custom_service_interaction_id( + stub: SdsFhirApiStub, + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test that SdsClient uses custom interaction ID when provided. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + custom_interaction = "urn:nhs:names:services:custom:CUSTOM123" + + # Add device with custom interaction ID + stub.upsert_device( + organization_ods="CUSTOMINT", + service_interaction_id=custom_interaction, + party_key=None, + device={ + "resourceType": "Device", + "id": "custom-device", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "777777777777", + } + ], + "owner": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "CUSTOMINT", + } + }, + }, + ) + + client = SdsClient( + api_key="test-key", + base_url=SdsClient.SANDBOX_URL, + service_interaction_id=custom_interaction, + ) + + result = client.get_org_details(ods_code="CUSTOMINT") + + # Verify the custom interaction was used + params = mock_requests_get["params"] + assert any( + custom_interaction in str(ident) for ident in params.get("identifier", []) + ) + + # Verify we got the result + assert result is not None + assert result.asid == "777777777777" + + +def test_sds_client_builds_correct_device_query_params( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test that SdsClient builds Device query parameters correctly. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + + client.get_org_details(ods_code="PROVIDER") + + params = mock_requests_get["params"] + + # Check organization parameter + assert ( + params["organization"] + == "https://fhir.nhs.uk/Id/ods-organization-code|PROVIDER" + ) + + # Check identifier list contains interaction ID + identifiers = params["identifier"] + assert isinstance(identifiers, list) + assert any( + "https://fhir.nhs.uk/Id/nhsServiceInteractionId|" in str(ident) + for ident in identifiers + ) + + +def test_sds_client_extract_asid_from_device( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test ASID extraction from Device resource. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + + result = client.get_org_details(ods_code="PROVIDER") + + assert result is not None + assert result.asid is not None + assert result.asid == "asid_PROV" + + +def test_sds_client_extract_party_key_from_device( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 +) -> None: + """ + Test party key extraction and subsequent endpoint lookup. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + # The default seeded PROVIDER device has a party key, which should trigger + # an endpoint lookup + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + + # Need to seed the data correctly - let's use CONSUMER which has party key + result = client.get_org_details(ods_code="CONSUMER") + + # Should have found ASID but may not have endpoint depending on seeding + assert result is not None + assert result.asid == "asid_CONS" diff --git a/gateway-api/stubs/stubs/stub_sds.py b/gateway-api/stubs/stubs/stub_sds.py new file mode 100644 index 00000000..d0c2a728 --- /dev/null +++ b/gateway-api/stubs/stubs/stub_sds.py @@ -0,0 +1,785 @@ +""" +In-memory SDS FHIR R4 API stub. + +The stub does **not** implement the full SDS API surface, nor full FHIR validation. +""" + +from __future__ import annotations + +import json +from http.client import responses as http_responses +from typing import Any + +from requests import Response +from requests.structures import CaseInsensitiveDict + + +def _create_response( + status_code: int, + headers: dict[str, str], + json_data: dict[str, Any], +) -> Response: + """ + Create a :class:`requests.Response` object for the stub. + + :param status_code: HTTP status code. + :param headers: Response headers dictionary. + :param json_data: JSON body data. + :return: A :class:`requests.Response` instance. + """ + response = Response() + response.status_code = status_code + response.headers = CaseInsensitiveDict(headers) + response._content = json.dumps(json_data).encode("utf-8") # noqa: SLF001 + response.encoding = "utf-8" + # Set a reason phrase for HTTP error handling + response.reason = http_responses.get(status_code, "Unknown") + return response + + +class SdsFhirApiStub: + """ + Minimal in-memory stub for the SDS FHIR API, implementing ``GET /Device`` + and ``GET /Endpoint`` + + Contract elements modelled from the SDS OpenAPI spec: + + * ``/Device`` requires query params: + - ``organization`` (required): ODS code with FHIR identifier prefix + - ``identifier`` (required, repeatable): Service interaction ID and/or party key + - ``manufacturing-organization`` (optional): Manufacturing org ODS code + * ``/Endpoint`` requires query param: + - ``identifier`` (required, repeatable): Service interaction ID and/or party key + - ``organization`` (optional): ODS code with FHIR identifier prefix + * ``X-Correlation-Id`` is optional and echoed back if supplied + * ``apikey`` header is required (but any value accepted in stub mode) + * Returns a FHIR Bundle with ``resourceType: "Bundle"`` and ``type: "searchset"`` + + See: + https://github.com/NHSDigital/spine-directory-service-api + """ + + ODS_SYSTEM = "https://fhir.nhs.uk/Id/ods-organization-code" + INTERACTION_SYSTEM = "https://fhir.nhs.uk/Id/nhsServiceInteractionId" + PARTYKEY_SYSTEM = "https://fhir.nhs.uk/Id/nhsMhsPartyKey" + ASID_SYSTEM = "https://fhir.nhs.uk/Id/nhsSpineASID" + CONNECTION_SYSTEM = "http://terminology.hl7.org/CodeSystem/endpoint-connection-type" + CODING_SYSTEM = "http://terminology.hl7.org/CodeSystem/endpoint-payload-type" + + GP_CONNECT_INTERACTION = ( + "urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1" + ) + CONNECTION_DISPLAY = "HL7 FHIR" + + def __init__(self) -> None: + """ + Create a new stub instance. + + :param strict_validation: If ``True``, enforce required query parameters and + apikey header. If ``False``, validation is relaxed. + """ + # Internal store: (org_ods, interaction_id, party_key) -> list[device_resource] + # party_key may be None if not specified + self._devices: dict[tuple[str, str, str | None], list[dict[str, Any]]] = {} + + # Internal store for endpoints: + # (org_ods, interaction_id, party_key) -> list[endpoint_resource] + # org_ods and/or interaction_id may be None since they're optional for + # endpoint queries + self._endpoints: dict[ + tuple[str | None, str | None, str | None], list[dict[str, Any]] + ] = {} + + # Seed some deterministic examples matching common test scenarios + self._seed_default_devices() + self._seed_default_endpoints() + + def _seed_default_devices(self) -> None: + """Seed the stub with some default Device records for testing.""" + self.upsert_device( + organization_ods="PROVIDER", + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key="PROVIDER-0000806", + device={ + "resourceType": "Device", + "id": "F0F0E921-92CA-4A88-A550-2DBB36F703AF", + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": "asid_PROV", + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": "PROVIDER-0000806", + }, + ], + "owner": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": "PROVIDER", + }, + "display": "Example NHS Trust", + }, + }, + ) + + self.upsert_device( + organization_ods="CONSUMER", + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key="CONSUMER-0000807", + device={ + "resourceType": "Device", + "id": "C0C0E921-92CA-4A88-A550-2DBB36F703AF", + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": "asid_CONS", + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": "CONSUMER-0000807", + }, + ], + "owner": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": "CONSUMER", + }, + "display": "Example Consumer Organisation", + }, + }, + ) + + def _seed_default_endpoints(self) -> None: + """Seed the stub with some default Endpoint records for testing.""" + # Example 1: Endpoint for provider organization with GP Connect interaction + self.upsert_endpoint( + organization_ods="PROVIDER", + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key="PROVIDER-0000806", + endpoint={ + "resourceType": "Endpoint", + "id": "E0E0E921-92CA-4A88-A550-2DBB36F703AF", + "status": "active", + "connectionType": { + "system": self.CONNECTION_SYSTEM, + "code": "hl7-fhir-rest", + "display": self.CONNECTION_DISPLAY, + }, + "payloadType": [ + { + "coding": [ + { + "system": self.CODING_SYSTEM, + "code": "any", + "display": "Any", + } + ] + } + ], + "address": "https://provider.example.com/fhir", + "managingOrganization": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": "PROVIDER", + } + }, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": "asid_PROV", + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": "PROVIDER-0000806", + }, + ], + }, + ) + + # Also seed endpoint with PSIS interaction for backwards compatibility + self.upsert_endpoint( + organization_ods="PROVIDER", + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key="PROVIDER-0000806", + endpoint={ + "resourceType": "Endpoint", + "id": "E0E0E921-92CA-4A88-A550-2DBB36F703AF", + "status": "active", + "connectionType": { + "system": self.CONNECTION_SYSTEM, + "code": "hl7-fhir-rest", + "display": self.CONNECTION_DISPLAY, + }, + "payloadType": [ + { + "coding": [ + { + "system": self.CODING_SYSTEM, + "code": "any", + "display": "Any", + } + ] + } + ], + "address": "https://provider.example.com/fhir", + "managingOrganization": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": "PROVIDER", + } + }, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": "asid_PROV", + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": "PROVIDER-0000806", + }, + ], + }, + ) + + # Example 2: Endpoint for consumer organization with GP Connect interaction + self.upsert_endpoint( + organization_ods="CONSUMER", + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key="CONSUMER-0000807", + endpoint={ + "resourceType": "Endpoint", + "id": "E1E1E921-92CA-4A88-A550-2DBB36F703AF", + "status": "active", + "connectionType": { + "system": self.CONNECTION_SYSTEM, + "code": "hl7-fhir-rest", + "display": self.CONNECTION_DISPLAY, + }, + "payloadType": [ + { + "coding": [ + { + "system": self.CODING_SYSTEM, + "code": "any", + "display": "Any", + } + ] + } + ], + "address": "https://consumer.example.com/fhir", + "managingOrganization": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": "CONSUMER", + } + }, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": "asid_CONS", + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": "CONSUMER-0000807", + }, + ], + }, + ) + + # Also seed endpoint with PSIS interaction for backwards compatibility + self.upsert_endpoint( + organization_ods="CONSUMER", + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key="CONSUMER-0000807", + endpoint={ + "resourceType": "Endpoint", + "id": "E1E1E921-92CA-4A88-A550-2DBB36F703AF", + "status": "active", + "connectionType": { + "system": self.CONNECTION_SYSTEM, + "code": "hl7-fhir-rest", + "display": self.CONNECTION_DISPLAY, + }, + "payloadType": [ + { + "coding": [ + { + "system": self.CODING_SYSTEM, + "code": "any", + "display": "Any", + } + ] + } + ], + "address": "https://consumer.example.com/fhir", + "managingOrganization": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": "CONSUMER", + } + }, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": "asid_CONS", + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": "CONSUMER-0000807", + }, + ], + }, + ) + + # --------------------------- + # Public API for tests + # --------------------------- + + def upsert_device( + self, + organization_ods: str, + service_interaction_id: str, + party_key: str | None, + device: dict[str, Any], + ) -> None: + """ + Insert or append a Device record in the stub store. + + Multiple devices can be registered for the same query combination (they will + all be returned in the Bundle.entry array). + + :param organization_ods: Organization ODS code. + :param service_interaction_id: Service interaction ID. + :param party_key: Optional MHS party key. + :param device: Device resource dictionary. + """ + key = (organization_ods, service_interaction_id, party_key) + if key not in self._devices: + self._devices[key] = [] + self._devices[key].append(device) + + def clear_devices(self) -> None: + """Clear all Device records from the stub.""" + self._devices.clear() + + def upsert_endpoint( + self, + organization_ods: str | None, + service_interaction_id: str | None, + party_key: str | None, + endpoint: dict[str, Any], + ) -> None: + """ + Insert or append an Endpoint record in the stub store. + + Multiple endpoints can be registered for the same query combination (they will + all be returned in the Bundle.entry array). + + :param organization_ods: Organization ODS code (optional for endpoints). + :param service_interaction_id: Service interaction ID (optional for endpoints). + :param party_key: Optional MHS party key. + :param endpoint: Endpoint resource dictionary. + """ + key = (organization_ods, service_interaction_id, party_key) + if key not in self._endpoints: + self._endpoints[key] = [] + self._endpoints[key].append(endpoint) + + def clear_endpoints(self) -> None: + """Clear all Endpoint records from the stub.""" + self._endpoints.clear() + + def get_device_bundle( + self, + url: str, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + headers: dict[str, str], + params: dict[str, Any], + timeout: int | None = None, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + ) -> Response: + """ + Implements ``GET /Device``. + + :param url: Request URL (expected to end with /Device). + :param headers: Request headers. Must include ``apikey``. + May include ``X-Correlation-Id``. + :param params: Query parameters dictionary. Must include ``organization`` and + ``identifier`` (list). + :param timeout: Timeout (ignored by the stub). + :return: A :class:`requests.Response` representing either: + * ``200`` with Bundle JSON (may be empty) + * ``400`` with error details for missing/invalid parameters + """ + headers = headers or {} + params = params or {} + + headers_out: dict[str, str] = {} + + # Echo correlation ID if provided + correlation_id = headers.get("X-Correlation-Id") + if correlation_id: + headers_out["X-Correlation-Id"] = correlation_id + + # Validate apikey header + if "apikey" not in headers: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required header: apikey", + ) + + # Always validate required query parameters (not just in strict mode) + organization = params.get("organization") + identifier = params.get("identifier") + + if not organization: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required query parameter: organization", + ) + if not identifier: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required query parameter: identifier", + ) + + # Parse organization ODS code + org_ods = self._extract_param_value(organization, self.ODS_SYSTEM) + + # Parse identifier list (can be string or list) + # if isinstance(identifier, str): + identifier_list = [identifier] if isinstance(identifier, str) else identifier + # else: + # identifier_list = identifier + + service_interaction_id: str | None = None + party_key: str | None = None + + for ident in identifier_list: + if self.INTERACTION_SYSTEM in ident: + service_interaction_id = self._extract_param_value( + ident, self.INTERACTION_SYSTEM + ) + elif self.PARTYKEY_SYSTEM in ident: + party_key = self._extract_param_value(ident, self.PARTYKEY_SYSTEM) + + # Always validate service interaction ID is present + if not service_interaction_id: + return self._error_response( + status_code=400, + headers=headers_out, + message="identifier must include nhsServiceInteractionId", + ) + + # Look up devices + devices = self._lookup_devices( + org_ods=org_ods or "", + service_interaction_id=service_interaction_id or "", + party_key=party_key, + ) + + # Build FHIR Bundle response + bundle = self._build_bundle(devices) + + return _create_response(status_code=200, headers=headers_out, json_data=bundle) + + def get_endpoint_bundle( + self, + url: str, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + timeout: int | None = None, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + ) -> Response: + """ + Implements ``GET /Endpoint``. + + :param url: Request URL (expected to end with /Endpoint). + :param headers: Request headers. Must include ``apikey`. + May include ``X-Correlation-Id``. + :param params: Query parameters dictionary. Must include ``identifier`` (list). + ``organization`` is optional. + :param timeout: Timeout (ignored by the stub). + :return: A :class:`requests.Response` representing either: + * ``200`` with Bundle JSON (may be empty) + * ``400`` with error details for missing/invalid parameters + """ + headers = headers or {} + params = params or {} + + headers_out: dict[str, str] = {} + + # Echo correlation ID if provided + correlation_id = headers.get("X-Correlation-Id") + if correlation_id: + headers_out["X-Correlation-Id"] = correlation_id + + # Validate apikey header + if "apikey" not in headers: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required header: apikey", + ) + + # Always validate required query parameters (not just in strict mode) + identifier = params.get("identifier") + organization = params.get("organization") + + if not identifier: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required query parameter: identifier", + ) + + # Parse organization ODS code (optional) + org_ods: str | None = None + if organization: + org_ods = self._extract_param_value(organization, self.ODS_SYSTEM) + + # Parse identifier list (can be string or list) + if isinstance(identifier, str): + identifier = [identifier] + + service_interaction_id: str | None = None + party_key: str | None = None + + for ident in identifier or []: + if self.INTERACTION_SYSTEM in ident: + service_interaction_id = self._extract_param_value( + ident, self.INTERACTION_SYSTEM + ) + elif self.PARTYKEY_SYSTEM in ident: + party_key = self._extract_param_value(ident, self.PARTYKEY_SYSTEM) + + # Look up endpoints + endpoints = self._lookup_endpoints( + org_ods=org_ods, + service_interaction_id=service_interaction_id, + party_key=party_key, + ) + + # Build FHIR Bundle response + bundle = self._build_endpoint_bundle(endpoints) + + return _create_response(status_code=200, headers=headers_out, json_data=bundle) + + def get( + self, + url: str, + headers: dict[str, str], + params: dict[str, Any], + timeout: int = 10, + ) -> Response: + """ + Convenience method matching requests.get signature for easy monkeypatching. + + Routes to the appropriate handler based on the URL path. + + :param url: Request URL. + :param headers: Request headers. + :param params: Query parameters. + :param timeout: Timeout value. + :return: A :class:`requests.Response`. + """ + if "/Endpoint" in url: + return self.get_endpoint_bundle( + url=url, headers=headers, params=params, timeout=timeout + ) + return self.get_device_bundle( + url=url, headers=headers, params=params, timeout=timeout + ) + + # --------------------------- + # Internal helpers + # --------------------------- + + def _lookup_devices( + self, org_ods: str, service_interaction_id: str, party_key: str | None + ) -> list[dict[str, Any]]: + """ + Look up devices matching the query parameters. + + :param org_ods: Organization ODS code. + :param service_interaction_id: Service interaction ID. + :param party_key: Optional party key. + :return: List of matching Device resources. + """ + # Exact match with party key (or None) + key = (org_ods, service_interaction_id, party_key) + if key in self._devices: + return list(self._devices[key]) + + # If no party_key was provided (None), search for any entries with the + # same org+interaction + # This allows querying without knowing the party_key upfront + if party_key is None: + for stored_key, devices in self._devices.items(): + stored_org, stored_interaction, _ = stored_key + if ( + stored_org == org_ods + and stored_interaction == service_interaction_id + ): + return list(devices) + + # If party_key was provided but no exact match, try without party key + if party_key: + key_without_party = (org_ods, service_interaction_id, None) + if key_without_party in self._devices: + return list(self._devices[key_without_party]) + + return [] + + def _lookup_endpoints( + self, + org_ods: str | None, + service_interaction_id: str | None, + party_key: str | None, + ) -> list[dict[str, Any]]: + """ + Look up endpoints matching the query parameters. + + For /Endpoint, the query combinations are more flexible: + - organization + service_interaction_id + party_key + - organization + party_key + - organization + service_interaction_id + - service_interaction_id + party_key + + :param org_ods: Organization ODS code (optional). + :param service_interaction_id: Service interaction ID (optional). + :param party_key: Optional party key. + :return: List of matching Endpoint resources. + """ + results = [] + + # Try to find exact matches and partial matches + for key, endpoints in self._endpoints.items(): + stored_org, stored_interaction, stored_party = key + + # Check if the query parameters match + org_match = org_ods is None or stored_org is None or org_ods == stored_org + interaction_match = ( + service_interaction_id is None + or stored_interaction is None + or service_interaction_id == stored_interaction + ) + party_match = ( + party_key is None or stored_party is None or party_key == stored_party + ) + + # If all specified parameters match, include these endpoints + if org_match and interaction_match and party_match: + # But at least one must be non-None and match + has_match = ( + (org_ods and stored_org and org_ods == stored_org) + or ( + service_interaction_id + and stored_interaction + and service_interaction_id == stored_interaction + ) + or (party_key and stored_party and party_key == stored_party) + ) + if has_match: + results.extend(endpoints) + + return results + + def _build_bundle(self, devices: list[dict[str, Any]]) -> dict[str, Any]: + """ + Build a FHIR Bundle from a list of Device resources. + + :param devices: List of Device resources. + :return: FHIR Bundle dictionary. + """ + entries = [] + for device in devices: + device_id = device.get("id", "unknown") + entries.append( + { + "fullUrl": f"https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4/Device/{device_id}", + "resource": device, + "search": {"mode": "match"}, + } + ) + + return { + "resourceType": "Bundle", + "type": "searchset", + "total": len(devices), + "entry": entries, + } + + def _build_endpoint_bundle(self, endpoints: list[dict[str, Any]]) -> dict[str, Any]: + """ + Build a FHIR Bundle from a list of Endpoint resources. + + :param endpoints: List of Endpoint resources. + :return: FHIR Bundle dictionary. + """ + entries = [] + for endpoint in endpoints: + endpoint_id = endpoint.get("id", "unknown") + entries.append( + { + "fullUrl": f"https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4/Endpoint/{endpoint_id}", + "resource": endpoint, + "search": {"mode": "match"}, + } + ) + + return { + "resourceType": "Bundle", + "type": "searchset", + "total": len(endpoints), + "entry": entries, + } + + @staticmethod + def _extract_param_value(param: str, system: str) -> str | None: + """ + Extract the value from a FHIR-style parameter like 'system|value'. + + :param param: Parameter string in format 'system|value'. + :param system: Expected system URL. + :return: The value part, or None if not found. + """ + if not param or "|" not in param: + return None + + parts = param.split("|", 1) + if len(parts) != 2: + return None + + param_system, param_value = parts + if param_system == system: + return param_value.strip() + + return None + + @staticmethod + def _error_response( + status_code: int, headers: dict[str, str], message: str + ) -> Response: + """ + Build an error response. + + :param status_code: HTTP status code. + :param headers: Response headers. + :param message: Error message. + :return: A :class:`requests.Response` with error details. + """ + body = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": message, + } + ], + } + return _create_response( + status_code=status_code, headers=dict(headers), json_data=body + ) From eccff4eace2d34827de969c6371f49a75f5d0e7e Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:01:53 +0000 Subject: [PATCH 03/22] Fix method signatures --- .../src/gateway_api/test_controller.py | 9 +- .../src/gateway_api/test_sds_search.py | 172 +++++++++++++++--- 2 files changed, 156 insertions(+), 25 deletions(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 3fc3ded4..e7dd02f5 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -77,18 +77,21 @@ class FakeSdsClient: def __init__( self, - auth_token: str | None = None, + api_key: str, base_url: str = "test_url", timeout: int = 10, + service_interaction_id: str | None = None, ) -> None: FakeSdsClient.last_init = { - "auth_token": auth_token, + "api_key": api_key, "base_url": base_url, "timeout": timeout, + "service_interaction_id": service_interaction_id, } - self.auth_token = auth_token + self.api_key = api_key self.base_url = base_url self.timeout = timeout + self.service_interaction_id = service_interaction_id self._org_details_by_ods: dict[str, SdsSearchResults | None] = {} def set_org_details( diff --git a/gateway-api/src/gateway_api/test_sds_search.py b/gateway-api/src/gateway_api/test_sds_search.py index b723287f..912b1e09 100644 --- a/gateway-api/src/gateway_api/test_sds_search.py +++ b/gateway-api/src/gateway_api/test_sds_search.py @@ -280,28 +280,6 @@ def test_sds_client_timeout_parameter( assert mock_requests_get["timeout"] == 60 -def test_sds_client_default_service_interaction_id( - stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 -) -> None: - """ - Test that SdsClient uses default interaction ID when not provided. - - :param stub: SDS stub fixture. - :param mock_requests_get: Capture fixture for request details. - """ - client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) - - client.get_org_details(ods_code="PROVIDER") - - # Check that the default interaction ID was used in params - params = mock_requests_get["params"] - assert any( - SdsClient.DEFAULT_SERVICE_INTERACTION_ID in str(ident) - for ident in params.get("identifier", []) - ) - - def test_sds_client_custom_service_interaction_id( stub: SdsFhirApiStub, mock_requests_get: dict[str, Any], # noqa: ARG001 @@ -426,3 +404,153 @@ def test_sds_client_extract_party_key_from_device( # Should have found ASID but may not have endpoint depending on seeding assert result is not None assert result.asid == "asid_CONS" + + +def test_sds_client_handles_http_error_from_device_endpoint( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Test that ExternalServiceError is raised when Device API returns HTTP error. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + :param monkeypatch: Pytest monkeypatch fixture. + """ + from unittest.mock import Mock + + import requests + + from gateway_api.sds_search import ExternalServiceError + + # Create a mock response with error status + mock_response = Mock() + mock_response.status_code = 500 + mock_response.reason = "Internal Server Error" + mock_response.raise_for_status.side_effect = requests.HTTPError( + response=mock_response + ) + + # Create a mock that returns our error response + def mock_get(*args: Any, **kwargs: Any) -> Mock: # noqa: ARG001 + return mock_response + + # Patch the get_method to return error + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + monkeypatch.setattr(client, "get_method", mock_get) + + # Should raise ExternalServiceError + with pytest.raises(ExternalServiceError) as exc_info: + client.get_org_details(ods_code="PROVIDER") + + assert "Device request failed" in str(exc_info.value) + assert "500" in str(exc_info.value) + + +def test_sds_client_handles_http_error_from_endpoint_endpoint( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Test that ExternalServiceError is raised when Endpoint API returns HTTP error. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + :param monkeypatch: Pytest monkeypatch fixture. + """ + from unittest.mock import Mock + + import requests + + from gateway_api.sds_search import ExternalServiceError + + call_count = {"count": 0} + + # Create mock responses + def mock_get(url: str, *args: Any, **kwargs: Any) -> Mock: # noqa: ARG001 + call_count["count"] += 1 + if call_count["count"] == 1: + # First call (Device) - return success + device_response = Mock() + device_response.status_code = 200 + device_response.raise_for_status = Mock() + device_response.json.return_value = { + "resourceType": "Bundle", + "type": "searchset", + "total": 1, + "entry": [ + { + "resource": { + "resourceType": "Device", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "123456789012", + }, + { + "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", + "value": "TEST-PARTY-KEY", + }, + ], + } + } + ], + } + return device_response + else: + # Second call (Endpoint) - return error + endpoint_response = Mock() + endpoint_response.status_code = 503 + endpoint_response.reason = "Service Unavailable" + endpoint_response.raise_for_status.side_effect = requests.HTTPError( + response=endpoint_response + ) + return endpoint_response + + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + monkeypatch.setattr(client, "get_method", mock_get) + + # Should raise ExternalServiceError on Endpoint query + with pytest.raises(ExternalServiceError) as exc_info: + client.get_org_details(ods_code="TESTORG") + + assert "Endpoint request failed" in str(exc_info.value) + assert "503" in str(exc_info.value) + + +def test_sds_client_handles_empty_bundle_gracefully( + stub: SdsFhirApiStub, # noqa: ARG001 + mock_requests_get: dict[str, Any], # noqa: ARG001 + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Test that client handles empty Bundle (total: 0) gracefully. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + :param monkeypatch: Pytest monkeypatch fixture. + """ + from unittest.mock import Mock + + # Create mock that returns empty bundle + def mock_get(*args: Any, **kwargs: Any) -> Mock: # noqa: ARG001 + response = Mock() + response.status_code = 200 + response.raise_for_status = Mock() + response.json.return_value = { + "resourceType": "Bundle", + "type": "searchset", + "total": 0, + "entry": [], + } + return response + + client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + monkeypatch.setattr(client, "get_method", mock_get) + + # Should return None for empty result + result = client.get_org_details(ods_code="NONEXISTENT") + + assert result is None From d9996ea52f89245074006871a7122dd4976b6861 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:27:28 +0000 Subject: [PATCH 04/22] Add SDS integration tests --- .../tests/integration/test_sds_search.py | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 gateway-api/tests/integration/test_sds_search.py diff --git a/gateway-api/tests/integration/test_sds_search.py b/gateway-api/tests/integration/test_sds_search.py new file mode 100644 index 00000000..26bb7b89 --- /dev/null +++ b/gateway-api/tests/integration/test_sds_search.py @@ -0,0 +1,167 @@ +"""Integration tests for SDS (Spine Directory Service) search functionality.""" + +from __future__ import annotations + +import pytest +from gateway_api.sds_search import SdsClient, SdsSearchResults +from stubs.stub_sds import SdsFhirApiStub + + +@pytest.fixture +def sds_stub() -> SdsFhirApiStub: + """ + Create and return an SDS stub instance with default seeded data. + + :return: SdsFhirApiStub instance with PROVIDER and CONSUMER organizations. + """ + return SdsFhirApiStub() + + +@pytest.fixture +def sds_client(sds_stub: SdsFhirApiStub) -> SdsClient: + """ + Create an SdsClient configured to use the stub. + + :param sds_stub: SDS stub fixture. + :return: SdsClient configured with test stub. + """ + client = SdsClient(api_key="test-integration-key", base_url="http://stub") + # Override the get_method to use the stub + client.get_method = sds_stub.get + return client + + +class TestSdsIntegration: + """Integration tests for SDS search operations.""" + + def test_get_device_by_ods_code_returns_valid_asid( + self, sds_client: SdsClient + ) -> None: + """ + Test that querying by ODS code returns a valid ASID. + + :param sds_client: SDS client fixture configured with stub. + """ + result = sds_client.get_org_details(ods_code="PROVIDER") + + assert result is not None + assert isinstance(result, SdsSearchResults) + assert result.asid is not None + assert result.asid == "asid_PROV" + assert len(result.asid) > 0 + + def test_get_device_with_party_key_returns_endpoint( + self, sds_client: SdsClient + ) -> None: + """ + Test that a device with party key returns both ASID and endpoint. + + :param sds_client: SDS client fixture configured with stub. + """ + result = sds_client.get_org_details(ods_code="PROVIDER") + + assert result is not None + assert result.asid == "asid_PROV" + assert result.endpoint is not None + assert result.endpoint == "https://provider.example.com/fhir" + # Verify endpoint is a valid URL format + assert result.endpoint.startswith("https://") + assert "fhir" in result.endpoint + + def test_get_device_for_nonexistent_ods_returns_none( + self, sds_client: SdsClient + ) -> None: + """ + Test that querying for a non-existent ODS code returns None. + + :param sds_client: SDS client fixture configured with stub. + """ + result = sds_client.get_org_details(ods_code="NONEXISTENT") + + assert result is None + + def test_missing_required_parameters_returns_400( + self, sds_stub: SdsFhirApiStub + ) -> None: + """ + Test that missing required parameters returns a 400 error. + + :param sds_stub: SDS stub fixture. + """ + # Test missing organization parameter + response = sds_stub.get_device_bundle( + url="http://test/Device", + headers={"apikey": "test-key"}, + params={ + "identifier": [ + "https://fhir.nhs.uk/Id/nhsServiceInteractionId|urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1" + ] + }, + ) + + assert response.status_code == 400 + body = response.json() + assert body["resourceType"] == "OperationOutcome" + assert body["issue"][0]["severity"] == "error" + assert "organization" in body["issue"][0]["diagnostics"].lower() + + # Test missing identifier parameter + response = sds_stub.get_device_bundle( + url="http://test/Device", + headers={"apikey": "test-key"}, + params={ + "organization": "https://fhir.nhs.uk/Id/ods-organization-code|PROVIDER" + }, + ) + + assert response.status_code == 400 + body = response.json() + assert body["resourceType"] == "OperationOutcome" + assert body["issue"][0]["severity"] == "error" + assert "identifier" in body["issue"][0]["diagnostics"].lower() + + # Test missing service interaction ID in identifier + response = sds_stub.get_device_bundle( + url="http://test/Device", + headers={"apikey": "test-key"}, + params={ + "organization": "https://fhir.nhs.uk/Id/ods-organization-code|PROVIDER", + "identifier": ["https://fhir.nhs.uk/Id/nhsMhsPartyKey|TEST-KEY"], + }, + ) + + assert response.status_code == 400 + body = response.json() + assert body["resourceType"] == "OperationOutcome" + assert body["issue"][0]["severity"] == "error" + assert "nhsServiceInteractionId" in body["issue"][0]["diagnostics"] + + def test_consumer_organization_lookup(self, sds_client: SdsClient) -> None: + """ + Test that CONSUMER organization can be looked up successfully. + + :param sds_client: SDS client fixture configured with stub. + """ + result = sds_client.get_org_details(ods_code="CONSUMER") + + assert result is not None + assert result.asid == "asid_CONS" + assert result.endpoint is not None + assert result.endpoint == "https://consumer.example.com/fhir" + + def test_result_contains_both_asid_and_endpoint_when_available( + self, sds_client: SdsClient + ) -> None: + """ + Test that results contain both ASID and endpoint when both are available. + + :param sds_client: SDS client fixture configured with stub. + """ + result = sds_client.get_org_details(ods_code="PROVIDER") + + assert result is not None + # Verify both fields are present and not None + assert hasattr(result, "asid") + assert hasattr(result, "endpoint") + assert result.asid is not None + assert result.endpoint is not None From eae26cb246fad46dd925e91c0ed73b0c8be767ad Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:41:23 +0000 Subject: [PATCH 05/22] Update SdsSearchResults export location --- gateway-api/src/gateway_api/test_controller.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index e7dd02f5..e16e44a5 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -14,11 +14,9 @@ import gateway_api.controller as controller_module from gateway_api.app import app -from gateway_api.controller import ( - Controller, - SdsSearchResults, -) +from gateway_api.controller import Controller from gateway_api.get_structured_record.request import GetStructuredRecordRequest +from gateway_api.sds_search import SdsSearchResults if TYPE_CHECKING: from collections.abc import Generator From 3de8f2867ff06ea5200be7ef5da49678646844a1 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:37:16 +0000 Subject: [PATCH 06/22] Add extra test data to stub. --- gateway-api/stubs/stubs/stub_sds.py | 72 +++++++++++++++++++++++++++++ gateway-api/tests/conftest.py | 2 +- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/gateway-api/stubs/stubs/stub_sds.py b/gateway-api/stubs/stubs/stub_sds.py index d0c2a728..b5b2c456 100644 --- a/gateway-api/stubs/stubs/stub_sds.py +++ b/gateway-api/stubs/stubs/stub_sds.py @@ -150,6 +150,33 @@ def _seed_default_devices(self) -> None: }, ) + self.upsert_device( + organization_ods="A12345", + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key="A12345-0000808", + device={ + "resourceType": "Device", + "id": "A1A1E921-92CA-4A88-A550-2DBB36F703AF", + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": "asid_A12345", + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": "A12345-0000808", + }, + ], + "owner": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": "A12345", + }, + "display": "Example GP Practice A12345", + }, + }, + ) + def _seed_default_endpoints(self) -> None: """Seed the stub with some default Endpoint records for testing.""" # Example 1: Endpoint for provider organization with GP Connect interaction @@ -332,6 +359,51 @@ def _seed_default_endpoints(self) -> None: }, ) + # Example 3: Endpoint for A12345 organization with GP Connect interaction + self.upsert_endpoint( + organization_ods="A12345", + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key="A12345-0000808", + endpoint={ + "resourceType": "Endpoint", + "id": "E2E2E921-92CA-4A88-A550-2DBB36F703AF", + "status": "active", + "connectionType": { + "system": self.CONNECTION_SYSTEM, + "code": "hl7-fhir-rest", + "display": self.CONNECTION_DISPLAY, + }, + "payloadType": [ + { + "coding": [ + { + "system": self.CODING_SYSTEM, + "code": "any", + "display": "Any", + } + ] + } + ], + "address": "https://a12345.example.com/fhir", + "managingOrganization": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": "A12345", + } + }, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": "asid_A12345", + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": "A12345-0000808", + }, + ], + }, + ) + # --------------------------- # Public API for tests # --------------------------- diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index 7fef2c54..4f4bf8c7 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -30,7 +30,7 @@ def send_to_get_structured_record_endpoint( url = f"{self.base_url}/patient/$gpc.getstructuredrecord" default_headers = { "Content-Type": "application/fhir+json", - "Ods-from": "test-ods-code", + "Ods-from": "CONSUMER", "Ssp-TraceID": "test-trace-id", } if headers: From f67258af7df16772677200673f0ed8d34ed94b4a Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:09:45 +0000 Subject: [PATCH 07/22] Create stub base class --- gateway-api/stubs/stubs/base_stub.py | 61 ++++++++++++++++++++++++++++ gateway-api/stubs/stubs/stub_pds.py | 47 +++++++-------------- gateway-api/stubs/stubs/stub_sds.py | 46 ++++++--------------- 3 files changed, 89 insertions(+), 65 deletions(-) create mode 100644 gateway-api/stubs/stubs/base_stub.py diff --git a/gateway-api/stubs/stubs/base_stub.py b/gateway-api/stubs/stubs/base_stub.py new file mode 100644 index 00000000..3ff8677b --- /dev/null +++ b/gateway-api/stubs/stubs/base_stub.py @@ -0,0 +1,61 @@ +""" +Base class for FHIR API stubs. + +Provides common functionality for creating stub responses. +""" + +from __future__ import annotations + +import json +from abc import ABC, abstractmethod +from http.client import responses as http_responses +from typing import Any + +from requests import Response +from requests.structures import CaseInsensitiveDict + + +class FhirApiStubBase(ABC): + """ + Abstract base class for FHIR API stubs. + + Provides common functionality for creating HTTP responses and defines + the interface that all stub implementations must provide. + """ + + @staticmethod + def _create_response( + status_code: int, + headers: dict[str, str], + json_data: dict[str, Any], + ) -> Response: + """ + Create a :class:`requests.Response` object for the stub. + + :param status_code: HTTP status code. + :param headers: Response headers dictionary. + :param json_data: JSON body data. + :return: A :class:`requests.Response` instance. + """ + response = Response() + response.status_code = status_code + response.headers = CaseInsensitiveDict(headers) + response._content = json.dumps(json_data).encode("utf-8") # noqa: SLF001 + response.encoding = "utf-8" + # Set a reason phrase for HTTP error handling + response.reason = http_responses.get(status_code, "Unknown") + return response + + @abstractmethod + def get( + self, url: str, headers: dict[str, str], params: dict[str, Any], timeout: int + ) -> Response: + """ + Handle HTTP GET requests for the stub. + + :param url: Request URL. + :param headers: Request headers. + :param params: Query parameters. + :param timeout: Request timeout in seconds. + :return: A :class:`requests.Response` instance. + """ diff --git a/gateway-api/stubs/stubs/stub_pds.py b/gateway-api/stubs/stubs/stub_pds.py index f8249295..eeb6f873 100644 --- a/gateway-api/stubs/stubs/stub_pds.py +++ b/gateway-api/stubs/stubs/stub_pds.py @@ -6,41 +6,18 @@ from __future__ import annotations -import json import re import uuid from datetime import datetime, timezone -from http.client import responses as http_responses -from typing import Any +from typing import TYPE_CHECKING, Any -from requests import Response -from requests.structures import CaseInsensitiveDict +from stubs.base_stub import FhirApiStubBase - -def _create_response( - status_code: int, - headers: dict[str, str], - json_data: dict[str, Any], -) -> Response: - """ - Create a :class:`requests.Response` object for the stub. - - :param status_code: HTTP status code. - :param headers: Response headers dictionary. - :param json_data: JSON body data. - :return: A :class:`requests.Response` instance. - """ - response = Response() - response.status_code = status_code - response.headers = CaseInsensitiveDict(headers) - response._content = json.dumps(json_data).encode("utf-8") # noqa: SLF001 - response.encoding = "utf-8" - # Set a reason phrase for HTTP error handling - response.reason = http_responses.get(status_code, "Unknown") - return response +if TYPE_CHECKING: + from requests import Response -class PdsFhirApiStub: +class PdsFhirApiStub(FhirApiStubBase): """ Minimal in-memory stub for the PDS FHIR API, implementing only ``GET /Patient/{id}`` @@ -253,7 +230,9 @@ def get_patient( # ETag mirrors the "W/\"\"" shape and aligns to meta.versionId. headers_out["ETag"] = f'W/"{version_id}"' - return _create_response(status_code=200, headers=headers_out, json_data=patient) + return self._create_response( + status_code=200, headers=headers_out, json_data=patient + ) def get( self, @@ -356,9 +335,13 @@ def _bad_request( display=message, ) - @staticmethod def _operation_outcome( - *, status_code: int, headers: dict[str, str], spine_code: str, display: str + self, + *, + status_code: int, + headers: dict[str, str], + spine_code: str, + display: str, ) -> Response: """ Construct an OperationOutcome response body. @@ -388,6 +371,6 @@ def _operation_outcome( } ], } - return _create_response( + return self._create_response( status_code=status_code, headers=dict(headers), json_data=body ) diff --git a/gateway-api/stubs/stubs/stub_sds.py b/gateway-api/stubs/stubs/stub_sds.py index b5b2c456..a67b437c 100644 --- a/gateway-api/stubs/stubs/stub_sds.py +++ b/gateway-api/stubs/stubs/stub_sds.py @@ -6,38 +6,15 @@ from __future__ import annotations -import json -from http.client import responses as http_responses -from typing import Any +from typing import TYPE_CHECKING, Any -from requests import Response -from requests.structures import CaseInsensitiveDict +from stubs.base_stub import FhirApiStubBase - -def _create_response( - status_code: int, - headers: dict[str, str], - json_data: dict[str, Any], -) -> Response: - """ - Create a :class:`requests.Response` object for the stub. - - :param status_code: HTTP status code. - :param headers: Response headers dictionary. - :param json_data: JSON body data. - :return: A :class:`requests.Response` instance. - """ - response = Response() - response.status_code = status_code - response.headers = CaseInsensitiveDict(headers) - response._content = json.dumps(json_data).encode("utf-8") # noqa: SLF001 - response.encoding = "utf-8" - # Set a reason phrase for HTTP error handling - response.reason = http_responses.get(status_code, "Unknown") - return response +if TYPE_CHECKING: + from requests import Response -class SdsFhirApiStub: +class SdsFhirApiStub(FhirApiStubBase): """ Minimal in-memory stub for the SDS FHIR API, implementing ``GET /Device`` and ``GET /Endpoint`` @@ -555,7 +532,9 @@ def get_device_bundle( # Build FHIR Bundle response bundle = self._build_bundle(devices) - return _create_response(status_code=200, headers=headers_out, json_data=bundle) + return self._create_response( + status_code=200, headers=headers_out, json_data=bundle + ) def get_endpoint_bundle( self, @@ -636,7 +615,9 @@ def get_endpoint_bundle( # Build FHIR Bundle response bundle = self._build_endpoint_bundle(endpoints) - return _create_response(status_code=200, headers=headers_out, json_data=bundle) + return self._create_response( + status_code=200, headers=headers_out, json_data=bundle + ) def get( self, @@ -830,9 +811,8 @@ def _extract_param_value(param: str, system: str) -> str | None: return None - @staticmethod def _error_response( - status_code: int, headers: dict[str, str], message: str + self, status_code: int, headers: dict[str, str], message: str ) -> Response: """ Build an error response. @@ -852,6 +832,6 @@ def _error_response( } ], } - return _create_response( + return self._create_response( status_code=status_code, headers=dict(headers), json_data=body ) From afe0f8ef8cfbcb9db979840c75e2ae3f61e8795d Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:48:35 +0000 Subject: [PATCH 08/22] Move Provider stub to base class --- .../src/gateway_api/provider_request.py | 5 +- gateway-api/stubs/stubs/__init__.py | 6 + gateway-api/stubs/stubs/base_stub.py | 20 +++- gateway-api/stubs/stubs/stub_pds.py | 22 +++- gateway-api/stubs/stubs/stub_provider.py | 106 +++++++++--------- gateway-api/stubs/stubs/stub_sds.py | 22 +++- 6 files changed, 121 insertions(+), 60 deletions(-) diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index a628dbcf..3afe0e7f 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -26,7 +26,7 @@ from urllib.parse import urljoin from requests import HTTPError, Response, post -from stubs.stub_provider import stub_post +from stubs.stub_provider import GpProviderStub ARS_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:structured" @@ -43,7 +43,8 @@ # Direct all requests to the stub provider for steel threading in dev. # Replace with `from requests import post` for real requests. PostCallable = Callable[..., Response] - post: PostCallable = stub_post # type: ignore[no-redef] + _gp_provider_stub = GpProviderStub() + post: PostCallable = _gp_provider_stub.post # type: ignore[no-redef] class ExternalServiceError(Exception): diff --git a/gateway-api/stubs/stubs/__init__.py b/gateway-api/stubs/stubs/__init__.py index e69de29b..2b22a081 100644 --- a/gateway-api/stubs/stubs/__init__.py +++ b/gateway-api/stubs/stubs/__init__.py @@ -0,0 +1,6 @@ +from .base_stub import StubBase +from .stub_pds import PdsFhirApiStub +from .stub_provider import GpProviderStub +from .stub_sds import SdsFhirApiStub + +__all__ = ["StubBase", "PdsFhirApiStub", "SdsFhirApiStub", "GpProviderStub"] diff --git a/gateway-api/stubs/stubs/base_stub.py b/gateway-api/stubs/stubs/base_stub.py index 3ff8677b..246ec5fe 100644 --- a/gateway-api/stubs/stubs/base_stub.py +++ b/gateway-api/stubs/stubs/base_stub.py @@ -15,7 +15,7 @@ from requests.structures import CaseInsensitiveDict -class FhirApiStubBase(ABC): +class StubBase(ABC): """ Abstract base class for FHIR API stubs. @@ -59,3 +59,21 @@ def get( :param timeout: Request timeout in seconds. :return: A :class:`requests.Response` instance. """ + + @abstractmethod + def post( + self, + url: str, + headers: dict[str, Any], + data: str, + timeout: int, + ) -> Response: + """ + Handle HTTP POST requests for the stub. + + :param url: Request URL. + :param headers: Request headers. + :param data: Request body data. + :param timeout: Request timeout in seconds. + :return: A :class:`requests.Response` instance. + """ diff --git a/gateway-api/stubs/stubs/stub_pds.py b/gateway-api/stubs/stubs/stub_pds.py index eeb6f873..a1b2b219 100644 --- a/gateway-api/stubs/stubs/stub_pds.py +++ b/gateway-api/stubs/stubs/stub_pds.py @@ -11,13 +11,13 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Any -from stubs.base_stub import FhirApiStubBase +from .base_stub import StubBase if TYPE_CHECKING: from requests import Response -class PdsFhirApiStub(FhirApiStubBase): +class PdsFhirApiStub(StubBase): """ Minimal in-memory stub for the PDS FHIR API, implementing only ``GET /Patient/{id}`` @@ -266,6 +266,24 @@ def get( end_user_org_ods=end_user_org_ods, ) + def post( + self, + url: str, + headers: dict[str, Any], + data: Any, + timeout: int, + ) -> Response: + """ + Handle HTTP POST requests for the stub. + + :param url: Request URL. + :param headers: Request headers. + :param data: Request body data. + :param timeout: Request timeout in seconds. + :raises NotImplementedError: POST requests are not supported by this stub. + """ + raise NotImplementedError("POST requests are not supported by PdsFhirApiStub") + # --------------------------- # Internal helpers # --------------------------- diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index 2d0c96ba..4e9845a1 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -21,39 +21,14 @@ Request Body JSON (FHIR STU3 Parameters resource with patient NHS number. """ -import json from typing import Any -from gateway_api.common.common import json_str from requests import Response -from requests.structures import CaseInsensitiveDict +from .base_stub import StubBase -def _create_response( - status_code: int, - headers: dict[str, str] | CaseInsensitiveDict[str], - content: bytes, - reason: str = "", -) -> Response: - """ - Create a :class:`requests.Response` object for the stub. - - :param status_code: HTTP status code. - :param headers: Response headers dictionary. - :param content: Response body as bytes. - :param reason: HTTP reason phrase (e.g., "OK", "Bad Request"). - :return: A :class:`requests.Response` instance. - """ - response = Response() - response.status_code = status_code - response.headers = CaseInsensitiveDict(headers) - response._content = content # noqa: SLF001 - response.reason = reason - response.encoding = "utf-8" - return response - -class GpProviderStub: +class GpProviderStub(StubBase): """ A minimal in-memory stub for a Provider GP System FHIR API, implementing only accessRecordStructured to read basic @@ -118,36 +93,61 @@ def access_record_structured( returns: Response: The stub patient bundle wrapped in a Response object. """ + if trace_id == "invalid for test": + return self._create_response( + status_code=400, + headers={"Content-Type": "application/fhir+json"}, + json_data={ + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Invalid for testing", + } + ], + }, + ) - stub_response = _create_response( + return self._create_response( status_code=200, - headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), - content=json.dumps(self.patient_bundle).encode("utf-8"), - reason="OK", + headers={"Content-Type": "application/fhir+json"}, + json_data=self.patient_bundle, ) - if trace_id == "invalid for test": - return _create_response( - status_code=400, - headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), - content=( - b'{"resourceType":"OperationOutcome","issue":[' - b'{"severity":"error","code":"invalid",' - b'"diagnostics":"Invalid for testing"}]}' - ), - reason="Bad Request", - ) + def post( + self, + url: str, # NOQA ARG001 # NOSONAR S1172 (unused in stub) + headers: dict[str, Any], + data: str, + timeout: int, # NOQA ARG001 # NOSONAR S1172 (unused in stub) + ) -> Response: + """ + Handle HTTP POST requests for the stub. - return stub_response + :param url: Request URL. + :param headers: Request headers. + :param data: Request body data. + :param timeout: Request timeout in seconds. + :return: A :class:`requests.Response` instance. + """ + trace_id = headers.get("Ssp-TraceID", "no-trace-id") + return self.access_record_structured(trace_id, data) + def get( + self, + url: str, + headers: dict[str, str], + params: dict[str, Any], + timeout: int, + ) -> Response: + """ + Handle HTTP GET requests for the stub. -def stub_post( - url: str, # NOQA ARG001 # NOSONAR S1172 (unused in stub) - headers: dict[str, Any], - data: json_str, - timeout: int, # NOQA ARG001 # NOSONAR S1172 (unused in stub) -) -> Response: - """A stubbed requests.post function that routes to the GPProviderStub.""" - _provider_stub = GpProviderStub() - trace_id = headers.get("Ssp-TraceID", "no-trace-id") - return _provider_stub.access_record_structured(trace_id, data) + :param url: Request URL. + :param headers: Request headers. + :param params: Query parameters. + :param timeout: Request timeout in seconds. + :raises NotImplementedError: GET requests are not supported by this stub. + """ + raise NotImplementedError("GET requests are not supported by GpProviderStub") diff --git a/gateway-api/stubs/stubs/stub_sds.py b/gateway-api/stubs/stubs/stub_sds.py index a67b437c..b8c10e8a 100644 --- a/gateway-api/stubs/stubs/stub_sds.py +++ b/gateway-api/stubs/stubs/stub_sds.py @@ -8,13 +8,13 @@ from typing import TYPE_CHECKING, Any -from stubs.base_stub import FhirApiStubBase +from .base_stub import StubBase if TYPE_CHECKING: from requests import Response -class SdsFhirApiStub(FhirApiStubBase): +class SdsFhirApiStub(StubBase): """ Minimal in-memory stub for the SDS FHIR API, implementing ``GET /Device`` and ``GET /Endpoint`` @@ -645,6 +645,24 @@ def get( url=url, headers=headers, params=params, timeout=timeout ) + def post( + self, + url: str, + headers: dict[str, Any], + data: Any, + timeout: int, + ) -> Response: + """ + Handle HTTP POST requests for the stub. + + :param url: Request URL. + :param headers: Request headers. + :param data: Request body data. + :param timeout: Request timeout in seconds. + :raises NotImplementedError: POST requests are not supported by this stub. + """ + raise NotImplementedError("POST requests are not supported by SdsFhirApiStub") + # --------------------------- # Internal helpers # --------------------------- From 3320c0dbb94ad569fbb8fc7a2bc74261fade09eb Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:10:26 +0000 Subject: [PATCH 09/22] Refactor stub data to make sonar happy --- gateway-api/stubs/stubs/stub_sds.py | 433 +++++++++------------------- 1 file changed, 143 insertions(+), 290 deletions(-) diff --git a/gateway-api/stubs/stubs/stub_sds.py b/gateway-api/stubs/stubs/stub_sds.py index b8c10e8a..5af59263 100644 --- a/gateway-api/stubs/stubs/stub_sds.py +++ b/gateway-api/stubs/stubs/stub_sds.py @@ -73,313 +73,166 @@ def __init__(self) -> None: def _seed_default_devices(self) -> None: """Seed the stub with some default Device records for testing.""" - self.upsert_device( - organization_ods="PROVIDER", - service_interaction_id=self.GP_CONNECT_INTERACTION, - party_key="PROVIDER-0000806", - device={ - "resourceType": "Device", - "id": "F0F0E921-92CA-4A88-A550-2DBB36F703AF", - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": "asid_PROV", - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": "PROVIDER-0000806", - }, - ], - "owner": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": "PROVIDER", - }, - "display": "Example NHS Trust", - }, + # Define test device data as a list of parameters + device_data = [ + { + "org_ods": "PROVIDER", + "party_key": "PROVIDER-0000806", + "device_id": "F0F0E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_PROV", + "display": "Example NHS Trust", }, - ) - - self.upsert_device( - organization_ods="CONSUMER", - service_interaction_id=self.GP_CONNECT_INTERACTION, - party_key="CONSUMER-0000807", - device={ - "resourceType": "Device", - "id": "C0C0E921-92CA-4A88-A550-2DBB36F703AF", - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": "asid_CONS", - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": "CONSUMER-0000807", - }, - ], - "owner": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": "CONSUMER", - }, - "display": "Example Consumer Organisation", - }, + { + "org_ods": "CONSUMER", + "party_key": "CONSUMER-0000807", + "device_id": "C0C0E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_CONS", + "display": "Example Consumer Organisation", }, - ) - - self.upsert_device( - organization_ods="A12345", - service_interaction_id=self.GP_CONNECT_INTERACTION, - party_key="A12345-0000808", - device={ - "resourceType": "Device", - "id": "A1A1E921-92CA-4A88-A550-2DBB36F703AF", - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": "asid_A12345", - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": "A12345-0000808", - }, - ], - "owner": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": "A12345", - }, - "display": "Example GP Practice A12345", - }, + { + "org_ods": "A12345", + "party_key": "A12345-0000808", + "device_id": "A1A1E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_A12345", + "display": "Example GP Practice A12345", }, - ) + ] + + # Iterate through test data and create devices + for data in device_data: + self.upsert_device( + organization_ods=data["org_ods"], + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key=data["party_key"], + device=self._create_device_resource( + device_id=data["device_id"], + asid=data["asid"], + party_key=data["party_key"], + org_ods=data["org_ods"], + display=data["display"], + ), + ) def _seed_default_endpoints(self) -> None: """Seed the stub with some default Endpoint records for testing.""" - # Example 1: Endpoint for provider organization with GP Connect interaction - self.upsert_endpoint( - organization_ods="PROVIDER", - service_interaction_id=self.GP_CONNECT_INTERACTION, - party_key="PROVIDER-0000806", - endpoint={ - "resourceType": "Endpoint", - "id": "E0E0E921-92CA-4A88-A550-2DBB36F703AF", - "status": "active", - "connectionType": { - "system": self.CONNECTION_SYSTEM, - "code": "hl7-fhir-rest", - "display": self.CONNECTION_DISPLAY, - }, - "payloadType": [ - { - "coding": [ - { - "system": self.CODING_SYSTEM, - "code": "any", - "display": "Any", - } - ] - } - ], + # Define test endpoint data as a list of parameters + endpoint_data = [ + { + "org_ods": "PROVIDER", + "party_key": "PROVIDER-0000806", + "endpoint_id": "E0E0E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_PROV", "address": "https://provider.example.com/fhir", - "managingOrganization": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": "PROVIDER", - } - }, - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": "asid_PROV", - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": "PROVIDER-0000806", - }, - ], }, - ) - - # Also seed endpoint with PSIS interaction for backwards compatibility - self.upsert_endpoint( - organization_ods="PROVIDER", - service_interaction_id=self.GP_CONNECT_INTERACTION, - party_key="PROVIDER-0000806", - endpoint={ - "resourceType": "Endpoint", - "id": "E0E0E921-92CA-4A88-A550-2DBB36F703AF", - "status": "active", - "connectionType": { - "system": self.CONNECTION_SYSTEM, - "code": "hl7-fhir-rest", - "display": self.CONNECTION_DISPLAY, - }, - "payloadType": [ - { - "coding": [ - { - "system": self.CODING_SYSTEM, - "code": "any", - "display": "Any", - } - ] - } - ], - "address": "https://provider.example.com/fhir", - "managingOrganization": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": "PROVIDER", - } - }, - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": "asid_PROV", - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": "PROVIDER-0000806", - }, - ], - }, - ) - - # Example 2: Endpoint for consumer organization with GP Connect interaction - self.upsert_endpoint( - organization_ods="CONSUMER", - service_interaction_id=self.GP_CONNECT_INTERACTION, - party_key="CONSUMER-0000807", - endpoint={ - "resourceType": "Endpoint", - "id": "E1E1E921-92CA-4A88-A550-2DBB36F703AF", - "status": "active", - "connectionType": { - "system": self.CONNECTION_SYSTEM, - "code": "hl7-fhir-rest", - "display": self.CONNECTION_DISPLAY, - }, - "payloadType": [ - { - "coding": [ - { - "system": self.CODING_SYSTEM, - "code": "any", - "display": "Any", - } - ] - } - ], + { + "org_ods": "CONSUMER", + "party_key": "CONSUMER-0000807", + "endpoint_id": "E1E1E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_CONS", "address": "https://consumer.example.com/fhir", - "managingOrganization": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": "CONSUMER", - } - }, - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": "asid_CONS", - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": "CONSUMER-0000807", - }, - ], }, - ) + { + "org_ods": "A12345", + "party_key": "A12345-0000808", + "endpoint_id": "E2E2E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_A12345", + "address": "https://a12345.example.com/fhir", + }, + ] + + # Iterate through test data and create endpoints + for data in endpoint_data: + self.upsert_endpoint( + organization_ods=data["org_ods"], + service_interaction_id=self.GP_CONNECT_INTERACTION, + party_key=data["party_key"], + endpoint=self._create_endpoint_resource( + endpoint_id=data["endpoint_id"], + asid=data["asid"], + party_key=data["party_key"], + org_ods=data["org_ods"], + address=data["address"], + ), + ) - # Also seed endpoint with PSIS interaction for backwards compatibility - self.upsert_endpoint( - organization_ods="CONSUMER", - service_interaction_id=self.GP_CONNECT_INTERACTION, - party_key="CONSUMER-0000807", - endpoint={ - "resourceType": "Endpoint", - "id": "E1E1E921-92CA-4A88-A550-2DBB36F703AF", - "status": "active", - "connectionType": { - "system": self.CONNECTION_SYSTEM, - "code": "hl7-fhir-rest", - "display": self.CONNECTION_DISPLAY, + def _create_device_resource( + self, + device_id: str, + asid: str, + party_key: str, + org_ods: str, + display: str, + ) -> dict[str, Any]: + """Create a Device resource dictionary with the given parameters.""" + return { + "resourceType": "Device", + "id": device_id, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": asid, }, - "payloadType": [ - { - "coding": [ - { - "system": self.CODING_SYSTEM, - "code": "any", - "display": "Any", - } - ] - } - ], - "address": "https://consumer.example.com/fhir", - "managingOrganization": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": "CONSUMER", - } + { + "system": self.PARTYKEY_SYSTEM, + "value": party_key, + }, + ], + "owner": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": org_ods, }, - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": "asid_CONS", - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": "CONSUMER-0000807", - }, - ], + "display": display, }, - ) + } - # Example 3: Endpoint for A12345 organization with GP Connect interaction - self.upsert_endpoint( - organization_ods="A12345", - service_interaction_id=self.GP_CONNECT_INTERACTION, - party_key="A12345-0000808", - endpoint={ - "resourceType": "Endpoint", - "id": "E2E2E921-92CA-4A88-A550-2DBB36F703AF", - "status": "active", - "connectionType": { - "system": self.CONNECTION_SYSTEM, - "code": "hl7-fhir-rest", - "display": self.CONNECTION_DISPLAY, + def _create_endpoint_resource( + self, + endpoint_id: str, + asid: str, + party_key: str, + org_ods: str, + address: str, + ) -> dict[str, Any]: + """Create an Endpoint resource dictionary with the given parameters.""" + return { + "resourceType": "Endpoint", + "id": endpoint_id, + "status": "active", + "connectionType": { + "system": self.CONNECTION_SYSTEM, + "code": "hl7-fhir-rest", + "display": self.CONNECTION_DISPLAY, + }, + "payloadType": [ + { + "coding": [ + { + "system": self.CODING_SYSTEM, + "code": "any", + "display": "Any", + } + ] + } + ], + "address": address, + "managingOrganization": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": org_ods, + } + }, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": asid, }, - "payloadType": [ - { - "coding": [ - { - "system": self.CODING_SYSTEM, - "code": "any", - "display": "Any", - } - ] - } - ], - "address": "https://a12345.example.com/fhir", - "managingOrganization": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": "A12345", - } + { + "system": self.PARTYKEY_SYSTEM, + "value": party_key, }, - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": "asid_A12345", - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": "A12345-0000808", - }, - ], - }, - ) + ], + } # --------------------------- # Public API for tests From af0024d60431048af9eca0b7a744fd45824e4325 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:18:50 +0000 Subject: [PATCH 10/22] That really wasn't a security flaw, sonar --- gateway-api/stubs/stubs/stub_sds.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gateway-api/stubs/stubs/stub_sds.py b/gateway-api/stubs/stubs/stub_sds.py index 5af59263..16fc6d75 100644 --- a/gateway-api/stubs/stubs/stub_sds.py +++ b/gateway-api/stubs/stubs/stub_sds.py @@ -40,8 +40,10 @@ class SdsFhirApiStub(StubBase): INTERACTION_SYSTEM = "https://fhir.nhs.uk/Id/nhsServiceInteractionId" PARTYKEY_SYSTEM = "https://fhir.nhs.uk/Id/nhsMhsPartyKey" ASID_SYSTEM = "https://fhir.nhs.uk/Id/nhsSpineASID" - CONNECTION_SYSTEM = "http://terminology.hl7.org/CodeSystem/endpoint-connection-type" - CODING_SYSTEM = "http://terminology.hl7.org/CodeSystem/endpoint-payload-type" + CONNECTION_SYSTEM = ( + "https://terminology.hl7.org/CodeSystem/endpoint-connection-type" + ) + CODING_SYSTEM = "https://terminology.hl7.org/CodeSystem/endpoint-payload-type" GP_CONNECT_INTERACTION = ( "urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1" From 8bb8a100f2508dea0a683a685159bc71b8573471 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:17:36 +0000 Subject: [PATCH 11/22] Comment/docstring changes --- gateway-api/stubs/stubs/base_stub.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/gateway-api/stubs/stubs/base_stub.py b/gateway-api/stubs/stubs/base_stub.py index 246ec5fe..f1b08070 100644 --- a/gateway-api/stubs/stubs/base_stub.py +++ b/gateway-api/stubs/stubs/base_stub.py @@ -31,16 +31,11 @@ def _create_response( ) -> Response: """ Create a :class:`requests.Response` object for the stub. - - :param status_code: HTTP status code. - :param headers: Response headers dictionary. - :param json_data: JSON body data. - :return: A :class:`requests.Response` instance. """ response = Response() response.status_code = status_code response.headers = CaseInsensitiveDict(headers) - response._content = json.dumps(json_data).encode("utf-8") # noqa: SLF001 + response._content = json.dumps(json_data).encode("utf-8") # noqa: SLF001 to customise stub response.encoding = "utf-8" # Set a reason phrase for HTTP error handling response.reason = http_responses.get(status_code, "Unknown") @@ -52,12 +47,6 @@ def get( ) -> Response: """ Handle HTTP GET requests for the stub. - - :param url: Request URL. - :param headers: Request headers. - :param params: Query parameters. - :param timeout: Request timeout in seconds. - :return: A :class:`requests.Response` instance. """ @abstractmethod @@ -70,10 +59,4 @@ def post( ) -> Response: """ Handle HTTP POST requests for the stub. - - :param url: Request URL. - :param headers: Request headers. - :param data: Request body data. - :param timeout: Request timeout in seconds. - :return: A :class:`requests.Response` instance. """ From 758861bad90c02792936125a0137bba4caf010a9 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:45:48 +0000 Subject: [PATCH 12/22] Remove error handling and testing --- gateway-api/src/gateway_api/sds_search.py | 12 +- .../src/gateway_api/test_sds_search.py | 167 ------------------ .../tests/integration/test_sds_search.py | 68 ------- 3 files changed, 3 insertions(+), 244 deletions(-) diff --git a/gateway-api/src/gateway_api/sds_search.py b/gateway-api/src/gateway_api/sds_search.py index f6c70d75..a3183a87 100644 --- a/gateway-api/src/gateway_api/sds_search.py +++ b/gateway-api/src/gateway_api/sds_search.py @@ -234,13 +234,7 @@ def _query_sds( timeout=timeout or self.timeout, ) - try: - response.raise_for_status() - except requests.HTTPError as err: - raise ExternalServiceError( - f"SDS /{querytype} request failed: {err.response.status_code} " - f"{err.response.reason}" - ) from err + # TODO: Post-steel-thread we probably want a raise_for_status() here body = response.json() return cast("ResultStructureDict", body) @@ -258,8 +252,8 @@ def _extract_first_entry( :return: First Device resource, or ``None`` if the bundle is empty. """ entries = cast("ResultList", bundle.get("entry", [])) - if not entries: - return None + + # TODO: Post-steel-thread handle case where bundle contains no entries first_entry = entries[0] return cast("ResultStructureDict", first_entry.get("resource", {})) diff --git a/gateway-api/src/gateway_api/test_sds_search.py b/gateway-api/src/gateway_api/test_sds_search.py index 912b1e09..d023fb1e 100644 --- a/gateway-api/src/gateway_api/test_sds_search.py +++ b/gateway-api/src/gateway_api/test_sds_search.py @@ -207,23 +207,6 @@ def test_sds_client_get_org_details_no_endpoint( assert result.endpoint is None -def test_sds_client_get_org_details_not_found( - stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 -) -> None: - """ - Test SdsClient returns None when organization is not found. - - :param stub: SDS stub fixture. - :param mock_requests_get: Capture fixture for request details. - """ - client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) - - result = client.get_org_details(ods_code="NONEXISTENT") - - assert result is None - - def test_sds_client_sends_correlation_id( stub: SdsFhirApiStub, # noqa: ARG001 mock_requests_get: dict[str, Any], # noqa: ARG001 @@ -404,153 +387,3 @@ def test_sds_client_extract_party_key_from_device( # Should have found ASID but may not have endpoint depending on seeding assert result is not None assert result.asid == "asid_CONS" - - -def test_sds_client_handles_http_error_from_device_endpoint( - stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ - Test that ExternalServiceError is raised when Device API returns HTTP error. - - :param stub: SDS stub fixture. - :param mock_requests_get: Capture fixture for request details. - :param monkeypatch: Pytest monkeypatch fixture. - """ - from unittest.mock import Mock - - import requests - - from gateway_api.sds_search import ExternalServiceError - - # Create a mock response with error status - mock_response = Mock() - mock_response.status_code = 500 - mock_response.reason = "Internal Server Error" - mock_response.raise_for_status.side_effect = requests.HTTPError( - response=mock_response - ) - - # Create a mock that returns our error response - def mock_get(*args: Any, **kwargs: Any) -> Mock: # noqa: ARG001 - return mock_response - - # Patch the get_method to return error - client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) - monkeypatch.setattr(client, "get_method", mock_get) - - # Should raise ExternalServiceError - with pytest.raises(ExternalServiceError) as exc_info: - client.get_org_details(ods_code="PROVIDER") - - assert "Device request failed" in str(exc_info.value) - assert "500" in str(exc_info.value) - - -def test_sds_client_handles_http_error_from_endpoint_endpoint( - stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ - Test that ExternalServiceError is raised when Endpoint API returns HTTP error. - - :param stub: SDS stub fixture. - :param mock_requests_get: Capture fixture for request details. - :param monkeypatch: Pytest monkeypatch fixture. - """ - from unittest.mock import Mock - - import requests - - from gateway_api.sds_search import ExternalServiceError - - call_count = {"count": 0} - - # Create mock responses - def mock_get(url: str, *args: Any, **kwargs: Any) -> Mock: # noqa: ARG001 - call_count["count"] += 1 - if call_count["count"] == 1: - # First call (Device) - return success - device_response = Mock() - device_response.status_code = 200 - device_response.raise_for_status = Mock() - device_response.json.return_value = { - "resourceType": "Bundle", - "type": "searchset", - "total": 1, - "entry": [ - { - "resource": { - "resourceType": "Device", - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhsSpineASID", - "value": "123456789012", - }, - { - "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", - "value": "TEST-PARTY-KEY", - }, - ], - } - } - ], - } - return device_response - else: - # Second call (Endpoint) - return error - endpoint_response = Mock() - endpoint_response.status_code = 503 - endpoint_response.reason = "Service Unavailable" - endpoint_response.raise_for_status.side_effect = requests.HTTPError( - response=endpoint_response - ) - return endpoint_response - - client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) - monkeypatch.setattr(client, "get_method", mock_get) - - # Should raise ExternalServiceError on Endpoint query - with pytest.raises(ExternalServiceError) as exc_info: - client.get_org_details(ods_code="TESTORG") - - assert "Endpoint request failed" in str(exc_info.value) - assert "503" in str(exc_info.value) - - -def test_sds_client_handles_empty_bundle_gracefully( - stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ - Test that client handles empty Bundle (total: 0) gracefully. - - :param stub: SDS stub fixture. - :param mock_requests_get: Capture fixture for request details. - :param monkeypatch: Pytest monkeypatch fixture. - """ - from unittest.mock import Mock - - # Create mock that returns empty bundle - def mock_get(*args: Any, **kwargs: Any) -> Mock: # noqa: ARG001 - response = Mock() - response.status_code = 200 - response.raise_for_status = Mock() - response.json.return_value = { - "resourceType": "Bundle", - "type": "searchset", - "total": 0, - "entry": [], - } - return response - - client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) - monkeypatch.setattr(client, "get_method", mock_get) - - # Should return None for empty result - result = client.get_org_details(ods_code="NONEXISTENT") - - assert result is None diff --git a/gateway-api/tests/integration/test_sds_search.py b/gateway-api/tests/integration/test_sds_search.py index 26bb7b89..c12623d2 100644 --- a/gateway-api/tests/integration/test_sds_search.py +++ b/gateway-api/tests/integration/test_sds_search.py @@ -68,74 +68,6 @@ def test_get_device_with_party_key_returns_endpoint( assert result.endpoint.startswith("https://") assert "fhir" in result.endpoint - def test_get_device_for_nonexistent_ods_returns_none( - self, sds_client: SdsClient - ) -> None: - """ - Test that querying for a non-existent ODS code returns None. - - :param sds_client: SDS client fixture configured with stub. - """ - result = sds_client.get_org_details(ods_code="NONEXISTENT") - - assert result is None - - def test_missing_required_parameters_returns_400( - self, sds_stub: SdsFhirApiStub - ) -> None: - """ - Test that missing required parameters returns a 400 error. - - :param sds_stub: SDS stub fixture. - """ - # Test missing organization parameter - response = sds_stub.get_device_bundle( - url="http://test/Device", - headers={"apikey": "test-key"}, - params={ - "identifier": [ - "https://fhir.nhs.uk/Id/nhsServiceInteractionId|urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1" - ] - }, - ) - - assert response.status_code == 400 - body = response.json() - assert body["resourceType"] == "OperationOutcome" - assert body["issue"][0]["severity"] == "error" - assert "organization" in body["issue"][0]["diagnostics"].lower() - - # Test missing identifier parameter - response = sds_stub.get_device_bundle( - url="http://test/Device", - headers={"apikey": "test-key"}, - params={ - "organization": "https://fhir.nhs.uk/Id/ods-organization-code|PROVIDER" - }, - ) - - assert response.status_code == 400 - body = response.json() - assert body["resourceType"] == "OperationOutcome" - assert body["issue"][0]["severity"] == "error" - assert "identifier" in body["issue"][0]["diagnostics"].lower() - - # Test missing service interaction ID in identifier - response = sds_stub.get_device_bundle( - url="http://test/Device", - headers={"apikey": "test-key"}, - params={ - "organization": "https://fhir.nhs.uk/Id/ods-organization-code|PROVIDER", - "identifier": ["https://fhir.nhs.uk/Id/nhsMhsPartyKey|TEST-KEY"], - }, - ) - - assert response.status_code == 400 - body = response.json() - assert body["resourceType"] == "OperationOutcome" - assert body["issue"][0]["severity"] == "error" - assert "nhsServiceInteractionId" in body["issue"][0]["diagnostics"] - def test_consumer_organization_lookup(self, sds_client: SdsClient) -> None: """ Test that CONSUMER organization can be looked up successfully. From d523e09569a49cd94b46548e1371d4f4830ec58a Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:20:40 +0000 Subject: [PATCH 13/22] Move interaction ID into common --- gateway-api/src/gateway_api/common/common.py | 6 ++++++ .../src/gateway_api/get_structured_record/request.py | 7 +++++-- gateway-api/src/gateway_api/provider_request.py | 6 +++--- gateway-api/src/gateway_api/sds_search.py | 6 +++--- gateway-api/src/gateway_api/test_provider_request.py | 8 ++------ gateway-api/src/gateway_api/test_sds_search.py | 12 ++++++++---- gateway-api/stubs/stubs/stub_sds.py | 10 ++++------ 7 files changed, 31 insertions(+), 24 deletions(-) diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py index 3891b8f3..32b37543 100644 --- a/gateway-api/src/gateway_api/common/common.py +++ b/gateway-api/src/gateway_api/common/common.py @@ -9,6 +9,12 @@ # The alias is used to make intent clearer in function signatures. type json_str = str +# Access record structured interaction ID from +# https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions +ACCESS_RECORD_STRUCTURED_INTERACTION_ID = ( + "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" +) + @dataclass class FlaskResponse: diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index c4279272..420e1a97 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -5,7 +5,10 @@ from fhir.operation_outcome import OperationOutcomeIssue from flask.wrappers import Request, Response -from gateway_api.common.common import FlaskResponse +from gateway_api.common.common import ( + ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + FlaskResponse, +) if TYPE_CHECKING: from fhir.bundle import Bundle @@ -16,7 +19,7 @@ class RequestValidationError(Exception): class GetStructuredRecordRequest: - INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" + INTERACTION_ID: str = ACCESS_RECORD_STRUCTURED_INTERACTION_ID RESOURCE: str = "patient" FHIR_OPERATION: str = "$gpc.getstructuredrecord" diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index 3afe0e7f..31420b1d 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -28,10 +28,10 @@ from requests import HTTPError, Response, post from stubs.stub_provider import GpProviderStub -ARS_INTERACTION_ID = ( - "urn:nhs:names:services:gpconnect:structured" - ":fhir:operation:gpc.getstructuredrecord-1" +from gateway_api.common.common import ( + ACCESS_RECORD_STRUCTURED_INTERACTION_ID as ARS_INTERACTION_ID, ) + ARS_FHIR_BASE = "FHIR/STU3" FHIR_RESOURCE = "patient" ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" diff --git a/gateway-api/src/gateway_api/sds_search.py b/gateway-api/src/gateway_api/sds_search.py index a3183a87..a6e8da06 100644 --- a/gateway-api/src/gateway_api/sds_search.py +++ b/gateway-api/src/gateway_api/sds_search.py @@ -18,6 +18,8 @@ import requests from stubs.stub_sds import SdsFhirApiStub +from gateway_api.common.common import ACCESS_RECORD_STRUCTURED_INTERACTION_ID + # Recursive JSON-like structure typing used for parsed FHIR bodies. type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] type ResultStructureDict = dict[str, ResultStructure] @@ -92,9 +94,7 @@ class SdsClient: ENDPOINT: Literal["Endpoint"] = "Endpoint" # Default service interaction ID for GP Connect - DEFAULT_SERVICE_INTERACTION_ID = ( - "urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1" - ) + DEFAULT_SERVICE_INTERACTION_ID = ACCESS_RECORD_STRUCTURED_INTERACTION_ID def __init__( self, diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index 6441490a..3deb035a 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -14,13 +14,9 @@ from stubs.stub_provider import GpProviderStub from gateway_api import provider_request +from gateway_api.common.common import ACCESS_RECORD_STRUCTURED_INTERACTION_ID 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: @@ -123,7 +119,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": ACCESS_RECORD_STRUCTURED_INTERACTION_ID, } result = client.access_structured_record(trace_id, "body") diff --git a/gateway-api/src/gateway_api/test_sds_search.py b/gateway-api/src/gateway_api/test_sds_search.py index d023fb1e..b74b9d16 100644 --- a/gateway-api/src/gateway_api/test_sds_search.py +++ b/gateway-api/src/gateway_api/test_sds_search.py @@ -9,7 +9,11 @@ import pytest from stubs.stub_sds import SdsFhirApiStub -from gateway_api.sds_search import SdsClient, SdsSearchResults +from gateway_api.common.common import ACCESS_RECORD_STRUCTURED_INTERACTION_ID +from gateway_api.sds_search import ( + SdsClient, + SdsSearchResults, +) @pytest.fixture @@ -110,7 +114,7 @@ def test_sds_client_get_org_details_with_endpoint( # Add a device with party key so we can get an endpoint stub.upsert_device( organization_ods="TESTORG", - service_interaction_id="urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, party_key="TESTORG-123456", device={ "resourceType": "Device", @@ -136,7 +140,7 @@ def test_sds_client_get_org_details_with_endpoint( stub.upsert_endpoint( organization_ods="TESTORG", - service_interaction_id="urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, party_key="TESTORG-123456", endpoint={ "resourceType": "Endpoint", @@ -179,7 +183,7 @@ def test_sds_client_get_org_details_no_endpoint( # Add a device without a party key (so no endpoint will be found) stub.upsert_device( organization_ods="NOENDPOINT", - service_interaction_id="urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, party_key=None, device={ "resourceType": "Device", diff --git a/gateway-api/stubs/stubs/stub_sds.py b/gateway-api/stubs/stubs/stub_sds.py index 16fc6d75..c6b5218d 100644 --- a/gateway-api/stubs/stubs/stub_sds.py +++ b/gateway-api/stubs/stubs/stub_sds.py @@ -8,6 +8,8 @@ from typing import TYPE_CHECKING, Any +from gateway_api.common.common import ACCESS_RECORD_STRUCTURED_INTERACTION_ID + from .base_stub import StubBase if TYPE_CHECKING: @@ -44,10 +46,6 @@ class SdsFhirApiStub(StubBase): "https://terminology.hl7.org/CodeSystem/endpoint-connection-type" ) CODING_SYSTEM = "https://terminology.hl7.org/CodeSystem/endpoint-payload-type" - - GP_CONNECT_INTERACTION = ( - "urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1" - ) CONNECTION_DISPLAY = "HL7 FHIR" def __init__(self) -> None: @@ -104,7 +102,7 @@ def _seed_default_devices(self) -> None: for data in device_data: self.upsert_device( organization_ods=data["org_ods"], - service_interaction_id=self.GP_CONNECT_INTERACTION, + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, party_key=data["party_key"], device=self._create_device_resource( device_id=data["device_id"], @@ -146,7 +144,7 @@ def _seed_default_endpoints(self) -> None: for data in endpoint_data: self.upsert_endpoint( organization_ods=data["org_ods"], - service_interaction_id=self.GP_CONNECT_INTERACTION, + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, party_key=data["party_key"], endpoint=self._create_endpoint_resource( endpoint_id=data["endpoint_id"], From bb7b2fa3415514e5b9d5c1e38745beef81d7054d Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:29:50 +0000 Subject: [PATCH 14/22] Remove error check on no hit on ODS code --- gateway-api/src/gateway_api/sds_search.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gateway-api/src/gateway_api/sds_search.py b/gateway-api/src/gateway_api/sds_search.py index a6e8da06..0673c334 100644 --- a/gateway-api/src/gateway_api/sds_search.py +++ b/gateway-api/src/gateway_api/sds_search.py @@ -174,8 +174,8 @@ def get_org_details( ) device = self._extract_first_entry(device_bundle) - if device is None: - return None + + # TODO: Post-steel-thread handle case where no device is found for ODS code asid = self._extract_identifier(device, self.ASID_SYSTEM) party_key = self._extract_identifier(device, self.PARTYKEY_SYSTEM) @@ -244,7 +244,7 @@ def _query_sds( @staticmethod def _extract_first_entry( bundle: ResultStructureDict, - ) -> ResultStructureDict | None: + ) -> ResultStructureDict: # TODO: Post-steel-thread this may return a None as well """ Extract the first Device resource from a Bundle. From de073fa34da20fc51037675f39330ea7524fde47 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:30:09 +0000 Subject: [PATCH 15/22] Refactor accidental singleton out of tests --- gateway-api/src/gateway_api/sds_search.py | 19 +- .../src/gateway_api/test_sds_search.py | 80 +--- gateway-api/stubs/stubs/base_stub.py | 70 +++- gateway-api/stubs/stubs/stub_sds.py | 364 +++++++++--------- .../images/build-container/Dockerfile | 6 + infrastructure/images/gateway-api/Dockerfile | 6 + 6 files changed, 284 insertions(+), 261 deletions(-) diff --git a/gateway-api/src/gateway_api/sds_search.py b/gateway-api/src/gateway_api/sds_search.py index 0673c334..01f147e5 100644 --- a/gateway-api/src/gateway_api/sds_search.py +++ b/gateway-api/src/gateway_api/sds_search.py @@ -11,6 +11,7 @@ from __future__ import annotations +import os from collections.abc import Callable from dataclasses import dataclass from typing import Any, Literal, cast @@ -93,6 +94,9 @@ class SdsClient: DEVICE: Literal["Device"] = "Device" ENDPOINT: Literal["Endpoint"] = "Endpoint" + # Define here so it's neater + get_method: GetCallable + # Default service interaction ID for GP Connect DEFAULT_SERVICE_INTERACTION_ID = ACCESS_RECORD_STRUCTURED_INTERACTION_ID @@ -118,14 +122,13 @@ def __init__( self.service_interaction_id = ( service_interaction_id or self.DEFAULT_SERVICE_INTERACTION_ID ) - self.stub = SdsFhirApiStub() - - # Use stub for now - use environment variable once we have one - # TODO: Put this back to using the environment variable - # if os.environ.get("STUB_SDS", None): - self.get_method: GetCallable = self.stub.get - # else: - # self.get_method: GetCallable = requests.get + self.stub = None + + if os.environ.get("STUB_SDS", None): + self.stub = SdsFhirApiStub() + self.get_method = self.stub.get + else: + self.get_method = requests.get def _build_headers(self, correlation_id: str | None = None) -> dict[str, str]: """ diff --git a/gateway-api/src/gateway_api/test_sds_search.py b/gateway-api/src/gateway_api/test_sds_search.py index b74b9d16..ce0fe33c 100644 --- a/gateway-api/src/gateway_api/test_sds_search.py +++ b/gateway-api/src/gateway_api/test_sds_search.py @@ -4,8 +4,6 @@ from __future__ import annotations -from typing import Any - import pytest from stubs.stub_sds import SdsFhirApiStub @@ -17,59 +15,8 @@ @pytest.fixture -def stub() -> SdsFhirApiStub: - """ - Create a stub backend instance. - - :return: A :class:`stubs.stub_sds.SdsFhirApiStub` instance. - """ - return SdsFhirApiStub() - - -@pytest.fixture -def mock_requests_get( - monkeypatch: pytest.MonkeyPatch, stub: SdsFhirApiStub -) -> dict[str, Any]: - """ - Patch ``SdsFhirApiStub`` so the SdsClient uses the test stub fixture. - - The fixture returns a "capture" dict recording the most recent request information. - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend used to serve GET requests. - :param return: A capture dictionary containing the last call details. - """ - capture: dict[str, Any] = {} - - # Wrap the stub's get method to capture call parameters - original_stub_get = stub.get - - def _capturing_get( - url: str, - headers: dict[str, str] | None = None, - params: Any = None, - timeout: Any = None, - ) -> Any: - """ - Wrapper around stub.get that captures parameters. - - :param url: URL passed by the client. - :param headers: Headers passed by the client. - :param params: Query parameters. - :param timeout: Timeout. - :return: Response from the stub. - """ - headers = headers or {} - capture["url"] = url - capture["headers"] = dict(headers) - capture["params"] = params - capture["timeout"] = timeout - - return original_stub_get(url, headers, params, timeout) - - stub.get = _capturing_get # type: ignore[method-assign] - - # Monkeypatch SdsFhirApiStub so SdsClient uses our test stub +def stub(monkeypatch: pytest.MonkeyPatch) -> SdsFhirApiStub: + stub = SdsFhirApiStub() import gateway_api.sds_search as sds_module monkeypatch.setattr( @@ -78,12 +25,11 @@ def _capturing_get( lambda *args, **kwargs: stub, # NOQA ARG005 (maintain signature) ) - return capture + return stub def test_sds_client_get_org_details_success( stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test SdsClient can successfully look up organization details. @@ -103,7 +49,6 @@ def test_sds_client_get_org_details_success( def test_sds_client_get_org_details_with_endpoint( stub: SdsFhirApiStub, - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test SdsClient retrieves endpoint when available. @@ -111,6 +56,7 @@ def test_sds_client_get_org_details_with_endpoint( :param stub: SDS stub fixture. :param mock_requests_get: Capture fixture for request details. """ + # Add a device with party key so we can get an endpoint stub.upsert_device( organization_ods="TESTORG", @@ -172,7 +118,6 @@ def test_sds_client_get_org_details_with_endpoint( def test_sds_client_get_org_details_no_endpoint( stub: SdsFhirApiStub, - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test SdsClient handles missing endpoint gracefully. @@ -213,7 +158,6 @@ def test_sds_client_get_org_details_no_endpoint( def test_sds_client_sends_correlation_id( stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test that SdsClient sends X-Correlation-Id header when provided. @@ -227,12 +171,11 @@ def test_sds_client_sends_correlation_id( client.get_org_details(ods_code="PROVIDER", correlation_id=correlation_id) # Check that the header was sent - assert mock_requests_get["headers"]["X-Correlation-Id"] == correlation_id + assert stub.get_headers["X-Correlation-Id"] == correlation_id def test_sds_client_sends_apikey( stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test that SdsClient sends apikey header. @@ -246,12 +189,11 @@ def test_sds_client_sends_apikey( client.get_org_details(ods_code="PROVIDER") # Check that the apikey header was sent - assert mock_requests_get["headers"]["apikey"] == api_key + assert stub.get_headers["apikey"] == api_key def test_sds_client_timeout_parameter( stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test that SdsClient passes timeout parameter to requests. @@ -264,12 +206,11 @@ def test_sds_client_timeout_parameter( client.get_org_details(ods_code="PROVIDER", timeout=60) # Check that the custom timeout was passed - assert mock_requests_get["timeout"] == 60 + assert stub.get_timeout == 60 def test_sds_client_custom_service_interaction_id( stub: SdsFhirApiStub, - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test that SdsClient uses custom interaction ID when provided. @@ -311,7 +252,7 @@ def test_sds_client_custom_service_interaction_id( result = client.get_org_details(ods_code="CUSTOMINT") # Verify the custom interaction was used - params = mock_requests_get["params"] + params = stub.get_params assert any( custom_interaction in str(ident) for ident in params.get("identifier", []) ) @@ -323,7 +264,6 @@ def test_sds_client_custom_service_interaction_id( def test_sds_client_builds_correct_device_query_params( stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test that SdsClient builds Device query parameters correctly. @@ -335,7 +275,7 @@ def test_sds_client_builds_correct_device_query_params( client.get_org_details(ods_code="PROVIDER") - params = mock_requests_get["params"] + params = stub.get_params # Check organization parameter assert ( @@ -354,7 +294,6 @@ def test_sds_client_builds_correct_device_query_params( def test_sds_client_extract_asid_from_device( stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test ASID extraction from Device resource. @@ -373,7 +312,6 @@ def test_sds_client_extract_asid_from_device( def test_sds_client_extract_party_key_from_device( stub: SdsFhirApiStub, # noqa: ARG001 - mock_requests_get: dict[str, Any], # noqa: ARG001 ) -> None: """ Test party key extraction and subsequent endpoint lookup. diff --git a/gateway-api/stubs/stubs/base_stub.py b/gateway-api/stubs/stubs/base_stub.py index f1b08070..c6b8d73b 100644 --- a/gateway-api/stubs/stubs/base_stub.py +++ b/gateway-api/stubs/stubs/base_stub.py @@ -7,20 +7,22 @@ from __future__ import annotations import json -from abc import ABC, abstractmethod +from abc import abstractmethod from http.client import responses as http_responses -from typing import Any +from typing import Any, Protocol from requests import Response from requests.structures import CaseInsensitiveDict -class StubBase(ABC): +class StubBase: """ - Abstract base class for FHIR API stubs. + Base class for FHIR API stubs. Provides common functionality for creating HTTP responses and defines the interface that all stub implementations must provide. + + Recommended to subclass with GetStub or PostStub (or both) """ @staticmethod @@ -41,6 +43,8 @@ def _create_response( response.reason = http_responses.get(status_code, "Unknown") return response + +class GetStub(Protocol): @abstractmethod def get( self, url: str, headers: dict[str, str], params: dict[str, Any], timeout: int @@ -49,6 +53,36 @@ def get( Handle HTTP GET requests for the stub. """ + @property + @abstractmethod + def get_url(self) -> str: + """ + Last URL stub.get was called with. Empty string if not called yet. + """ + + @property + @abstractmethod + def get_headers(self) -> dict[str, str]: + """ + Dict of last headers stub.get was called with. Empty if not called yet. + """ + + @property + @abstractmethod + def get_params(self) -> dict[str, str]: + """ + Dict of last get parameters stub.get was called with. Empty if not called yet. + """ + + @property + @abstractmethod + def get_timeout(self) -> int | None: + """ + Last timeout value stub.get was called with. None if not called yet. + """ + + +class PostStub(Protocol): @abstractmethod def post( self, @@ -60,3 +94,31 @@ def post( """ Handle HTTP POST requests for the stub. """ + + @property + @abstractmethod + def post_url(self) -> str: + """ + Last URL stub.post was called with. Empty string if not called yet. + """ + + @property + @abstractmethod + def post_headers(self) -> dict[str, str]: + """ + Dict of last headers stub.post was called with. Empty if not called yet. + """ + + @property + @abstractmethod + def post_data(self) -> str: + """ + Last post request body stub.gpostet was called with. Empty if not called yet. + """ + + @property + @abstractmethod + def post_timeout(self) -> int | None: + """ + Last timeout value stub.post was called with. None if not called yet. + """ diff --git a/gateway-api/stubs/stubs/stub_sds.py b/gateway-api/stubs/stubs/stub_sds.py index c6b5218d..c1620e99 100644 --- a/gateway-api/stubs/stubs/stub_sds.py +++ b/gateway-api/stubs/stubs/stub_sds.py @@ -10,13 +10,13 @@ from gateway_api.common.common import ACCESS_RECORD_STRUCTURED_INTERACTION_ID -from .base_stub import StubBase +from .base_stub import GetStub, StubBase if TYPE_CHECKING: from requests import Response -class SdsFhirApiStub(StubBase): +class SdsFhirApiStub(StubBase, GetStub): """ Minimal in-memory stub for the SDS FHIR API, implementing ``GET /Device`` and ``GET /Endpoint`` @@ -71,168 +71,26 @@ def __init__(self) -> None: self._seed_default_devices() self._seed_default_endpoints() - def _seed_default_devices(self) -> None: - """Seed the stub with some default Device records for testing.""" - # Define test device data as a list of parameters - device_data = [ - { - "org_ods": "PROVIDER", - "party_key": "PROVIDER-0000806", - "device_id": "F0F0E921-92CA-4A88-A550-2DBB36F703AF", - "asid": "asid_PROV", - "display": "Example NHS Trust", - }, - { - "org_ods": "CONSUMER", - "party_key": "CONSUMER-0000807", - "device_id": "C0C0E921-92CA-4A88-A550-2DBB36F703AF", - "asid": "asid_CONS", - "display": "Example Consumer Organisation", - }, - { - "org_ods": "A12345", - "party_key": "A12345-0000808", - "device_id": "A1A1E921-92CA-4A88-A550-2DBB36F703AF", - "asid": "asid_A12345", - "display": "Example GP Practice A12345", - }, - ] + self._last_headers: dict[str, str] = {} + self._last_params: dict[str, str] = {} + self._last_url: str = "" + self._last_timeout: int | None = None - # Iterate through test data and create devices - for data in device_data: - self.upsert_device( - organization_ods=data["org_ods"], - service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, - party_key=data["party_key"], - device=self._create_device_resource( - device_id=data["device_id"], - asid=data["asid"], - party_key=data["party_key"], - org_ods=data["org_ods"], - display=data["display"], - ), - ) + @property + def get_headers(self) -> dict[str, str]: + return self._last_headers - def _seed_default_endpoints(self) -> None: - """Seed the stub with some default Endpoint records for testing.""" - # Define test endpoint data as a list of parameters - endpoint_data = [ - { - "org_ods": "PROVIDER", - "party_key": "PROVIDER-0000806", - "endpoint_id": "E0E0E921-92CA-4A88-A550-2DBB36F703AF", - "asid": "asid_PROV", - "address": "https://provider.example.com/fhir", - }, - { - "org_ods": "CONSUMER", - "party_key": "CONSUMER-0000807", - "endpoint_id": "E1E1E921-92CA-4A88-A550-2DBB36F703AF", - "asid": "asid_CONS", - "address": "https://consumer.example.com/fhir", - }, - { - "org_ods": "A12345", - "party_key": "A12345-0000808", - "endpoint_id": "E2E2E921-92CA-4A88-A550-2DBB36F703AF", - "asid": "asid_A12345", - "address": "https://a12345.example.com/fhir", - }, - ] - - # Iterate through test data and create endpoints - for data in endpoint_data: - self.upsert_endpoint( - organization_ods=data["org_ods"], - service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, - party_key=data["party_key"], - endpoint=self._create_endpoint_resource( - endpoint_id=data["endpoint_id"], - asid=data["asid"], - party_key=data["party_key"], - org_ods=data["org_ods"], - address=data["address"], - ), - ) + @property + def get_params(self) -> dict[str, str]: + return self._last_params - def _create_device_resource( - self, - device_id: str, - asid: str, - party_key: str, - org_ods: str, - display: str, - ) -> dict[str, Any]: - """Create a Device resource dictionary with the given parameters.""" - return { - "resourceType": "Device", - "id": device_id, - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": asid, - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": party_key, - }, - ], - "owner": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": org_ods, - }, - "display": display, - }, - } + @property + def get_url(self) -> str: + return self._last_url - def _create_endpoint_resource( - self, - endpoint_id: str, - asid: str, - party_key: str, - org_ods: str, - address: str, - ) -> dict[str, Any]: - """Create an Endpoint resource dictionary with the given parameters.""" - return { - "resourceType": "Endpoint", - "id": endpoint_id, - "status": "active", - "connectionType": { - "system": self.CONNECTION_SYSTEM, - "code": "hl7-fhir-rest", - "display": self.CONNECTION_DISPLAY, - }, - "payloadType": [ - { - "coding": [ - { - "system": self.CODING_SYSTEM, - "code": "any", - "display": "Any", - } - ] - } - ], - "address": address, - "managingOrganization": { - "identifier": { - "system": self.ODS_SYSTEM, - "value": org_ods, - } - }, - "identifier": [ - { - "system": self.ASID_SYSTEM, - "value": asid, - }, - { - "system": self.PARTYKEY_SYSTEM, - "value": party_key, - }, - ], - } + @property + def get_timeout(self) -> int | None: + return self._last_timeout # --------------------------- # Public API for tests @@ -490,6 +348,11 @@ def get( :param timeout: Timeout value. :return: A :class:`requests.Response`. """ + self._last_url = url + self._last_headers = headers + self._last_params = params + self._last_timeout = timeout + if "/Endpoint" in url: return self.get_endpoint_bundle( url=url, headers=headers, params=params, timeout=timeout @@ -498,28 +361,173 @@ def get( url=url, headers=headers, params=params, timeout=timeout ) - def post( - self, - url: str, - headers: dict[str, Any], - data: Any, - timeout: int, - ) -> Response: - """ - Handle HTTP POST requests for the stub. - - :param url: Request URL. - :param headers: Request headers. - :param data: Request body data. - :param timeout: Request timeout in seconds. - :raises NotImplementedError: POST requests are not supported by this stub. - """ - raise NotImplementedError("POST requests are not supported by SdsFhirApiStub") - # --------------------------- # Internal helpers # --------------------------- + def _seed_default_devices(self) -> None: + """Seed the stub with some default Device records for testing.""" + # Define test device data as a list of parameters + device_data = [ + { + "org_ods": "PROVIDER", + "party_key": "PROVIDER-0000806", + "device_id": "F0F0E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_PROV", + "display": "Example NHS Trust", + }, + { + "org_ods": "CONSUMER", + "party_key": "CONSUMER-0000807", + "device_id": "C0C0E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_CONS", + "display": "Example Consumer Organisation", + }, + { + "org_ods": "A12345", + "party_key": "A12345-0000808", + "device_id": "A1A1E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_A12345", + "display": "Example GP Practice A12345", + }, + ] + + # Iterate through test data and create devices + for data in device_data: + self.upsert_device( + organization_ods=data["org_ods"], + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key=data["party_key"], + device=self._create_device_resource( + device_id=data["device_id"], + asid=data["asid"], + party_key=data["party_key"], + org_ods=data["org_ods"], + display=data["display"], + ), + ) + + def _seed_default_endpoints(self) -> None: + """Seed the stub with some default Endpoint records for testing.""" + # Define test endpoint data as a list of parameters + endpoint_data = [ + { + "org_ods": "PROVIDER", + "party_key": "PROVIDER-0000806", + "endpoint_id": "E0E0E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_PROV", + "address": "https://provider.example.com/fhir", + }, + { + "org_ods": "CONSUMER", + "party_key": "CONSUMER-0000807", + "endpoint_id": "E1E1E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_CONS", + "address": "https://consumer.example.com/fhir", + }, + { + "org_ods": "A12345", + "party_key": "A12345-0000808", + "endpoint_id": "E2E2E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_A12345", + "address": "https://a12345.example.com/fhir", + }, + ] + + # Iterate through test data and create endpoints + for data in endpoint_data: + self.upsert_endpoint( + organization_ods=data["org_ods"], + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key=data["party_key"], + endpoint=self._create_endpoint_resource( + endpoint_id=data["endpoint_id"], + asid=data["asid"], + party_key=data["party_key"], + org_ods=data["org_ods"], + address=data["address"], + ), + ) + + def _create_device_resource( + self, + device_id: str, + asid: str, + party_key: str, + org_ods: str, + display: str, + ) -> dict[str, Any]: + """Create a Device resource dictionary with the given parameters.""" + return { + "resourceType": "Device", + "id": device_id, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": asid, + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": party_key, + }, + ], + "owner": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": org_ods, + }, + "display": display, + }, + } + + def _create_endpoint_resource( + self, + endpoint_id: str, + asid: str, + party_key: str, + org_ods: str, + address: str, + ) -> dict[str, Any]: + """Create an Endpoint resource dictionary with the given parameters.""" + return { + "resourceType": "Endpoint", + "id": endpoint_id, + "status": "active", + "connectionType": { + "system": self.CONNECTION_SYSTEM, + "code": "hl7-fhir-rest", + "display": self.CONNECTION_DISPLAY, + }, + "payloadType": [ + { + "coding": [ + { + "system": self.CODING_SYSTEM, + "code": "any", + "display": "Any", + } + ] + } + ], + "address": address, + "managingOrganization": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": org_ods, + } + }, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": asid, + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": party_key, + }, + ], + } + def _lookup_devices( self, org_ods: str, service_interaction_id: str, party_key: str | None ) -> list[dict[str, Any]]: diff --git a/infrastructure/images/build-container/Dockerfile b/infrastructure/images/build-container/Dockerfile index e062232e..d81993c5 100644 --- a/infrastructure/images/build-container/Dockerfile +++ b/infrastructure/images/build-container/Dockerfile @@ -147,6 +147,12 @@ RUN bash -c "source ~/.bashrc && pyenv virtualenv ${PYTHON_VERSION} gateway" && # Change default shell to bash for gateway-dev user. && chsh -s /bin/bash gateway-dev +# Configure external services to use stubs +# To disable, comment these out - if they're set at all, it'll use the stub +ENV STUB_SDS=1 +ENV STUB_PDS=1 +ENV STUB_PROVIDER=1 + # Update gateway-dev user's bash configuration. COPY /resources/.bashrc /home/gateway-dev/.bashrc diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 54824a4b..001ba448 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -13,6 +13,12 @@ ENV PYTHONPATH=/resources/build/gateway-api ENV FLASK_HOST="0.0.0.0" ENV FLASK_PORT="8080" +# Configure external services to use stubs +# To disable, comment these out - if they're set at all, it'll use the stub +ENV STUB_SDS=1 +ENV STUB_PDS=1 +ENV STUB_PROVIDER=1 + ARG COMMIT_VERSION ENV COMMIT_VERSION=$COMMIT_VERSION ARG BUILD_DATE From 08f8d6bc5e58f269600264b37ff1a8b064ca4731 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:20:40 +0000 Subject: [PATCH 16/22] Address review comments --- gateway-api/src/gateway_api/controller.py | 8 +- .../src/gateway_api/provider_request.py | 4 +- gateway-api/src/gateway_api/sds_search.py | 6 +- .../src/gateway_api/test_sds_search.py | 141 +++++++----------- 4 files changed, 65 insertions(+), 94 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index f186bcb2..c7fa12f3 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -210,7 +210,9 @@ def _get_sds_details( timeout=self.timeout, ) - provider_details: SdsSearchResults | None = sds.get_org_details(provider_ods) + provider_details: SdsSearchResults | None = sds.get_org_details( + provider_ods, get_endpoint=True + ) if provider_details is None: raise RequestError( status_code=404, @@ -238,7 +240,9 @@ def _get_sds_details( ) # SDS: Get consumer details (ASID) for consumer ODS - consumer_details: SdsSearchResults | None = sds.get_org_details(consumer_ods) + consumer_details: SdsSearchResults | None = sds.get_org_details( + consumer_ods, get_endpoint=False + ) if consumer_details is None: raise RequestError( status_code=404, diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index 31420b1d..6d555a40 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -29,7 +29,7 @@ from stubs.stub_provider import GpProviderStub from gateway_api.common.common import ( - ACCESS_RECORD_STRUCTURED_INTERACTION_ID as ARS_INTERACTION_ID, + ACCESS_RECORD_STRUCTURED_INTERACTION_ID, ) ARS_FHIR_BASE = "FHIR/STU3" @@ -95,7 +95,7 @@ def _build_headers(self, trace_id: str) -> dict[str, str]: return { "Content-Type": "application/fhir+json", "Accept": "application/fhir+json", - "Ssp-InteractionID": ARS_INTERACTION_ID, + "Ssp-InteractionID": ACCESS_RECORD_STRUCTURED_INTERACTION_ID, "Ssp-To": self.provider_asid, "Ssp-From": self.consumer_asid, "Ssp-TraceID": trace_id, diff --git a/gateway-api/src/gateway_api/sds_search.py b/gateway-api/src/gateway_api/sds_search.py index 01f147e5..e9c08377 100644 --- a/gateway-api/src/gateway_api/sds_search.py +++ b/gateway-api/src/gateway_api/sds_search.py @@ -152,6 +152,7 @@ def get_org_details( ods_code: str, correlation_id: str | None = None, timeout: int | None = None, + get_endpoint: bool = True, ) -> SdsSearchResults | None: """ Retrieve ASID and endpoint for an organization by ODS code. @@ -164,6 +165,8 @@ def get_org_details( :param correlation_id: Optional correlation ID for tracing. :param timeout: Optional per-call timeout in seconds. If not provided, :attr:`timeout` is used. + :param get_endpoint: Whether to perform the second query to retrieve the + endpoint URL. :return: A :class:`SdsSearchResults` instance if data can be extracted, otherwise ``None``. :raises ExternalServiceError: If the HTTP request returns an error status. @@ -185,7 +188,7 @@ def get_org_details( # Step 2: Get Endpoint to obtain endpoint URL endpoint_url: str | None = None - if party_key: + if get_endpoint: endpoint_bundle = self._query_sds( ods_code=ods_code, party_key=party_key, @@ -258,6 +261,7 @@ def _extract_first_entry( # TODO: Post-steel-thread handle case where bundle contains no entries + # TODO: consider business logic for handling multiple entries in beta first_entry = entries[0] return cast("ResultStructureDict", first_entry.get("resource", {})) diff --git a/gateway-api/src/gateway_api/test_sds_search.py b/gateway-api/src/gateway_api/test_sds_search.py index ce0fe33c..2cdaf19f 100644 --- a/gateway-api/src/gateway_api/test_sds_search.py +++ b/gateway-api/src/gateway_api/test_sds_search.py @@ -44,7 +44,7 @@ def test_sds_client_get_org_details_success( assert result is not None assert isinstance(result, SdsSearchResults) assert result.asid == "asid_PROV" - assert result.endpoint is not None + assert result.endpoint == "https://provider.example.com/fhir" def test_sds_client_get_org_details_with_endpoint( @@ -57,7 +57,7 @@ def test_sds_client_get_org_details_with_endpoint( :param mock_requests_get: Capture fixture for request details. """ - # Add a device with party key so we can get an endpoint + # Add a device so we can get an endpoint stub.upsert_device( organization_ods="TESTORG", service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, @@ -116,79 +116,23 @@ def test_sds_client_get_org_details_with_endpoint( assert result.endpoint == "https://testorg.example.com/fhir" -def test_sds_client_get_org_details_no_endpoint( +def test_sds_client_sends_correct_headers( stub: SdsFhirApiStub, ) -> None: """ - Test SdsClient handles missing endpoint gracefully. + Test that SdsClient sends X-Correlation-Id and apikey headers when provided. :param stub: SDS stub fixture. :param mock_requests_get: Capture fixture for request details. """ - # Add a device without a party key (so no endpoint will be found) - stub.upsert_device( - organization_ods="NOENDPOINT", - service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, - party_key=None, - device={ - "resourceType": "Device", - "id": "noendpoint-device-id", - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhsSpineASID", - "value": "888888888888", - } - ], - "owner": { - "identifier": { - "system": "https://fhir.nhs.uk/Id/ods-organization-code", - "value": "NOENDPOINT", - } - }, - }, - ) - - client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) - result = client.get_org_details(ods_code="NOENDPOINT") - - assert result is not None - assert result.asid == "888888888888" - assert result.endpoint is None - - -def test_sds_client_sends_correlation_id( - stub: SdsFhirApiStub, # noqa: ARG001 -) -> None: - """ - Test that SdsClient sends X-Correlation-Id header when provided. - - :param stub: SDS stub fixture. - :param mock_requests_get: Capture fixture for request details. - """ - client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) + api_key = "my-secret-key" + client = SdsClient(api_key=api_key, base_url=SdsClient.SANDBOX_URL) correlation_id = "test-correlation-123" client.get_org_details(ods_code="PROVIDER", correlation_id=correlation_id) - # Check that the header was sent + # Check that the headers were assert stub.get_headers["X-Correlation-Id"] == correlation_id - - -def test_sds_client_sends_apikey( - stub: SdsFhirApiStub, # noqa: ARG001 -) -> None: - """ - Test that SdsClient sends apikey header. - - :param stub: SDS stub fixture. - :param mock_requests_get: Capture fixture for request details. - """ - api_key = "my-secret-key" - client = SdsClient(api_key=api_key, base_url=SdsClient.SANDBOX_URL) - - client.get_org_details(ods_code="PROVIDER") - - # Check that the apikey header was sent assert stub.get_headers["apikey"] == api_key @@ -249,7 +193,7 @@ def test_sds_client_custom_service_interaction_id( service_interaction_id=custom_interaction, ) - result = client.get_org_details(ods_code="CUSTOMINT") + result = client.get_org_details(ods_code="CUSTOMINT", get_endpoint=False) # Verify the custom interaction was used params = stub.get_params @@ -263,7 +207,7 @@ def test_sds_client_custom_service_interaction_id( def test_sds_client_builds_correct_device_query_params( - stub: SdsFhirApiStub, # noqa: ARG001 + stub: SdsFhirApiStub, ) -> None: """ Test that SdsClient builds Device query parameters correctly. @@ -292,26 +236,8 @@ def test_sds_client_builds_correct_device_query_params( ) -def test_sds_client_extract_asid_from_device( - stub: SdsFhirApiStub, # noqa: ARG001 -) -> None: - """ - Test ASID extraction from Device resource. - - :param stub: SDS stub fixture. - :param mock_requests_get: Capture fixture for request details. - """ - client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) - - result = client.get_org_details(ods_code="PROVIDER") - - assert result is not None - assert result.asid is not None - assert result.asid == "asid_PROV" - - def test_sds_client_extract_party_key_from_device( - stub: SdsFhirApiStub, # noqa: ARG001 + stub: SdsFhirApiStub, ) -> None: """ Test party key extraction and subsequent endpoint lookup. @@ -319,13 +245,50 @@ def test_sds_client_extract_party_key_from_device( :param stub: SDS stub fixture. :param mock_requests_get: Capture fixture for request details. """ - # The default seeded PROVIDER device has a party key, which should trigger - # an endpoint lookup + # The default seeded PROVIDER device has a party key client = SdsClient(api_key="test-key", base_url=SdsClient.SANDBOX_URL) - # Need to seed the data correctly - let's use CONSUMER which has party key - result = client.get_org_details(ods_code="CONSUMER") + stub.upsert_device( + organization_ods="WITHPARTYKEY", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key="WITHPARTYKEY-654321", + device={ + "resourceType": "Device", + "id": "device-with-party-key", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "888888888888", + }, + { + "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", + "value": "WITHPARTYKEY-654321", + }, + ], + }, + ) + + stub.upsert_endpoint( + organization_ods="WITHPARTYKEY", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key="WITHPARTYKEY-654321", + endpoint={ + "resourceType": "Endpoint", + "id": "endpoint-for-party-key", + "status": "active", + "address": "https://withpartykey.example.com/fhir", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", + "value": "WITHPARTYKEY-654321", + } + ], + }, + ) + + result = client.get_org_details(ods_code="WITHPARTYKEY", get_endpoint=True) # Should have found ASID but may not have endpoint depending on seeding assert result is not None - assert result.asid == "asid_CONS" + assert result.asid == "888888888888" + assert result.endpoint == "https://withpartykey.example.com/fhir" From db8aa1c2bf7f74ba07475e2db0461e714189b480 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:26:32 +0000 Subject: [PATCH 17/22] Fix test fake for updated signature --- gateway-api/src/gateway_api/test_controller.py | 6 +++++- gateway-api/src/gateway_api/test_sds_search.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index e16e44a5..0e20485a 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -97,7 +97,11 @@ def set_org_details( ) -> None: self._org_details_by_ods[ods_code] = org_details - def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + def get_org_details( + self, + ods_code: str, + get_endpoint: bool = True, # NOQA ARG002 (unused in fake), + ) -> SdsSearchResults | None: return self._org_details_by_ods.get(ods_code) diff --git a/gateway-api/src/gateway_api/test_sds_search.py b/gateway-api/src/gateway_api/test_sds_search.py index 2cdaf19f..c9fd17a5 100644 --- a/gateway-api/src/gateway_api/test_sds_search.py +++ b/gateway-api/src/gateway_api/test_sds_search.py @@ -137,7 +137,7 @@ def test_sds_client_sends_correct_headers( def test_sds_client_timeout_parameter( - stub: SdsFhirApiStub, # noqa: ARG001 + stub: SdsFhirApiStub, ) -> None: """ Test that SdsClient passes timeout parameter to requests. From bce9d50f5792a8dc09accfc1c1067bc09bb7ed42 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:29:22 +0000 Subject: [PATCH 18/22] Remove unnecessary mocking --- .../tests/integration/test_sds_search.py | 44 ++++--------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/gateway-api/tests/integration/test_sds_search.py b/gateway-api/tests/integration/test_sds_search.py index c12623d2..5d05ba91 100644 --- a/gateway-api/tests/integration/test_sds_search.py +++ b/gateway-api/tests/integration/test_sds_search.py @@ -2,46 +2,21 @@ from __future__ import annotations -import pytest from gateway_api.sds_search import SdsClient, SdsSearchResults -from stubs.stub_sds import SdsFhirApiStub - -@pytest.fixture -def sds_stub() -> SdsFhirApiStub: - """ - Create and return an SDS stub instance with default seeded data. - - :return: SdsFhirApiStub instance with PROVIDER and CONSUMER organizations. - """ - return SdsFhirApiStub() - - -@pytest.fixture -def sds_client(sds_stub: SdsFhirApiStub) -> SdsClient: - """ - Create an SdsClient configured to use the stub. - - :param sds_stub: SDS stub fixture. - :return: SdsClient configured with test stub. - """ - client = SdsClient(api_key="test-integration-key", base_url="http://stub") - # Override the get_method to use the stub - client.get_method = sds_stub.get - return client +API_KEY = "test-integration-key" class TestSdsIntegration: """Integration tests for SDS search operations.""" - def test_get_device_by_ods_code_returns_valid_asid( - self, sds_client: SdsClient - ) -> None: + def test_get_device_by_ods_code_returns_valid_asid(self) -> None: """ Test that querying by ODS code returns a valid ASID. :param sds_client: SDS client fixture configured with stub. """ + sds_client = SdsClient(api_key=API_KEY) result = sds_client.get_org_details(ods_code="PROVIDER") assert result is not None @@ -50,14 +25,13 @@ def test_get_device_by_ods_code_returns_valid_asid( assert result.asid == "asid_PROV" assert len(result.asid) > 0 - def test_get_device_with_party_key_returns_endpoint( - self, sds_client: SdsClient - ) -> None: + def test_get_device_with_party_key_returns_endpoint(self) -> None: """ Test that a device with party key returns both ASID and endpoint. :param sds_client: SDS client fixture configured with stub. """ + sds_client = SdsClient(api_key=API_KEY) result = sds_client.get_org_details(ods_code="PROVIDER") assert result is not None @@ -68,12 +42,13 @@ def test_get_device_with_party_key_returns_endpoint( assert result.endpoint.startswith("https://") assert "fhir" in result.endpoint - def test_consumer_organization_lookup(self, sds_client: SdsClient) -> None: + def test_consumer_organization_lookup(self) -> None: """ Test that CONSUMER organization can be looked up successfully. :param sds_client: SDS client fixture configured with stub. """ + sds_client = SdsClient(api_key=API_KEY) result = sds_client.get_org_details(ods_code="CONSUMER") assert result is not None @@ -81,14 +56,13 @@ def test_consumer_organization_lookup(self, sds_client: SdsClient) -> None: assert result.endpoint is not None assert result.endpoint == "https://consumer.example.com/fhir" - def test_result_contains_both_asid_and_endpoint_when_available( - self, sds_client: SdsClient - ) -> None: + def test_result_contains_both_asid_and_endpoint_when_available(self) -> None: """ Test that results contain both ASID and endpoint when both are available. :param sds_client: SDS client fixture configured with stub. """ + sds_client = SdsClient(api_key=API_KEY) result = sds_client.get_org_details(ods_code="PROVIDER") assert result is not None From ff9623a8775e2dba5ade22e4f5e65b68208389d2 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:58:09 +0000 Subject: [PATCH 19/22] Mock requests.get as well as the stub --- gateway-api/src/gateway_api/test_sds_search.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gateway-api/src/gateway_api/test_sds_search.py b/gateway-api/src/gateway_api/test_sds_search.py index c9fd17a5..61c81903 100644 --- a/gateway-api/src/gateway_api/test_sds_search.py +++ b/gateway-api/src/gateway_api/test_sds_search.py @@ -24,6 +24,7 @@ def stub(monkeypatch: pytest.MonkeyPatch) -> SdsFhirApiStub: "SdsFhirApiStub", lambda *args, **kwargs: stub, # NOQA ARG005 (maintain signature) ) + monkeypatch.setattr("requests.get", stub.get) return stub From e6cf45de3015d696f14b554f63ff4b48d0617b48 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:05:41 +0000 Subject: [PATCH 20/22] Add check for default interaction ID --- gateway-api/src/gateway_api/test_sds_search.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gateway-api/src/gateway_api/test_sds_search.py b/gateway-api/src/gateway_api/test_sds_search.py index 61c81903..aee3f9ab 100644 --- a/gateway-api/src/gateway_api/test_sds_search.py +++ b/gateway-api/src/gateway_api/test_sds_search.py @@ -47,6 +47,12 @@ def test_sds_client_get_org_details_success( assert result.asid == "asid_PROV" assert result.endpoint == "https://provider.example.com/fhir" + params = stub.get_params + assert any( + ACCESS_RECORD_STRUCTURED_INTERACTION_ID in str(ident) + for ident in params.get("identifier", []) + ) + def test_sds_client_get_org_details_with_endpoint( stub: SdsFhirApiStub, From 06920d2340877c1d03525c24a149107819f5c55f Mon Sep 17 00:00:00 2001 From: neil-sproston Date: Fri, 13 Feb 2026 15:06:43 +0000 Subject: [PATCH 21/22] Set terraform version pinning --- .tool-versions | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.tool-versions b/.tool-versions index 253dc21c..7d8d4e9b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,6 +1,6 @@ # This file is for you! Please, updated to the versions agreed by your team. -terraform 1.14.0 +terraform 1.14.5 pre-commit 3.6.0 gitleaks 8.18.4 @@ -15,7 +15,7 @@ gitleaks 8.18.4 # docker/ghcr.io/make-ops-tools/gocloc latest@sha256:6888e62e9ae693c4ebcfed9f1d86c70fd083868acb8815fe44b561b9a73b5032 # SEE: https://github.com/make-ops-tools/gocloc/pkgs/container/gocloc # docker/ghcr.io/nhs-england-tools/github-runner-image 20230909-321fd1e-rt@sha256:ce4fd6035dc450a50d3cbafb4986d60e77cb49a71ab60a053bb1b9518139a646 # SEE: https://github.com/nhs-england-tools/github-runner-image/pkgs/container/github-runner-image # docker/hadolint/hadolint 2.12.0-alpine@sha256:7dba9a9f1a0350f6d021fb2f6f88900998a4fb0aaf8e4330aa8c38544f04db42 # SEE: https://hub.docker.com/r/hadolint/hadolint/tags -# docker/hashicorp/terraform 1.12.2@sha256:b3d13c9037d2bd858fe10060999aa7ca56d30daafe067d7715b29b3d4f5b162f # SEE: https://hub.docker.com/r/hashicorp/terraform/tags +docker/hashicorp/terraform 1.14.5@sha256:96d2bc440714bf2b2f2998ac730fd4612f30746df43fca6f0892b2e2035b11bc # SEE: https://hub.docker.com/r/hashicorp/terraform/tags # docker/koalaman/shellcheck latest@sha256:e40388688bae0fcffdddb7e4dea49b900c18933b452add0930654b2dea3e7d5c # SEE: https://hub.docker.com/r/koalaman/shellcheck/tags # docker/mstruebing/editorconfig-checker 2.7.1@sha256:dd3ca9ea50ef4518efe9be018d669ef9cf937f6bb5cfe2ef84ff2a620b5ddc24 # SEE: https://hub.docker.com/r/mstruebing/editorconfig-checker/tags # docker/sonarsource/sonar-scanner-cli 10.0@sha256:0bc49076468d2955948867620b2d98d67f0d59c0fd4a5ef1f0afc55cf86f2079 # SEE: https://hub.docker.com/r/sonarsource/sonar-scanner-cli/tags From a53e4beca3c21a272d3369a5d2eb6f38a68d28c0 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:47:00 +0000 Subject: [PATCH 22/22] Fix container build --- .tool-versions | 2 +- gateway-api/tests/integration/test_sds_search.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index 7d8d4e9b..fcbb5c74 100644 --- a/.tool-versions +++ b/.tool-versions @@ -15,7 +15,7 @@ gitleaks 8.18.4 # docker/ghcr.io/make-ops-tools/gocloc latest@sha256:6888e62e9ae693c4ebcfed9f1d86c70fd083868acb8815fe44b561b9a73b5032 # SEE: https://github.com/make-ops-tools/gocloc/pkgs/container/gocloc # docker/ghcr.io/nhs-england-tools/github-runner-image 20230909-321fd1e-rt@sha256:ce4fd6035dc450a50d3cbafb4986d60e77cb49a71ab60a053bb1b9518139a646 # SEE: https://github.com/nhs-england-tools/github-runner-image/pkgs/container/github-runner-image # docker/hadolint/hadolint 2.12.0-alpine@sha256:7dba9a9f1a0350f6d021fb2f6f88900998a4fb0aaf8e4330aa8c38544f04db42 # SEE: https://hub.docker.com/r/hadolint/hadolint/tags -docker/hashicorp/terraform 1.14.5@sha256:96d2bc440714bf2b2f2998ac730fd4612f30746df43fca6f0892b2e2035b11bc # SEE: https://hub.docker.com/r/hashicorp/terraform/tags +# docker/hashicorp/terraform 1.14.5@sha256:96d2bc440714bf2b2f2998ac730fd4612f30746df43fca6f0892b2e2035b11bc # SEE: https://hub.docker.com/r/hashicorp/terraform/tags # docker/koalaman/shellcheck latest@sha256:e40388688bae0fcffdddb7e4dea49b900c18933b452add0930654b2dea3e7d5c # SEE: https://hub.docker.com/r/koalaman/shellcheck/tags # docker/mstruebing/editorconfig-checker 2.7.1@sha256:dd3ca9ea50ef4518efe9be018d669ef9cf937f6bb5cfe2ef84ff2a620b5ddc24 # SEE: https://hub.docker.com/r/mstruebing/editorconfig-checker/tags # docker/sonarsource/sonar-scanner-cli 10.0@sha256:0bc49076468d2955948867620b2d98d67f0d59c0fd4a5ef1f0afc55cf86f2079 # SEE: https://hub.docker.com/r/sonarsource/sonar-scanner-cli/tags diff --git a/gateway-api/tests/integration/test_sds_search.py b/gateway-api/tests/integration/test_sds_search.py index 5d05ba91..5217887d 100644 --- a/gateway-api/tests/integration/test_sds_search.py +++ b/gateway-api/tests/integration/test_sds_search.py @@ -62,6 +62,9 @@ def test_result_contains_both_asid_and_endpoint_when_available(self) -> None: :param sds_client: SDS client fixture configured with stub. """ + import os + + print(f"Stub env var: {os.environ.get('STUB_SDS')}") sds_client = SdsClient(api_key=API_KEY) result = sds_client.get_org_details(ods_code="PROVIDER")