From 9cfbcfb7fb065b1d8b47755a3db816e5bd9ecdaa 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/14] 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 b58fc28482387f8f5850b6be6e86c4f36c20788b 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/14] 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 58a1217d66ab5fc653ec4b7562b56fabbc4552db 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/14] 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 915230aa37ea37507b948de603ad381d0d200088 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/14] 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 1993b688e64f239086b7e8dc89f3745cc864969b 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/14] 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 87a129566fce64c6b82b6ddada8e01d708bf004a 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/14] 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 ec7331bdd6b3d6b0e84f942a6e5ef8430e6f5e66 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/14] 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 5c1dc0c664d4b038add40fdae805dc97479a0333 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/14] 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 f06aa4ad4357fc75144ea9eaa5da87302062c35f 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/14] 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 0c0ee47902058ff4328ef9d8860a6023a4f43e67 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/14] 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 31a7492fec1a6af4974efc3f569ed406522c447f 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/14] 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 9903dfe3e90ca996c9b7b7a4dd030f5388ad8a98 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/14] 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 630ef3989e476bf6e66b898c256c9395fc942d82 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/14] 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 049cddf9e33bd5b37798b1696eaf70d2ac977edc 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/14] 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.