From e9ac116ca1f90299c1653cd5c4ded63c2b116df7 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:39:12 +0000 Subject: [PATCH 01/14] Add pyjwt, clinical_jwt, make ruff happy --- gateway-api/poetry.lock | 24 +++++- gateway-api/pyproject.toml | 1 + gateway-api/src/fhir/bundle.py | 5 +- gateway-api/src/fhir/parameters.py | 5 +- gateway-api/src/fhir/patient.py | 9 ++- .../src/gateway_api/clinical_jwt/__init__.py | 3 + .../src/gateway_api/clinical_jwt/jwt.py | 74 +++++++++++++++++++ gateway-api/src/gateway_api/conftest.py | 7 +- .../get_structured_record/request.py | 2 + .../get_structured_record/test_request.py | 2 +- .../src/gateway_api/provider/test_client.py | 4 + gateway-api/stubs/stubs/provider/stub.py | 5 +- gateway-api/tests/acceptance/conftest.py | 6 +- gateway-api/tests/conftest.py | 6 +- .../tests/schema/test_openapi_schema.py | 2 +- ruff.toml | 2 + 16 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 gateway-api/src/gateway_api/clinical_jwt/__init__.py create mode 100644 gateway-api/src/gateway_api/clinical_jwt/jwt.py diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 3c5a3418..2a0bda48 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "anyio" @@ -753,7 +753,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format\""} idna = {version = "*", optional = true, markers = "extra == \"format\""} isoduration = {version = "*", optional = true, markers = "extra == \"format\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format\""} -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format\""} rfc3987 = {version = "*", optional = true, markers = "extra == \"format\""} @@ -1494,6 +1494,24 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.11.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"}, + {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] + [[package]] name = "pyrate-limiter" version = "3.9.0" @@ -2425,4 +2443,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "a452bd22e2386a3ff58b4c7a5ac2cb571de9e3d49a4fbc161ffd3aafa2a7bf44" +content-hash = "5318997004e2751a99885d15bff9904a8db740172217dab4870807d0884c86ec" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index a841d21e..55b8d06d 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -13,6 +13,7 @@ clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-comm flask = "^3.1.2" types-flask = "^1.1.6" requests = "^2.32.5" +pyjwt = "^2.11.0" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py index 5fbc9a3b..b041d483 100644 --- a/gateway-api/src/fhir/bundle.py +++ b/gateway-api/src/fhir/bundle.py @@ -1,8 +1,9 @@ """FHIR Bundle resource.""" -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict -from fhir.patient import Patient +if TYPE_CHECKING: + from fhir.patient import Patient class BundleEntry(TypedDict): diff --git a/gateway-api/src/fhir/parameters.py b/gateway-api/src/fhir/parameters.py index 30b7cce8..abac7ebb 100644 --- a/gateway-api/src/fhir/parameters.py +++ b/gateway-api/src/fhir/parameters.py @@ -1,8 +1,9 @@ """FHIR Parameters resource.""" -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict -from fhir.identifier import Identifier +if TYPE_CHECKING: + from fhir.identifier import Identifier class Parameter(TypedDict): diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py index 453a6f2a..0386e46c 100644 --- a/gateway-api/src/fhir/patient.py +++ b/gateway-api/src/fhir/patient.py @@ -1,10 +1,11 @@ """FHIR Patient resource.""" -from typing import NotRequired, TypedDict +from typing import TYPE_CHECKING, TypedDict, NotRequired -from fhir.general_practitioner import GeneralPractitioner -from fhir.human_name import HumanName -from fhir.identifier import Identifier +if TYPE_CHECKING: + from fhir.general_practitioner import GeneralPractitioner + from fhir.human_name import HumanName + from fhir.identifier import Identifier class Patient(TypedDict): diff --git a/gateway-api/src/gateway_api/clinical_jwt/__init__.py b/gateway-api/src/gateway_api/clinical_jwt/__init__.py new file mode 100644 index 00000000..0088a3e4 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/__init__.py @@ -0,0 +1,3 @@ +from .jwt import JWT + +__all__ = ["JWT"] diff --git a/gateway-api/src/gateway_api/clinical_jwt/jwt.py b/gateway-api/src/gateway_api/clinical_jwt/jwt.py new file mode 100644 index 00000000..ffcd4bd0 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/jwt.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from time import time +from typing import Any + +import jwt as pyjwt + + +@dataclass(frozen=True, kw_only=True) +class JWT: + issuer: str + subject: str + audience: str + requesting_device: str + requesting_organization: str + requesting_practitioner: str + + # Time fields + issued_at: int = field(default_factory=lambda: int(time())) + expiration: int = field(default_factory=lambda: int(time()) + 300) + + # These are here for future proofing but are not expected ever to be changed + algorithm: str | None = None + type: str = "JWT" + reason_for_request: str = "directcare" + requested_scope: str = "patient/*.read" + + @property + def issue_time(self) -> str: + return datetime.fromtimestamp(self.issued_at, tz=UTC).isoformat() + + @property + def exp_time(self) -> str: + return datetime.fromtimestamp(self.expiration, tz=UTC).isoformat() + + def encode(self) -> str: + return pyjwt.encode( + self.payload(), + key=None, + algorithm=self.algorithm, + headers={"typ": self.type}, + ) + + @staticmethod + def decode(token: str) -> JWT: + token_dict = pyjwt.decode( + token, + options={"verify_signature": False}, # NOSONAR S5659 (not signed) + ) + + return JWT( + issuer=token_dict["iss"], + subject=token_dict["sub"], + audience=token_dict["aud"], + expiration=token_dict["exp"], + issued_at=token_dict["iat"], + requesting_device=token_dict["requesting_device"], + requesting_organization=token_dict["requesting_organization"], + requesting_practitioner=token_dict["requesting_practitioner"], + ) + + def payload(self) -> dict[str, Any]: + return { + "iss": self.issuer, + "sub": self.subject, + "aud": self.audience, + "exp": self.expiration, + "iat": self.issued_at, + "requesting_device": self.requesting_device, + "requesting_organization": self.requesting_organization, + "requesting_practitioner": self.requesting_practitioner, + "reason_for_request": self.reason_for_request, + "requested_scope": self.requested_scope, + } diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 65e3c779..9204846e 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -2,15 +2,18 @@ import json from dataclasses import dataclass -from typing import Any +from typing import Any, TYPE_CHECKING import pytest import requests -from fhir import Bundle, OperationOutcome, Parameters, Patient +from fhir import Bundle, OperationOutcome, Patient from flask import Request from requests.structures import CaseInsensitiveDict from werkzeug.test import EnvironBuilder +if TYPE_CHECKING: + from fhir.parameters import Parameters + @dataclass class FakeResponse: 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 94e8a23d..ff152b98 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -12,6 +12,8 @@ if TYPE_CHECKING: from fhir.bundle import Bundle + from gateway_api.common.common import FlaskResponse + # Access record structured interaction ID from # https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions ACCESS_RECORD_STRUCTURED_INTERACTION_ID = ( diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py index 34498655..c6f6fcf2 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING, cast import pytest -from fhir.parameters import Parameters from flask import Request from gateway_api.common.common import FlaskResponse @@ -12,6 +11,7 @@ if TYPE_CHECKING: from fhir.bundle import Bundle + from fhir.parameters import Parameters @pytest.fixture diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 98b4a118..e1988da0 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -18,6 +18,10 @@ from gateway_api.common.error import ProviderRequestFailedError from gateway_api.provider import GpProviderClient, client +if TYPE_CHECKING: + from requests import Response + from requests.structures import CaseInsensitiveDict + @pytest.fixture def stub() -> GpProviderStub: diff --git a/gateway-api/stubs/stubs/provider/stub.py b/gateway-api/stubs/stubs/provider/stub.py index 0e157505..3932cd95 100644 --- a/gateway-api/stubs/stubs/provider/stub.py +++ b/gateway-api/stubs/stubs/provider/stub.py @@ -24,11 +24,12 @@ import json from typing import Any -from requests import Response - from stubs.base_stub import StubBase from stubs.data.bundles import Bundles +if TYPE_CHECKING: + from requests import Response + class GpProviderStub(StubBase): """ diff --git a/gateway-api/tests/acceptance/conftest.py b/gateway-api/tests/acceptance/conftest.py index 6fd4da3c..caeae1cd 100644 --- a/gateway-api/tests/acceptance/conftest.py +++ b/gateway-api/tests/acceptance/conftest.py @@ -1,5 +1,9 @@ +from typing import TYPE_CHECKING + import pytest -import requests + +if TYPE_CHECKING: + import requests class ResponseContext: diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index 00783a77..0c80696f 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -2,12 +2,14 @@ import os from datetime import timedelta -from typing import cast +from typing import TYPE_CHECKING, cast import pytest import requests from dotenv import find_dotenv, load_dotenv -from fhir.parameters import Parameters + +if TYPE_CHECKING: + from fhir.parameters import Parameters # Load environment variables from .env file in the workspace root # find_dotenv searches upward from current directory for .env file diff --git a/gateway-api/tests/schema/test_openapi_schema.py b/gateway-api/tests/schema/test_openapi_schema.py index 5e83b004..199da70a 100644 --- a/gateway-api/tests/schema/test_openapi_schema.py +++ b/gateway-api/tests/schema/test_openapi_schema.py @@ -8,7 +8,7 @@ import schemathesis import yaml -from schemathesis.generation.case import Case +from schemathesis.generation.case import Case # NOQA TC002 (Is needed) from schemathesis.openapi import from_dict # Load the OpenAPI schema from the local file diff --git a/ruff.toml b/ruff.toml index db28865d..09a8e9cd 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,3 +1,5 @@ +target-version = "py314" + [lint] select = [ # Standard configuration taken from https://docs.astral.sh/ruff/linter/#rule-selection. From f1611c7ccfceea09f0e02b4d25068d73d2b2a1db Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:12:59 +0000 Subject: [PATCH 02/14] Include JWT with provider request --- .../src/gateway_api/clinical_jwt/__init__.py | 4 +- .../src/gateway_api/clinical_jwt/device.py | 29 +++++++++++ .../gateway_api/clinical_jwt/practitioner.py | 49 +++++++++++++++++++ gateway-api/src/gateway_api/controller.py | 48 +++++++++++++++++- .../src/gateway_api/provider/client.py | 9 ++-- 5 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 gateway-api/src/gateway_api/clinical_jwt/device.py create mode 100644 gateway-api/src/gateway_api/clinical_jwt/practitioner.py diff --git a/gateway-api/src/gateway_api/clinical_jwt/__init__.py b/gateway-api/src/gateway_api/clinical_jwt/__init__.py index 0088a3e4..5b6effaf 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/__init__.py +++ b/gateway-api/src/gateway_api/clinical_jwt/__init__.py @@ -1,3 +1,5 @@ +from .device import Device from .jwt import JWT +from .practitioner import Practitioner -__all__ = ["JWT"] +__all__ = ["JWT", "Device", "Practitioner"] diff --git a/gateway-api/src/gateway_api/clinical_jwt/device.py b/gateway-api/src/gateway_api/clinical_jwt/device.py new file mode 100644 index 00000000..5c6b9b2f --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/device.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class Device: + system: str + value: str + model: str + version: str + + @property + def json(self) -> str: + outstr = f""" + "requesting_device": {{ + "resourceType": "Device", + "identifier": [ + {{ + "system": "{self.system}", + "value": "{self.value}" + }} + ], + "model": "{self.model}", + "version": "{self.version}" + }} + """ + return outstr.strip() + + def __str__(self) -> str: + return self.json diff --git a/gateway-api/src/gateway_api/clinical_jwt/practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/practitioner.py new file mode 100644 index 00000000..e3d02464 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/practitioner.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class Practitioner: + id: str + sds_userid: str + role_profile_id: str + userid_url: str + userid_value: str + family_name: str + given_name: str | None = None + prefix: str | None = None + + def __post_init__(self) -> None: + given = "" if self.given_name is None else f',"given":["{self.given_name}"]' + prefix = "" if self.prefix is None else f',"prefix":["{self.prefix}"]' + self._name_str = f'[{{"family": "{self.family_name}"{given}{prefix}}}]' + + @property + def json(self) -> str: + user_id_system = "https://fhir.nhs.uk/Id/sds-user-id" + role_id_system = "https://fhir.nhs.uk/Id/sds-role-profile-id" + + outstr = f""" + "requesting_practitioner": {{ + "resourceType": "Practitioner", + "id": "{self.id}", + "identifier": [ + {{ + "system": "{user_id_system}", + "value": "{self.sds_userid}" + }}, + {{ + "system": "{role_id_system}", + "value": "{self.role_profile_id}" + }}, + {{ + "system": "{self.userid_url}", + "value": "{self.userid_value}" + }} + ], + "name": {self._name_str} + }} + """ + return outstr.strip() + + def __str__(self) -> str: + return self.json diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 1cda4a94..612f3a41 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -57,11 +57,14 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: request.ods_from.strip(), provider_ods ) + token = self.get_jwt(provider_endpoint, request.ods_from.strip()) + # Call GP provider with correct parameters self.gp_provider_client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=token, ) response = self.gp_provider_client.access_structured_record( @@ -84,7 +87,50 @@ def get_auth_token(self) -> str: """ return "AUTH_TOKEN123" - def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: + def get_jwt(self, provider_endpoint: str, consumer_ods: str) -> str: + # For requesting device details, see: + # https://webarchive.nationalarchives.gov.uk/ukgwa/20250307092533/https://developer.nhs.uk/apis/gpconnect/integration_cross_organisation_audit_and_provenance.html#requesting_device-claim + # For requesting practitioner details, see: + # https://webarchive.nationalarchives.gov.uk/ukgwa/20250307092533/https://developer.nhs.uk/apis/gpconnect/integration_cross_organisation_audit_and_provenance.html#requesting_practitioner-claim + + # TODO: Get requesting device details from consumer, somehow? + requesting_device = Device( + system="https://consumersupplier.com/Id/device-identifier", + value="CONS-APP-4", + model="Consumer product name", + version="5.3.0", + ) + + # TODO: Get practitioner details from consumer, somehow? + requesting_practitioner = Practitioner( + id="10019", + sds_userid="111222333444", + role_profile_id="444555666777", + userid_url="https://consumersupplier.com/Id/user-guid", + userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", + family_name="Doe", + given_name="John", + prefix="Mr", + ) + + # TODO: Get consumer URL for issuer. Use CDG API URL for now. + issuer = "https://clinical-data-gateway-api.sandbox.nhs.uk" + audience = provider_endpoint + requesting_organization = consumer_ods + + token = JWT( + issuer=issuer, + subject=requesting_practitioner.id, + audience=audience, + requesting_device=requesting_device.json, + requesting_organization=requesting_organization, + requesting_practitioner=requesting_practitioner.json, + ).encode() + return token + + def _get_pds_details( + self, auth_token: str, consumer_ods: str, nhs_number: str + ) -> str: """ Call PDS to find the provider ODS code (GP ODS code) for a patient. """ diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 36be04e1..b265a999 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -59,6 +59,7 @@ class GpProviderClient: provider_endpoint (str): The FHIR API endpoint for the provider. provider_asid (str): The ASID for the provider. consumer_asid (str): The ASID for the consumer. + token (str): The JWT token for authentication with the provider API. Methods: access_structured_record(trace_id: str, body: str) -> Response: @@ -66,19 +67,18 @@ class GpProviderClient: """ def __init__( - self, - provider_endpoint: str, - provider_asid: str, - consumer_asid: str, + self, provider_endpoint: str, provider_asid: str, consumer_asid: str, token: str ) -> None: self.provider_endpoint = provider_endpoint self.provider_asid = provider_asid self.consumer_asid = consumer_asid + self.token = token def _build_headers(self, trace_id: str) -> dict[str, str]: """ Build the headers required for the GPProvider FHIR API request. """ + # TODO: Post-steel-thread, probably check whether JWT is valid/not expired return { "Content-Type": "application/fhir+json", "Accept": "application/fhir+json", @@ -86,6 +86,7 @@ def _build_headers(self, trace_id: str) -> dict[str, str]: "Ssp-To": self.provider_asid, "Ssp-From": self.consumer_asid, "Ssp-TraceID": trace_id, + "Authorization": f"Bearer {self.token}", } def access_structured_record( From 03aac2f4ff07b2e5c77f091f92e9296d0f3c6dbe Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:15:35 +0000 Subject: [PATCH 03/14] Add unit tests --- .../gateway_api/clinical_jwt/test_device.py | 59 ++++++ .../src/gateway_api/clinical_jwt/test_jwt.py | 169 ++++++++++++++++++ .../clinical_jwt/test_practitioner.py | 101 +++++++++++ .../src/gateway_api/provider/test_client.py | 40 +++++ .../src/gateway_api/test_controller.py | 55 ++++++ 5 files changed, 424 insertions(+) create mode 100644 gateway-api/src/gateway_api/clinical_jwt/test_device.py create mode 100644 gateway-api/src/gateway_api/clinical_jwt/test_jwt.py create mode 100644 gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_device.py b/gateway-api/src/gateway_api/clinical_jwt/test_device.py new file mode 100644 index 00000000..34aa168e --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/test_device.py @@ -0,0 +1,59 @@ +""" +Unit tests for :mod:`gateway_api.clinical_jwt.device`. +""" + +from gateway_api.clinical_jwt import Device + + +def test_device_creation_with_all_required_fields() -> None: + """ + Test that a Device instance can be created with all required fields. + """ + device = Device( + system="https://consumersupplier.com/Id/device-identifier", + value="CONS-APP-4", + model="Consumer product name", + version="5.3.0", + ) + + assert device.system == "https://consumersupplier.com/Id/device-identifier" + assert device.value == "CONS-APP-4" + assert device.model == "Consumer product name" + assert device.version == "5.3.0" + + +def test_device_json_property_returns_valid_json_structure() -> None: + """ + Test that the json property returns a valid JSON structure for requesting_device. + """ + device = Device( + system="https://consumersupplier.com/Id/device-identifier", + value="CONS-APP-4", + model="Consumer product name", + version="5.3.0", + ) + + json_output = device.json + + # Verify it contains the expected fields + assert '"requesting_device"' in json_output + assert '"resourceType": "Device"' in json_output + assert '"identifier"' in json_output + assert f'"system": "{device.system}"' in json_output + assert f'"value": "{device.value}"' in json_output + assert f'"model": "{device.model}"' in json_output + assert f'"version": "{device.version}"' in json_output + + +def test_device_str_returns_json() -> None: + """ + Test that __str__ returns the same value as the json property. + """ + device = Device( + system="https://test.com/device", + value="TEST-001", + model="Test Model", + version="1.0.0", + ) + + assert str(device) == device.json diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py new file mode 100644 index 00000000..cddcd840 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py @@ -0,0 +1,169 @@ +""" +Unit tests for :mod:`gateway_api.clinical_jwt.jwt`. +""" + +from unittest.mock import Mock, patch + +import jwt as pyjwt + +from gateway_api.clinical_jwt import JWT + + +def test_jwt_creation_with_required_fields() -> None: + """ + Test that a JWT instance can be created with all required fields. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + ) + + assert token.issuer == "https://example.com" + assert token.subject == "user-123" + assert token.audience == "https://provider.example.com" + assert token.requesting_device == '{"device": "info"}' + assert token.requesting_organization == "ORG-123" + assert token.requesting_practitioner == '{"practitioner": "info"}' + assert token.algorithm is None + assert token.type == "JWT" + assert token.reason_for_request == "directcare" + assert token.requested_scope == "patient/*.read" + + +@patch("gateway_api.clinical_jwt.jwt.time") +def test_jwt_default_issued_at_and_expiration(mock_time: Mock) -> None: + """ + Test that issued_at and expiration have correct default values. + """ + mock_time.return_value = 1000.0 + + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + ) + + assert token.issued_at == 1000 + assert token.expiration == 1300 # issued_at + 300 + + +def test_jwt_issue_time_property() -> None: + """ + Test that issue_time property returns ISO formatted timestamp. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + issued_at=1609459200, # 2021-01-01 00:00:00 UTC + ) + + assert token.issue_time == "2021-01-01T00:00:00+00:00" + + +def test_jwt_exp_time_property() -> None: + """ + Test that exp_time property returns ISO formatted timestamp. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + expiration=1609459500, # 2021-01-01 00:05:00 UTC + ) + + assert token.exp_time == "2021-01-01T00:05:00+00:00" + + +def test_jwt_payload_contains_all_required_fields() -> None: + """ + Test that payload() returns a dictionary with all required JWT fields. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + issued_at=1000, + expiration=1300, + ) + + payload = token.payload() + + assert payload["iss"] == token.issuer + assert payload["sub"] == token.subject + assert payload["aud"] == token.audience + assert payload["exp"] == token.expiration + assert payload["iat"] == token.issued_at + assert payload["requesting_device"] == token.requesting_device + assert payload["requesting_organization"] == token.requesting_organization + assert payload["requesting_practitioner"] == token.requesting_practitioner + assert payload["reason_for_request"] == token.reason_for_request + assert payload["requested_scope"] == token.requested_scope + + +def test_jwt_encode_returns_string() -> None: + """ + Test that encode() returns a valid JWT token string with correct structure. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + issued_at=1000, + expiration=1300, + ) + + encoded = token.encode() + + # Use PyJWT to decode and verify the token structure + pyjwt.decode( + encoded, + options={"verify_signature": False}, # NOSONAR S5659 (not signed) + ) + + +def test_jwt_decode_reconstructs_token() -> None: + """ + Test that decode() can reconstruct a JWT from an encoded token string. + """ + original = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + issued_at=1000, + expiration=1300, + ) + + encoded = original.encode() + decoded = JWT.decode(encoded) + + assert decoded.issuer == original.issuer + assert decoded.subject == original.subject + assert decoded.audience == original.audience + assert decoded.requesting_device == original.requesting_device + assert decoded.requesting_organization == original.requesting_organization + assert decoded.requesting_practitioner == original.requesting_practitioner + assert decoded.issued_at == original.issued_at + assert decoded.expiration == original.expiration diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py new file mode 100644 index 00000000..85e355fd --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py @@ -0,0 +1,101 @@ +""" +Unit tests for :mod:`gateway_api.clinical_jwt.practitioner`. +""" + +from gateway_api.clinical_jwt import Practitioner + + +def test_practitioner_creation_with_all_fields() -> None: + """ + Test that a Practitioner instance can be created with all fields. + """ + practitioner = Practitioner( + id="10019", + sds_userid="111222333444", + role_profile_id="444555666777", + userid_url="https://consumersupplier.com/Id/user-guid", + userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", + family_name="Doe", + given_name="John", + prefix="Mr", + ) + + assert practitioner.id == "10019" + assert practitioner.sds_userid == "111222333444" + assert practitioner.role_profile_id == "444555666777" + assert practitioner.userid_url == "https://consumersupplier.com/Id/user-guid" + assert practitioner.userid_value == "98ed4f78-814d-4266-8d5b-cde742f3093c" + assert practitioner.family_name == "Doe" + assert practitioner.given_name == "John" + assert practitioner.prefix == "Mr" + + +def test_practitioner_json_property_returns_valid_structure() -> None: + """ + Test that the json property returns a valid JSON structure for + requesting_practitioner. + """ + practitioner = Practitioner( + id="10019", + sds_userid="111222333444", + role_profile_id="444555666777", + userid_url="https://consumersupplier.com/Id/user-guid", + userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", + family_name="Doe", + given_name="John", + prefix="Mr", + ) + + json_output = practitioner.json + + # Verify it's a string + assert isinstance(json_output, str) + + # Verify it contains the expected fields + assert '"requesting_practitioner"' in json_output + assert '"resourceType": "Practitioner"' in json_output + assert f'"id": "{practitioner.id}"' in json_output + assert '"identifier"' in json_output + assert practitioner.sds_userid in json_output + assert practitioner.role_profile_id in json_output + assert practitioner.userid_url in json_output + assert practitioner.userid_value in json_output + assert f'"family": "{practitioner.family_name}"' in json_output + assert f'"given":["{practitioner.given_name}"]' in json_output + assert f'"prefix":["{practitioner.prefix}"]' in json_output + + +def test_practitioner_str_returns_json() -> None: + """ + Test that __str__ returns the same value as the json property. + """ + practitioner = Practitioner( + id="10026", + sds_userid="888999000111", + role_profile_id="111222333444", + userid_url="https://test.com/user", + userid_value="test-guid-7", + family_name="Taylor", + ) + + assert str(practitioner) == practitioner.json + + +def test_practitioner_identifier_systems() -> None: + """ + Test that the correct identifier systems are used in the JSON output. + """ + practitioner = Practitioner( + id="10027", + sds_userid="999000111222", + role_profile_id="222333444555", + userid_url="https://test.com/user", + userid_value="test-guid-8", + family_name="Anderson", + ) + + json_output = practitioner.json + + # Verify the correct system URLs are used + assert "https://fhir.nhs.uk/Id/sds-user-id" in json_output + assert "https://fhir.nhs.uk/Id/sds-role-profile-id" in json_output diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index e1988da0..98e067ea 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -77,11 +77,13 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "some_uuid_value" + token = "test-jwt-token" # NOQA S105 (dummy token value for testing) client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=token, ) result = client.access_structured_record( @@ -113,11 +115,13 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "some_uuid_value" + token = "test-jwt-token" # NOQA S105 (dummy token value for testing) client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=token, ) expected_headers = { "Content-Type": "application/fhir+json", @@ -128,6 +132,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-InteractionID": ( "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" ), + "Authorization": f"Bearer {token}", } result = client.access_structured_record( @@ -155,6 +160,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "some_uuid_value" + token = "test-jwt-token" # NOQA S105 (dummy token value for testing) request_body = json.dumps(valid_simple_request_payload) @@ -162,6 +168,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=token, ) result = client.access_structured_record(trace_id, request_body) @@ -188,11 +195,13 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "some_uuid_value" + token = "test-jwt-token" # NOQA S105 (dummy token value for testing) client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=token, ) expected_response = stub.access_record_structured( @@ -218,11 +227,13 @@ def test_access_structured_record_raises_external_service_error( consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "invalid for test" + token = "test-jwt-token" # NOQA S105 (dummy token value for testing) client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=token, ) with pytest.raises( @@ -230,3 +241,32 @@ def test_access_structured_record_raises_external_service_error( match="Provider request failed: Bad Request", ): client.access_structured_record(trace_id, "body") + + +def test_gpprovider_client_includes_authorization_header_with_bearer_token( + mock_request_post: dict[str, Any], +) -> None: + """ + Test that the GpProviderClient includes an Authorization header with the + Bearer token. + """ + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://test.com" + trace_id = "test-trace-id" + token = "my-jwt-token-value" # NOQA S105 (dummy token value for testing) + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + token=token, + ) + + result = client.access_structured_record(trace_id, "body") + + captured_headers = mock_request_post["headers"] + + assert "Authorization" in captured_headers + assert captured_headers["Authorization"] == f"Bearer {token}" + assert result.status_code == 200 diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index fc783205..0d4c2522 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -280,3 +280,58 @@ def mock_happy_path_get_structured_record_request( body=valid_simple_request_payload, ) return happy_path_request + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_controller_creates_jwt_token_with_correct_claims( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + Test that the controller creates a JWT token with the correct claims. + """ + from gateway_api.clinical_jwt import JWT + + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + _ = controller.run(get_structured_record_request) + + # Verify that a JWT token was created and passed to GpProviderClient + assert FakeGpProviderClient.last_init is not None + token_str = FakeGpProviderClient.last_init["token"] + + # Decode the token to verify its contents + decoded_token = JWT.decode(token_str) + + # Verify the standard JWT claims + assert decoded_token.issuer == "https://clinical-data-gateway-api.sandbox.nhs.uk" + assert decoded_token.subject == "10019" # From Controller.get_jwt() + assert decoded_token.audience == "https://provider.example/ep" + + # Verify the requesting organization matches the consumer ODS + assert decoded_token.requesting_organization == "CONSUMER" + + # Verify device and practitioner JSON are present + assert "requesting_device" in decoded_token.requesting_device + assert "Device" in decoded_token.requesting_device + assert "requesting_practitioner" in decoded_token.requesting_practitioner + assert "Practitioner" in decoded_token.requesting_practitioner From da59fecb7fe34877311828777f0fa06443575333 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:12:23 +0000 Subject: [PATCH 04/14] Review comments --- gateway-api/pyproject.toml | 2 +- .../src/gateway_api/clinical_jwt/jwt.py | 3 ++ .../gateway_api/clinical_jwt/test_device.py | 21 +++++--- .../src/gateway_api/clinical_jwt/test_jwt.py | 49 ++++++++++--------- gateway-api/src/gateway_api/controller.py | 6 +-- .../src/gateway_api/provider/client.py | 4 +- .../src/gateway_api/provider/test_client.py | 41 ++++++++++------ .../src/gateway_api/test_controller.py | 22 ++++----- .../tests/acceptance/steps/happy_path.py | 9 ++-- .../tests/schema/test_openapi_schema.py | 5 +- 10 files changed, 96 insertions(+), 66 deletions(-) diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 55b8d06d..8330ee5d 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -6,7 +6,7 @@ authors = [ {name = "Your Name", email = "you@example.com"} ] readme = "README.md" -requires-python = ">3.13,<4.0.0" +requires-python = ">=3.14,<4.0.0" [tool.poetry.dependencies] clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-common.git", tag = "v0.1.0" } diff --git a/gateway-api/src/gateway_api/clinical_jwt/jwt.py b/gateway-api/src/gateway_api/clinical_jwt/jwt.py index ffcd4bd0..e763fac3 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/jwt.py +++ b/gateway-api/src/gateway_api/clinical_jwt/jwt.py @@ -72,3 +72,6 @@ def payload(self) -> dict[str, Any]: "reason_for_request": self.reason_for_request, "requested_scope": self.requested_scope, } + + def __str__(self) -> str: + return self.encode() diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_device.py b/gateway-api/src/gateway_api/clinical_jwt/test_device.py index 34aa168e..21c0f563 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/test_device.py +++ b/gateway-api/src/gateway_api/clinical_jwt/test_device.py @@ -35,14 +35,19 @@ def test_device_json_property_returns_valid_json_structure() -> None: json_output = device.json - # Verify it contains the expected fields - assert '"requesting_device"' in json_output - assert '"resourceType": "Device"' in json_output - assert '"identifier"' in json_output - assert f'"system": "{device.system}"' in json_output - assert f'"value": "{device.value}"' in json_output - assert f'"model": "{device.model}"' in json_output - assert f'"version": "{device.version}"' in json_output + expected_json = """"requesting_device": { + "resourceType": "Device", + "identifier": [ + { + "system": "https://consumersupplier.com/Id/device-identifier", + "value": "CONS-APP-4" + } + ], + "model": "Consumer product name", + "version": "5.3.0" + }""" + + assert json_output == expected_json def test_device_str_returns_json() -> None: diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py index cddcd840..418fb039 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py +++ b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch import jwt as pyjwt +import pytest from gateway_api.clinical_jwt import JWT @@ -105,16 +106,20 @@ def test_jwt_payload_contains_all_required_fields() -> None: payload = token.payload() - assert payload["iss"] == token.issuer - assert payload["sub"] == token.subject - assert payload["aud"] == token.audience - assert payload["exp"] == token.expiration - assert payload["iat"] == token.issued_at - assert payload["requesting_device"] == token.requesting_device - assert payload["requesting_organization"] == token.requesting_organization - assert payload["requesting_practitioner"] == token.requesting_practitioner - assert payload["reason_for_request"] == token.reason_for_request - assert payload["requested_scope"] == token.requested_scope + expected = { + "iss": token.issuer, + "sub": token.subject, + "aud": token.audience, + "exp": token.expiration, + "iat": token.issued_at, + "requesting_device": token.requesting_device, + "requesting_organization": token.requesting_organization, + "requesting_practitioner": token.requesting_practitioner, + "reason_for_request": token.reason_for_request, + "requested_scope": token.requested_scope, + } + + assert payload == expected def test_jwt_encode_returns_string() -> None: @@ -135,10 +140,15 @@ def test_jwt_encode_returns_string() -> None: encoded = token.encode() # Use PyJWT to decode and verify the token structure - pyjwt.decode( - encoded, - options={"verify_signature": False}, # NOSONAR S5659 (not signed) - ) + try: + pyjwt.decode( + encoded, + options={"verify_signature": False}, # NOSONAR S5659 (not signed) + ) + except pyjwt.DecodeError as err: + pytest.fail(f"Failed to decode JWT: {err}") + except Exception as err: + pytest.fail(f"Unexpected error during JWT decoding: {err}") def test_jwt_decode_reconstructs_token() -> None: @@ -159,11 +169,6 @@ def test_jwt_decode_reconstructs_token() -> None: encoded = original.encode() decoded = JWT.decode(encoded) - assert decoded.issuer == original.issuer - assert decoded.subject == original.subject - assert decoded.audience == original.audience - assert decoded.requesting_device == original.requesting_device - assert decoded.requesting_organization == original.requesting_organization - assert decoded.requesting_practitioner == original.requesting_practitioner - assert decoded.issued_at == original.issued_at - assert decoded.expiration == original.expiration + assert decoded == original, ( + f"The decoded token, {decoded}, does not match the original, {original}" + ) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 612f3a41..2053bf58 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -57,7 +57,7 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: request.ods_from.strip(), provider_ods ) - token = self.get_jwt(provider_endpoint, request.ods_from.strip()) + token = self.get_jwt_for_provider(provider_endpoint, request.ods_from.strip()) # Call GP provider with correct parameters self.gp_provider_client = GpProviderClient( @@ -87,7 +87,7 @@ def get_auth_token(self) -> str: """ return "AUTH_TOKEN123" - def get_jwt(self, provider_endpoint: str, consumer_ods: str) -> str: + def get_jwt_for_provider(self, provider_endpoint: str, consumer_ods: str) -> JWT: # For requesting device details, see: # https://webarchive.nationalarchives.gov.uk/ukgwa/20250307092533/https://developer.nhs.uk/apis/gpconnect/integration_cross_organisation_audit_and_provenance.html#requesting_device-claim # For requesting practitioner details, see: @@ -125,7 +125,7 @@ def get_jwt(self, provider_endpoint: str, consumer_ods: str) -> str: requesting_device=requesting_device.json, requesting_organization=requesting_organization, requesting_practitioner=requesting_practitioner.json, - ).encode() + ) return token def _get_pds_details( diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index b265a999..d5a8ce10 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -59,7 +59,7 @@ class GpProviderClient: provider_endpoint (str): The FHIR API endpoint for the provider. provider_asid (str): The ASID for the provider. consumer_asid (str): The ASID for the consumer. - token (str): The JWT token for authentication with the provider API. + token (JWT): JWT object for authentication with the provider API. Methods: access_structured_record(trace_id: str, body: str) -> Response: @@ -67,7 +67,7 @@ class GpProviderClient: """ def __init__( - self, provider_endpoint: str, provider_asid: str, consumer_asid: str, token: str + self, provider_endpoint: str, provider_asid: str, consumer_asid: str, token: JWT ) -> None: self.provider_endpoint = provider_endpoint self.provider_asid = provider_asid diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 98e067ea..82b07809 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -17,6 +17,7 @@ from gateway_api.common.error import ProviderRequestFailedError from gateway_api.provider import GpProviderClient, client +from gateway_api.clinical_jwt import JWT if TYPE_CHECKING: from requests import Response @@ -62,9 +63,22 @@ def _fake_post( return capture +@pytest.fixture +def dummy_jwt() -> JWT: + return JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + ) + + def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method constructs the correct URL @@ -77,13 +91,12 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "some_uuid_value" - token = "test-jwt-token" # NOQA S105 (dummy token value for testing) client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, - token=token, + token=dummy_jwt, ) result = client.access_structured_record( @@ -102,6 +115,7 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method includes the correct headers @@ -115,13 +129,12 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "some_uuid_value" - token = "test-jwt-token" # NOQA S105 (dummy token value for testing) client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, - token=token, + token=dummy_jwt, ) expected_headers = { "Content-Type": "application/fhir+json", @@ -132,7 +145,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-InteractionID": ( "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" ), - "Authorization": f"Bearer {token}", + "Authorization": f"Bearer {dummy_jwt}", } result = client.access_structured_record( @@ -148,6 +161,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 def test_valid_gpprovider_access_structured_record_with_correct_body_200( mock_request_post: dict[str, Any], valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method includes the correct body @@ -160,7 +174,6 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "some_uuid_value" - token = "test-jwt-token" # NOQA S105 (dummy token value for testing) request_body = json.dumps(valid_simple_request_payload) @@ -168,7 +181,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, - token=token, + token=dummy_jwt, ) result = client.access_structured_record(trace_id, request_body) @@ -183,6 +196,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) stub: GpProviderStub, valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method returns the same response @@ -195,13 +209,12 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "some_uuid_value" - token = "test-jwt-token" # NOQA S105 (dummy token value for testing) client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, - token=token, + token=dummy_jwt, ) expected_response = stub.access_record_structured( @@ -218,6 +231,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( def test_access_structured_record_raises_external_service_error( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method raises an `SdsRequestFailed` @@ -227,13 +241,12 @@ def test_access_structured_record_raises_external_service_error( consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "invalid for test" - token = "test-jwt-token" # NOQA S105 (dummy token value for testing) client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, - token=token, + token=dummy_jwt, ) with pytest.raises( @@ -245,6 +258,7 @@ def test_access_structured_record_raises_external_service_error( def test_gpprovider_client_includes_authorization_header_with_bearer_token( mock_request_post: dict[str, Any], + dummy_jwt: JWT, ) -> None: """ Test that the GpProviderClient includes an Authorization header with the @@ -254,13 +268,12 @@ def test_gpprovider_client_includes_authorization_header_with_bearer_token( consumer_asid = "200000001152" provider_endpoint = "https://test.com" trace_id = "test-trace-id" - token = "my-jwt-token-value" # NOQA S105 (dummy token value for testing) client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, - token=token, + token=dummy_jwt, ) result = client.access_structured_record(trace_id, "body") @@ -268,5 +281,5 @@ def test_gpprovider_client_includes_authorization_header_with_bearer_token( captured_headers = mock_request_post["headers"] assert "Authorization" in captured_headers - assert captured_headers["Authorization"] == f"Bearer {token}" + assert captured_headers["Authorization"] == f"Bearer {dummy_jwt}" assert result.status_code == 200 diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 0d4c2522..97d679fe 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -295,8 +295,6 @@ def test_controller_creates_jwt_token_with_correct_claims( """ Test that the controller creates a JWT token with the correct claims. """ - from gateway_api.clinical_jwt import JWT - pds = pds_factory(ods_code="PROVIDER") sds_org1 = SdsSetup( ods_code="PROVIDER", @@ -317,21 +315,21 @@ def test_controller_creates_jwt_token_with_correct_claims( # Verify that a JWT token was created and passed to GpProviderClient assert FakeGpProviderClient.last_init is not None - token_str = FakeGpProviderClient.last_init["token"] + last_jwt = FakeGpProviderClient.last_init["token"] # Decode the token to verify its contents - decoded_token = JWT.decode(token_str) + # decoded_token = JWT.decode(token_str) # Verify the standard JWT claims - assert decoded_token.issuer == "https://clinical-data-gateway-api.sandbox.nhs.uk" - assert decoded_token.subject == "10019" # From Controller.get_jwt() - assert decoded_token.audience == "https://provider.example/ep" + assert last_jwt.issuer == "https://clinical-data-gateway-api.sandbox.nhs.uk" + assert last_jwt.subject == "10019" # From Controller.get_jwt() + assert last_jwt.audience == "https://provider.example/ep" # Verify the requesting organization matches the consumer ODS - assert decoded_token.requesting_organization == "CONSUMER" + assert last_jwt.requesting_organization == "CONSUMER" # Verify device and practitioner JSON are present - assert "requesting_device" in decoded_token.requesting_device - assert "Device" in decoded_token.requesting_device - assert "requesting_practitioner" in decoded_token.requesting_practitioner - assert "Practitioner" in decoded_token.requesting_practitioner + assert "requesting_device" in last_jwt.requesting_device + assert "Device" in last_jwt.requesting_device + assert "requesting_practitioner" in last_jwt.requesting_practitioner + assert "Practitioner" in last_jwt.requesting_practitioner diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index 88cd84af..a7d4d649 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -2,14 +2,17 @@ import json from datetime import timedelta +from typing import TYPE_CHECKING import requests -from fhir.parameters import Parameters from pytest_bdd import given, parsers, then, when from stubs.data.bundles import Bundles -from tests.acceptance.conftest import ResponseContext -from tests.conftest import Client +if TYPE_CHECKING: + from fhir.parameters import Parameters + + from tests.acceptance.conftest import ResponseContext + from tests.conftest import Client @given("the API is running") diff --git a/gateway-api/tests/schema/test_openapi_schema.py b/gateway-api/tests/schema/test_openapi_schema.py index 199da70a..bec928d2 100644 --- a/gateway-api/tests/schema/test_openapi_schema.py +++ b/gateway-api/tests/schema/test_openapi_schema.py @@ -5,12 +5,15 @@ """ from pathlib import Path +from typing import TYPE_CHECKING import schemathesis import yaml -from schemathesis.generation.case import Case # NOQA TC002 (Is needed) from schemathesis.openapi import from_dict +if TYPE_CHECKING: + from schemathesis.generation.case import Case + # Load the OpenAPI schema from the local file schema_path = Path(__file__).parent.parent.parent / "openapi.yaml" with open(schema_path) as f: From b93cf1c6db6b08d3bc4ba036a7455cc1985d4dd8 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:43:50 +0000 Subject: [PATCH 05/14] Make ruff happy, refactor controller JWT test --- gateway-api/poetry.lock | 4 +- gateway-api/src/fhir/bundle.py | 5 +- gateway-api/src/fhir/parameters.py | 5 +- gateway-api/src/fhir/patient.py | 9 +- .../src/gateway_api/clinical_jwt/jwt.py | 2 +- gateway-api/src/gateway_api/conftest.py | 6 +- gateway-api/src/gateway_api/controller.py | 5 +- .../get_structured_record/request.py | 8 +- .../get_structured_record/test_request.py | 2 +- gateway-api/src/gateway_api/pds/client.py | 6 +- .../src/gateway_api/provider/client.py | 1 + .../src/gateway_api/provider/test_client.py | 6 +- gateway-api/src/gateway_api/test_app.py | 2 +- .../src/gateway_api/test_controller.py | 91 +++++++++++-------- gateway-api/stubs/stubs/pds/stub.py | 7 +- gateway-api/stubs/stubs/provider/stub.py | 7 +- gateway-api/tests/acceptance/conftest.py | 6 +- .../tests/acceptance/steps/happy_path.py | 9 +- gateway-api/tests/conftest.py | 6 +- gateway-api/tests/contract/conftest.py | 2 +- .../tests/schema/test_openapi_schema.py | 5 +- ruff.toml | 2 - 22 files changed, 92 insertions(+), 104 deletions(-) diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 2a0bda48..296b64cd 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -2442,5 +2442,5 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" -python-versions = ">3.13,<4.0.0" -content-hash = "5318997004e2751a99885d15bff9904a8db740172217dab4870807d0884c86ec" +python-versions = ">=3.14,<4.0.0" +content-hash = "161eb0c3f2fa94f8b8a90196db766f1c7ce006eb698852d3bd92adfed98455d1" diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py index b041d483..5fbc9a3b 100644 --- a/gateway-api/src/fhir/bundle.py +++ b/gateway-api/src/fhir/bundle.py @@ -1,9 +1,8 @@ """FHIR Bundle resource.""" -from typing import TYPE_CHECKING, TypedDict +from typing import TypedDict -if TYPE_CHECKING: - from fhir.patient import Patient +from fhir.patient import Patient class BundleEntry(TypedDict): diff --git a/gateway-api/src/fhir/parameters.py b/gateway-api/src/fhir/parameters.py index abac7ebb..30b7cce8 100644 --- a/gateway-api/src/fhir/parameters.py +++ b/gateway-api/src/fhir/parameters.py @@ -1,9 +1,8 @@ """FHIR Parameters resource.""" -from typing import TYPE_CHECKING, TypedDict +from typing import TypedDict -if TYPE_CHECKING: - from fhir.identifier import Identifier +from fhir.identifier import Identifier class Parameter(TypedDict): diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py index 0386e46c..453a6f2a 100644 --- a/gateway-api/src/fhir/patient.py +++ b/gateway-api/src/fhir/patient.py @@ -1,11 +1,10 @@ """FHIR Patient resource.""" -from typing import TYPE_CHECKING, TypedDict, NotRequired +from typing import NotRequired, TypedDict -if TYPE_CHECKING: - from fhir.general_practitioner import GeneralPractitioner - from fhir.human_name import HumanName - from fhir.identifier import Identifier +from fhir.general_practitioner import GeneralPractitioner +from fhir.human_name import HumanName +from fhir.identifier import Identifier class Patient(TypedDict): diff --git a/gateway-api/src/gateway_api/clinical_jwt/jwt.py b/gateway-api/src/gateway_api/clinical_jwt/jwt.py index e763fac3..c8ec78cb 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/jwt.py +++ b/gateway-api/src/gateway_api/clinical_jwt/jwt.py @@ -42,7 +42,7 @@ def encode(self) -> str: ) @staticmethod - def decode(token: str) -> JWT: + def decode(token: str) -> "JWT": token_dict = pyjwt.decode( token, options={"verify_signature": False}, # NOSONAR S5659 (not signed) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 9204846e..8ef4c2d9 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -2,18 +2,16 @@ import json from dataclasses import dataclass -from typing import Any, TYPE_CHECKING +from typing import Any import pytest import requests from fhir import Bundle, OperationOutcome, Patient +from fhir.parameters import Parameters from flask import Request from requests.structures import CaseInsensitiveDict from werkzeug.test import EnvironBuilder -if TYPE_CHECKING: - from fhir.parameters import Parameters - @dataclass class FakeResponse: diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 2053bf58..78568d0c 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,6 +2,7 @@ Controller layer for orchestrating calls to external services """ +from gateway_api.clinical_jwt import JWT, Device, Practitioner from gateway_api.common.common import FlaskResponse from gateway_api.common.error import ( NoAsidFoundError, @@ -128,9 +129,7 @@ def get_jwt_for_provider(self, provider_endpoint: str, consumer_ods: str) -> JWT ) return token - def _get_pds_details( - self, auth_token: str, consumer_ods: str, nhs_number: str - ) -> str: + def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: """ Call PDS to find the provider ODS code (GP ODS code) for a patient. """ 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 ff152b98..7e723e7c 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -9,17 +9,15 @@ from gateway_api.common.common import FlaskResponse from gateway_api.common.error import InvalidRequestJSONError, MissingOrEmptyHeaderError -if TYPE_CHECKING: - from fhir.bundle import Bundle - - from gateway_api.common.common import FlaskResponse - # 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" ) +if TYPE_CHECKING: + from fhir.bundle import Bundle + class GetStructuredRecordRequest: INTERACTION_ID: ClassVar[str] = ACCESS_RECORD_STRUCTURED_INTERACTION_ID diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py index c6f6fcf2..34498655 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, cast import pytest +from fhir.parameters import Parameters from flask import Request from gateway_api.common.common import FlaskResponse @@ -11,7 +12,6 @@ if TYPE_CHECKING: from fhir.bundle import Bundle - from fhir.parameters import Parameters @pytest.fixture diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 0bee89fa..87ec279b 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -21,7 +21,7 @@ import os import uuid from collections.abc import Callable -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime from typing import cast import requests @@ -230,7 +230,7 @@ def find_current_gp( today: date | None = None, ) -> GeneralPractitioner | None: if today is None: - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() if self.ignore_dates: if len(general_practitioners) > 0: @@ -252,7 +252,7 @@ def find_current_name_record( self, names: list[HumanName], today: date | None = None ) -> HumanName | None: if today is None: - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() if self.ignore_dates: if len(names) > 0: diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index d5a8ce10..4b6c7e53 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -27,6 +27,7 @@ from requests import HTTPError, Response +from gateway_api.clinical_jwt import JWT from gateway_api.common.error import ProviderRequestFailedError from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 82b07809..2b7fed10 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -15,13 +15,9 @@ from requests.structures import CaseInsensitiveDict from stubs.provider.stub import GpProviderStub +from gateway_api.clinical_jwt import JWT from gateway_api.common.error import ProviderRequestFailedError from gateway_api.provider import GpProviderClient, client -from gateway_api.clinical_jwt import JWT - -if TYPE_CHECKING: - from requests import Response - from requests.structures import CaseInsensitiveDict @pytest.fixture diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index bd282cb4..de7982f9 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -21,7 +21,7 @@ @pytest.fixture -def client() -> Generator[FlaskClient[Flask], None, None]: +def client() -> Generator[FlaskClient[Flask]]: app.config["TESTING"] = True with app.test_client() as client: yield client diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 97d679fe..257a7d95 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -281,55 +281,72 @@ def mock_happy_path_get_structured_record_request( ) return happy_path_request -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) + def test_controller_creates_jwt_token_with_correct_claims( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, + mocker: MockerFixture, + valid_simple_request_payload: Parameters, + valid_simple_response_payload: Bundle, ) -> None: """ Test that the controller creates a JWT token with the correct claims. """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + nhs_number = "9000000009" + provider_ods = "PROVIDER" + consumer_ods = "CONSUMER" + provider_endpoint = "https://provider.example/ep" + + # Mock PDS to return provider ODS code + pds_search_result = PdsSearchResults( + given_names="Jane", + family_name="Smith", + nhs_number=nhs_number, + gp_ods_code=provider_ods, ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + mocker.patch( + "gateway_api.pds.PdsClient.search_patient_by_nhs_number", + return_value=pds_search_result, ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) + # Mock SDS to return provider and consumer details + provider_sds_results = SdsSearchResults( + asid="asid_PROV", endpoint=provider_endpoint + ) + consumer_sds_results = SdsSearchResults(asid="asid_CONS", endpoint=None) + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=[provider_sds_results, consumer_sds_results], + ) + + # Mock GpProviderClient to capture initialization arguments + mock_gp_provider = mocker.patch("gateway_api.controller.GpProviderClient") + + # Mock the access_structured_record method to return a response + provider_response = FakeResponse( + status_code=200, + headers={"Content-Type": "application/fhir+json"}, + _json=valid_simple_response_payload, + ) + mock_gp_provider.return_value.access_structured_record.return_value = ( + provider_response + ) - _ = controller.run(get_structured_record_request) + # Create request and run controller + request = create_mock_request( + headers={"ODS-From": consumer_ods, "Ssp-TraceID": "test-trace-id"}, + body=valid_simple_request_payload, + ) - # Verify that a JWT token was created and passed to GpProviderClient - assert FakeGpProviderClient.last_init is not None - last_jwt = FakeGpProviderClient.last_init["token"] + controller = Controller() + _ = controller.run(GetStructuredRecordRequest(request)) - # Decode the token to verify its contents - # decoded_token = JWT.decode(token_str) + # Verify that GpProviderClient was called and extract the JWT token + mock_gp_provider.assert_called_once() + jwt_token = mock_gp_provider.call_args.kwargs["token"] # Verify the standard JWT claims - assert last_jwt.issuer == "https://clinical-data-gateway-api.sandbox.nhs.uk" - assert last_jwt.subject == "10019" # From Controller.get_jwt() - assert last_jwt.audience == "https://provider.example/ep" + assert jwt_token.issuer == "https://clinical-data-gateway-api.sandbox.nhs.uk" + assert jwt_token.subject == "10019" + assert jwt_token.audience == provider_endpoint # Verify the requesting organization matches the consumer ODS - assert last_jwt.requesting_organization == "CONSUMER" - - # Verify device and practitioner JSON are present - assert "requesting_device" in last_jwt.requesting_device - assert "Device" in last_jwt.requesting_device - assert "requesting_practitioner" in last_jwt.requesting_practitioner - assert "Practitioner" in last_jwt.requesting_practitioner + assert jwt_token.requesting_organization == consumer_ods diff --git a/gateway-api/stubs/stubs/pds/stub.py b/gateway-api/stubs/stubs/pds/stub.py index 23e36d7b..ed88b446 100644 --- a/gateway-api/stubs/stubs/pds/stub.py +++ b/gateway-api/stubs/stubs/pds/stub.py @@ -6,7 +6,7 @@ import re import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from requests import Response @@ -239,10 +239,7 @@ def _now_fhir_instant() -> str: :return: Timestamp string in the format ``YYYY-MM-DDTHH:MM:SSZ``. """ return ( - datetime.now(timezone.utc) - .replace(microsecond=0) - .isoformat() - .replace("+00:00", "Z") + datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") ) @staticmethod diff --git a/gateway-api/stubs/stubs/provider/stub.py b/gateway-api/stubs/stubs/provider/stub.py index 3932cd95..a1f8d58d 100644 --- a/gateway-api/stubs/stubs/provider/stub.py +++ b/gateway-api/stubs/stubs/provider/stub.py @@ -24,12 +24,11 @@ import json from typing import Any +from requests import Response + from stubs.base_stub import StubBase from stubs.data.bundles import Bundles -if TYPE_CHECKING: - from requests import Response - class GpProviderStub(StubBase): """ @@ -71,7 +70,7 @@ def access_record_structured( try: nhs_number = json.loads(body)["parameter"][0]["valueIdentifier"]["value"] - except (json.JSONDecodeError, KeyError, IndexError): + except json.JSONDecodeError, KeyError, IndexError: return self._create_response( status_code=400, json_data={ diff --git a/gateway-api/tests/acceptance/conftest.py b/gateway-api/tests/acceptance/conftest.py index caeae1cd..6fd4da3c 100644 --- a/gateway-api/tests/acceptance/conftest.py +++ b/gateway-api/tests/acceptance/conftest.py @@ -1,9 +1,5 @@ -from typing import TYPE_CHECKING - import pytest - -if TYPE_CHECKING: - import requests +import requests class ResponseContext: diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index a7d4d649..88cd84af 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -2,17 +2,14 @@ import json from datetime import timedelta -from typing import TYPE_CHECKING import requests +from fhir.parameters import Parameters from pytest_bdd import given, parsers, then, when from stubs.data.bundles import Bundles -if TYPE_CHECKING: - from fhir.parameters import Parameters - - from tests.acceptance.conftest import ResponseContext - from tests.conftest import Client +from tests.acceptance.conftest import ResponseContext +from tests.conftest import Client @given("the API is running") diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index 0c80696f..00783a77 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -2,14 +2,12 @@ import os from datetime import timedelta -from typing import TYPE_CHECKING, cast +from typing import cast import pytest import requests from dotenv import find_dotenv, load_dotenv - -if TYPE_CHECKING: - from fhir.parameters import Parameters +from fhir.parameters import Parameters # Load environment variables from .env file in the workspace root # find_dotenv searches upward from current directory for .env file diff --git a/gateway-api/tests/contract/conftest.py b/gateway-api/tests/contract/conftest.py index 49df8670..d451f3e5 100644 --- a/gateway-api/tests/contract/conftest.py +++ b/gateway-api/tests/contract/conftest.py @@ -76,7 +76,7 @@ def do_PUT(self) -> None: @pytest.fixture(scope="module") -def mtls_proxy(base_url: str) -> Generator[str, None, None]: +def mtls_proxy(base_url: str) -> Generator[str]: """ Spins up a local HTTP server in a separate thread. Returns the URL of this local proxy. diff --git a/gateway-api/tests/schema/test_openapi_schema.py b/gateway-api/tests/schema/test_openapi_schema.py index bec928d2..5e83b004 100644 --- a/gateway-api/tests/schema/test_openapi_schema.py +++ b/gateway-api/tests/schema/test_openapi_schema.py @@ -5,15 +5,12 @@ """ from pathlib import Path -from typing import TYPE_CHECKING import schemathesis import yaml +from schemathesis.generation.case import Case from schemathesis.openapi import from_dict -if TYPE_CHECKING: - from schemathesis.generation.case import Case - # Load the OpenAPI schema from the local file schema_path = Path(__file__).parent.parent.parent / "openapi.yaml" with open(schema_path) as f: diff --git a/ruff.toml b/ruff.toml index 09a8e9cd..db28865d 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,5 +1,3 @@ -target-version = "py314" - [lint] select = [ # Standard configuration taken from https://docs.astral.sh/ruff/linter/#rule-selection. From b5127f52bda419450c4143bfb957250595737e9e Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:47:23 +0000 Subject: [PATCH 06/14] Fix provider test payloads --- gateway-api/src/gateway_api/provider/test_client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 2b7fed10..07d4e587 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -227,6 +227,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( def test_access_structured_record_raises_external_service_error( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) + valid_simple_request_payload: Parameters, dummy_jwt: JWT, ) -> None: """ @@ -249,11 +250,14 @@ def test_access_structured_record_raises_external_service_error( ProviderRequestFailedError, match="Provider request failed: Bad Request", ): - client.access_structured_record(trace_id, "body") + client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) def test_gpprovider_client_includes_authorization_header_with_bearer_token( mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, dummy_jwt: JWT, ) -> None: """ @@ -272,7 +276,9 @@ def test_gpprovider_client_includes_authorization_header_with_bearer_token( token=dummy_jwt, ) - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) captured_headers = mock_request_post["headers"] From c1ef469a8d4201f44046be062f8a112d401089d7 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:09:22 +0000 Subject: [PATCH 07/14] Remove incorrect return values from docstring --- gateway-api/tests/integration/test_sds_search.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/gateway-api/tests/integration/test_sds_search.py b/gateway-api/tests/integration/test_sds_search.py index 04b8fc84..7bcaa7fa 100644 --- a/gateway-api/tests/integration/test_sds_search.py +++ b/gateway-api/tests/integration/test_sds_search.py @@ -11,8 +11,6 @@ class TestSdsIntegration: def test_get_device_by_ods_code_returns_valid_asid(self) -> None: """ Test that querying by ODS code returns a valid ASID. - - :param sds_client: SDS client fixture configured with stub. """ sds_client = SdsClient() result = sds_client.get_org_details(ods_code="PROVIDER") @@ -24,8 +22,6 @@ def test_get_device_by_ods_code_returns_valid_asid(self) -> None: def test_consumer_organization_lookup(self) -> None: """ Test that CONSUMER organization can be looked up successfully. - - :param sds_client: SDS client fixture configured with stub. """ sds_client = SdsClient() result = sds_client.get_org_details(ods_code="CONSUMER") @@ -37,8 +33,6 @@ def test_consumer_organization_lookup(self) -> None: def test_result_contains_both_asid_and_endpoint_when_available(self) -> None: """ Test that results contain both ASID and endpoint when both are available. - - :param sds_client: SDS client fixture configured with stub. """ sds_client = SdsClient() From 72c16fc7b0619aa138f1d3681c0c5ff8dcfed345 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:36:33 +0000 Subject: [PATCH 08/14] Make SDS integration tests call main app --- .../tests/integration/test_sds_search.py | 99 ++++++++++++++----- 1 file changed, 77 insertions(+), 22 deletions(-) diff --git a/gateway-api/tests/integration/test_sds_search.py b/gateway-api/tests/integration/test_sds_search.py index 7bcaa7fa..b241f432 100644 --- a/gateway-api/tests/integration/test_sds_search.py +++ b/gateway-api/tests/integration/test_sds_search.py @@ -2,45 +2,100 @@ from __future__ import annotations -from gateway_api.sds import SdsClient +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tests.conftest import Client class TestSdsIntegration: """Integration tests for SDS search operations.""" - def test_get_device_by_ods_code_returns_valid_asid(self) -> None: + def test_get_device_by_ods_code_returns_valid_asid(self, client: Client) -> None: """ Test that querying by ODS code returns a valid ASID. """ - sds_client = SdsClient() - result = sds_client.get_org_details(ods_code="PROVIDER") + # Create a request payload with a known patient + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", # Alice Jones with A12345 provider + }, + }, + ], + } + + # Make request to the application endpoint + response = client.send_to_get_structured_record_endpoint(json.dumps(payload)) - assert result is not None - assert result.asid == "asid_PROV" - assert result.endpoint == "https://provider.example.com/fhir" + # Verify successful response indicates SDS lookup worked + assert response.status_code == 200 + # Verify we got a FHIR response (which means the full flow including SDS worked) + response_data = response.json() + assert response_data.get("resourceType") == "Bundle" - def test_consumer_organization_lookup(self) -> None: + def test_consumer_organization_lookup(self, client: Client) -> None: """ Test that CONSUMER organization can be looked up successfully. """ - sds_client = SdsClient() - result = sds_client.get_org_details(ods_code="CONSUMER") + # Create a request with a known patient + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", # Alice Jones with A12345 provider + }, + }, + ], + } + + # Use A12345 as the consumer ODS (Ods-from header) + response = client.send_to_get_structured_record_endpoint( + json.dumps(payload), + headers={"Ods-from": "A12345"}, # Consumer ODS code + ) - assert result is not None - assert result.asid == "asid_CONS" - assert result.endpoint == "https://consumer.example.com/fhir" + # Verify successful response indicates both consumer and provider + # SDS lookups worked + assert response.status_code == 200 + response_data = response.json() + assert response_data.get("resourceType") == "Bundle" - def test_result_contains_both_asid_and_endpoint_when_available(self) -> None: + def test_result_contains_both_asid_and_endpoint_when_available( + self, client: Client + ) -> None: """ Test that results contain both ASID and endpoint when both are available. """ + # Create a request with a known patient + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", # Alice Jones with A12345 provider + }, + }, + ], + } - sds_client = SdsClient() - result = sds_client.get_org_details(ods_code="PROVIDER") + # Make request to the application endpoint + response = client.send_to_get_structured_record_endpoint(json.dumps(payload)) - 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 + # Verify successful response (200) means both ASID and endpoint were retrieved + # If either were missing, the application would fail with an error + assert response.status_code == 200 + response_data = response.json() + # Verify we got a valid FHIR Bundle (indicating full flow including SDS worked) + assert response_data.get("resourceType") == "Bundle" + assert "entry" in response_data or response_data.get("total") is not None From 5b8b77307424bbbe3190c5b5d06ab429ebfa38ae Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:35:22 +0000 Subject: [PATCH 09/14] Start of orange box connection --- .../src/gateway_api/clinical_jwt/device.py | 6 +++--- gateway-api/src/gateway_api/common/common.py | 6 ++++++ gateway-api/src/gateway_api/controller.py | 15 +++++++++++---- gateway-api/src/gateway_api/provider/client.py | 7 ++++++- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/gateway-api/src/gateway_api/clinical_jwt/device.py b/gateway-api/src/gateway_api/clinical_jwt/device.py index 5c6b9b2f..d0d28864 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/device.py +++ b/gateway-api/src/gateway_api/clinical_jwt/device.py @@ -11,12 +11,12 @@ class Device: @property def json(self) -> str: outstr = f""" - "requesting_device": {{ - "resourceType": "Device", + {{ + "resourceType": "Device", "identifier": [ {{ "system": "{self.system}", - "value": "{self.value}" + "value": "{self.value}" }} ], "model": "{self.model}", diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py index 3891b8f3..f9452f1a 100644 --- a/gateway-api/src/gateway_api/common/common.py +++ b/gateway-api/src/gateway_api/common/common.py @@ -4,6 +4,7 @@ import re from dataclasses import dataclass +from http import HTTPStatus # This project uses JSON request/response bodies as strings in the controller layer. # The alias is used to make intent clearer in function signatures. @@ -61,3 +62,8 @@ def validate_nhs_number(value: str | int) -> bool: return False # invalid NHS number return check == provided_check_digit + + +def get_http_text(status_code: int) -> str: + status = HTTPStatus(status_code) + return status.phrase diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 78568d0c..576578d2 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -95,11 +95,18 @@ def get_jwt_for_provider(self, provider_endpoint: str, consumer_ods: str) -> JWT # https://webarchive.nationalarchives.gov.uk/ukgwa/20250307092533/https://developer.nhs.uk/apis/gpconnect/integration_cross_organisation_audit_and_provenance.html#requesting_practitioner-claim # TODO: Get requesting device details from consumer, somehow? + # requesting_device = Device( + # system="https://consumersupplier.com/Id/device-identifier", + # value="CONS-APP-4", + # model="Consumer product name", + # version="5.3.0", + # ) + requesting_device = Device( - system="https://consumersupplier.com/Id/device-identifier", - value="CONS-APP-4", - model="Consumer product name", - version="5.3.0", + system="https://orange.testlab.nhs.uk/gpconnect-demonstrator/Id/local-system-instance-id", + value="gpcdemonstrator-1-orange", + model="GP Connect Demonstrator", + version="1.5.0", ) # TODO: Get practitioner details from consumer, somehow? diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 4b6c7e53..467a38d9 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -28,6 +28,7 @@ from requests import HTTPError, Response from gateway_api.clinical_jwt import JWT +from gateway_api.common.common import get_http_text from gateway_api.common.error import ProviderRequestFailedError from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID @@ -114,6 +115,10 @@ def access_structured_record( try: response.raise_for_status() except HTTPError as err: - raise ProviderRequestFailedError(error_reason=err.response.reason) from err + errstr = "GPProvider FHIR API request failed:\n" + errstr += f"{response.status_code}: " + errstr += f"{get_http_text(response.status_code)}: {response.reason}\n" + errstr += response.text + raise ProviderRequestFailedError(error_reason=errstr) from err return response From fdc019df4c4391f5a67e0b4a55a8567746b16800 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:24:06 +0000 Subject: [PATCH 10/14] Orange box working with debug script --- .../src/gateway_api/clinical_jwt/__init__.py | 3 +- .../src/gateway_api/clinical_jwt/device.py | 35 ++++++----- .../src/gateway_api/clinical_jwt/jwt.py | 6 +- .../gateway_api/clinical_jwt/organization.py | 36 +++++++++++ .../gateway_api/clinical_jwt/practitioner.py | 60 ++++++++++--------- .../gateway_api/clinical_jwt/test_device.py | 21 ++++--- .../src/gateway_api/clinical_jwt/test_jwt.py | 48 +++++++-------- .../clinical_jwt/test_practitioner.py | 44 ++++++++------ gateway-api/src/gateway_api/controller.py | 14 +++-- .../src/gateway_api/provider/client.py | 29 ++++++--- .../src/gateway_api/provider/test_client.py | 6 +- .../src/gateway_api/test_controller.py | 2 +- gateway-api/stubs/stubs/sds/stub.py | 21 +++++++ 13 files changed, 208 insertions(+), 117 deletions(-) create mode 100644 gateway-api/src/gateway_api/clinical_jwt/organization.py diff --git a/gateway-api/src/gateway_api/clinical_jwt/__init__.py b/gateway-api/src/gateway_api/clinical_jwt/__init__.py index 5b6effaf..63d06bf2 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/__init__.py +++ b/gateway-api/src/gateway_api/clinical_jwt/__init__.py @@ -1,5 +1,6 @@ from .device import Device from .jwt import JWT +from .organization import Organization from .practitioner import Practitioner -__all__ = ["JWT", "Device", "Practitioner"] +__all__ = ["JWT", "Device", "Organization", "Practitioner"] diff --git a/gateway-api/src/gateway_api/clinical_jwt/device.py b/gateway-api/src/gateway_api/clinical_jwt/device.py index d0d28864..2c733103 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/device.py +++ b/gateway-api/src/gateway_api/clinical_jwt/device.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any @dataclass(frozen=True, kw_only=True) @@ -8,22 +9,26 @@ class Device: model: str version: str + def to_dict(self) -> dict[str, Any]: + """ + Return the Device as a dictionary suitable for JWT payload. + """ + return { + "resourceType": "Device", + "identifier": [{"system": self.system, "value": self.value}], + "model": self.model, + "version": self.version, + } + @property - def json(self) -> str: - outstr = f""" - {{ - "resourceType": "Device", - "identifier": [ - {{ - "system": "{self.system}", - "value": "{self.value}" - }} - ], - "model": "{self.model}", - "version": "{self.version}" - }} + def json(self) -> dict[str, Any]: """ - return outstr.strip() + Return the Device as a dictionary suitable for JWT payload. + Provided for backwards compatibility. + """ + return self.to_dict() def __str__(self) -> str: - return self.json + import json + + return json.dumps(self.to_dict(), indent=2) diff --git a/gateway-api/src/gateway_api/clinical_jwt/jwt.py b/gateway-api/src/gateway_api/clinical_jwt/jwt.py index c8ec78cb..f965d4df 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/jwt.py +++ b/gateway-api/src/gateway_api/clinical_jwt/jwt.py @@ -11,9 +11,9 @@ class JWT: issuer: str subject: str audience: str - requesting_device: str - requesting_organization: str - requesting_practitioner: str + requesting_device: dict[str, Any] + requesting_organization: dict[str, Any] + requesting_practitioner: dict[str, Any] # Time fields issued_at: int = field(default_factory=lambda: int(time())) diff --git a/gateway-api/src/gateway_api/clinical_jwt/organization.py b/gateway-api/src/gateway_api/clinical_jwt/organization.py new file mode 100644 index 00000000..18f69354 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/organization.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, kw_only=True) +class Organization: + ods_code: str + name: str + + def to_dict(self) -> dict[str, Any]: + """ + Return the Organization as a dictionary suitable for JWT payload. + """ + return { + "resourceType": "Organization", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": self.ods_code, + } + ], + "name": self.name, + } + + @property + def json(self) -> dict[str, Any]: + """ + Return the Organization as a dictionary suitable for JWT payload. + Provided for backwards compatibility. + """ + return self.to_dict() + + def __str__(self) -> str: + import json + + return json.dumps(self.to_dict(), indent=2) diff --git a/gateway-api/src/gateway_api/clinical_jwt/practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/practitioner.py index e3d02464..5e98e12b 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/practitioner.py +++ b/gateway-api/src/gateway_api/clinical_jwt/practitioner.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any @dataclass(kw_only=True) @@ -12,38 +13,39 @@ class Practitioner: given_name: str | None = None prefix: str | None = None - def __post_init__(self) -> None: - given = "" if self.given_name is None else f',"given":["{self.given_name}"]' - prefix = "" if self.prefix is None else f',"prefix":["{self.prefix}"]' - self._name_str = f'[{{"family": "{self.family_name}"{given}{prefix}}}]' - - @property - def json(self) -> str: + def to_dict(self) -> dict[str, Any]: + """ + Return the Practitioner as a dictionary suitable for JWT payload. + """ user_id_system = "https://fhir.nhs.uk/Id/sds-user-id" role_id_system = "https://fhir.nhs.uk/Id/sds-role-profile-id" - outstr = f""" - "requesting_practitioner": {{ - "resourceType": "Practitioner", - "id": "{self.id}", - "identifier": [ - {{ - "system": "{user_id_system}", - "value": "{self.sds_userid}" - }}, - {{ - "system": "{role_id_system}", - "value": "{self.role_profile_id}" - }}, - {{ - "system": "{self.userid_url}", - "value": "{self.userid_value}" - }} - ], - "name": {self._name_str} - }} + name_dict: dict[str, Any] = {"family": self.family_name} + if self.given_name is not None: + name_dict["given"] = [self.given_name] + if self.prefix is not None: + name_dict["prefix"] = [self.prefix] + + return { + "resourceType": "Practitioner", + "id": self.id, + "identifier": [ + {"system": user_id_system, "value": self.sds_userid}, + {"system": role_id_system, "value": self.role_profile_id}, + {"system": self.userid_url, "value": self.userid_value}, + ], + "name": [name_dict], + } + + @property + def json(self) -> dict[str, Any]: """ - return outstr.strip() + Return the Practitioner as a dictionary suitable for JWT payload. + Provided for backwards compatibility. + """ + return self.to_dict() def __str__(self) -> str: - return self.json + import json + + return json.dumps(self.to_dict(), indent=2) diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_device.py b/gateway-api/src/gateway_api/clinical_jwt/test_device.py index 21c0f563..6361dd46 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/test_device.py +++ b/gateway-api/src/gateway_api/clinical_jwt/test_device.py @@ -24,7 +24,7 @@ def test_device_creation_with_all_required_fields() -> None: def test_device_json_property_returns_valid_json_structure() -> None: """ - Test that the json property returns a valid JSON structure for requesting_device. + Test that the json property returns a valid dict structure for requesting_device. """ device = Device( system="https://consumersupplier.com/Id/device-identifier", @@ -35,24 +35,24 @@ def test_device_json_property_returns_valid_json_structure() -> None: json_output = device.json - expected_json = """"requesting_device": { - "resourceType": "Device", + expected_dict = { + "resourceType": "Device", "identifier": [ { "system": "https://consumersupplier.com/Id/device-identifier", - "value": "CONS-APP-4" + "value": "CONS-APP-4", } ], "model": "Consumer product name", - "version": "5.3.0" - }""" + "version": "5.3.0", + } - assert json_output == expected_json + assert json_output == expected_dict def test_device_str_returns_json() -> None: """ - Test that __str__ returns the same value as the json property. + Test that __str__ returns a JSON string representation of the dictionary. """ device = Device( system="https://test.com/device", @@ -61,4 +61,7 @@ def test_device_str_returns_json() -> None: version="1.0.0", ) - assert str(device) == device.json + # __str__ now returns a JSON string, while .json returns a dict + import json + + assert json.loads(str(device)) == device.json diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py index 418fb039..a8f0f038 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py +++ b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py @@ -18,17 +18,17 @@ def test_jwt_creation_with_required_fields() -> None: issuer="https://example.com", subject="user-123", audience="https://provider.example.com", - requesting_device='{"device": "info"}', - requesting_organization="ORG-123", - requesting_practitioner='{"practitioner": "info"}', + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, ) assert token.issuer == "https://example.com" assert token.subject == "user-123" assert token.audience == "https://provider.example.com" - assert token.requesting_device == '{"device": "info"}' - assert token.requesting_organization == "ORG-123" - assert token.requesting_practitioner == '{"practitioner": "info"}' + assert token.requesting_device == {"device": "info"} + assert token.requesting_organization == {"org": "info"} + assert token.requesting_practitioner == {"practitioner": "info"} assert token.algorithm is None assert token.type == "JWT" assert token.reason_for_request == "directcare" @@ -46,9 +46,9 @@ def test_jwt_default_issued_at_and_expiration(mock_time: Mock) -> None: issuer="https://example.com", subject="user-123", audience="https://provider.example.com", - requesting_device='{"device": "info"}', - requesting_organization="ORG-123", - requesting_practitioner='{"practitioner": "info"}', + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, ) assert token.issued_at == 1000 @@ -63,9 +63,9 @@ def test_jwt_issue_time_property() -> None: issuer="https://example.com", subject="user-123", audience="https://provider.example.com", - requesting_device='{"device": "info"}', - requesting_organization="ORG-123", - requesting_practitioner='{"practitioner": "info"}', + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, issued_at=1609459200, # 2021-01-01 00:00:00 UTC ) @@ -80,9 +80,9 @@ def test_jwt_exp_time_property() -> None: issuer="https://example.com", subject="user-123", audience="https://provider.example.com", - requesting_device='{"device": "info"}', - requesting_organization="ORG-123", - requesting_practitioner='{"practitioner": "info"}', + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, expiration=1609459500, # 2021-01-01 00:05:00 UTC ) @@ -97,9 +97,9 @@ def test_jwt_payload_contains_all_required_fields() -> None: issuer="https://example.com", subject="user-123", audience="https://provider.example.com", - requesting_device='{"device": "info"}', - requesting_organization="ORG-123", - requesting_practitioner='{"practitioner": "info"}', + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, issued_at=1000, expiration=1300, ) @@ -130,9 +130,9 @@ def test_jwt_encode_returns_string() -> None: issuer="https://example.com", subject="user-123", audience="https://provider.example.com", - requesting_device='{"device": "info"}', - requesting_organization="ORG-123", - requesting_practitioner='{"practitioner": "info"}', + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, issued_at=1000, expiration=1300, ) @@ -159,9 +159,9 @@ def test_jwt_decode_reconstructs_token() -> None: issuer="https://example.com", subject="user-123", audience="https://provider.example.com", - requesting_device='{"device": "info"}', - requesting_organization="ORG-123", - requesting_practitioner='{"practitioner": "info"}', + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, issued_at=1000, expiration=1300, ) diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py index 85e355fd..17d13f3e 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py +++ b/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py @@ -32,7 +32,7 @@ def test_practitioner_creation_with_all_fields() -> None: def test_practitioner_json_property_returns_valid_structure() -> None: """ - Test that the json property returns a valid JSON structure for + Test that the json property returns a valid dictionary structure for requesting_practitioner. """ practitioner = Practitioner( @@ -48,26 +48,26 @@ def test_practitioner_json_property_returns_valid_structure() -> None: json_output = practitioner.json - # Verify it's a string - assert isinstance(json_output, str) + # Verify it's a dictionary + assert isinstance(json_output, dict) # Verify it contains the expected fields - assert '"requesting_practitioner"' in json_output - assert '"resourceType": "Practitioner"' in json_output - assert f'"id": "{practitioner.id}"' in json_output - assert '"identifier"' in json_output - assert practitioner.sds_userid in json_output - assert practitioner.role_profile_id in json_output - assert practitioner.userid_url in json_output - assert practitioner.userid_value in json_output - assert f'"family": "{practitioner.family_name}"' in json_output - assert f'"given":["{practitioner.given_name}"]' in json_output - assert f'"prefix":["{practitioner.prefix}"]' in json_output + assert json_output["resourceType"] == "Practitioner" + assert json_output["id"] == practitioner.id + assert "identifier" in json_output + assert len(json_output["identifier"]) == 3 + assert json_output["identifier"][0]["value"] == practitioner.sds_userid + assert json_output["identifier"][1]["value"] == practitioner.role_profile_id + assert json_output["identifier"][2]["system"] == practitioner.userid_url + assert json_output["identifier"][2]["value"] == practitioner.userid_value + assert json_output["name"][0]["family"] == practitioner.family_name + assert json_output["name"][0]["given"][0] == practitioner.given_name + assert json_output["name"][0]["prefix"][0] == practitioner.prefix def test_practitioner_str_returns_json() -> None: """ - Test that __str__ returns the same value as the json property. + Test that __str__ returns a JSON string representation of the dictionary. """ practitioner = Practitioner( id="10026", @@ -78,7 +78,10 @@ def test_practitioner_str_returns_json() -> None: family_name="Taylor", ) - assert str(practitioner) == practitioner.json + # __str__ now returns a JSON string, while .json returns a dict + import json + + assert json.loads(str(practitioner)) == practitioner.json def test_practitioner_identifier_systems() -> None: @@ -97,5 +100,10 @@ def test_practitioner_identifier_systems() -> None: json_output = practitioner.json # Verify the correct system URLs are used - assert "https://fhir.nhs.uk/Id/sds-user-id" in json_output - assert "https://fhir.nhs.uk/Id/sds-role-profile-id" in json_output + assert ( + json_output["identifier"][0]["system"] == "https://fhir.nhs.uk/Id/sds-user-id" + ) + assert ( + json_output["identifier"][1]["system"] + == "https://fhir.nhs.uk/Id/sds-role-profile-id" + ) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 576578d2..631e2e02 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,7 +2,7 @@ Controller layer for orchestrating calls to external services """ -from gateway_api.clinical_jwt import JWT, Device, Practitioner +from gateway_api.clinical_jwt import JWT, Device, Organization, Practitioner from gateway_api.common.common import FlaskResponse from gateway_api.common.error import ( NoAsidFoundError, @@ -121,18 +121,22 @@ def get_jwt_for_provider(self, provider_endpoint: str, consumer_ods: str) -> JWT prefix="Mr", ) + # TODO: Get consumer organization details properly + requesting_organization = Organization( + ods_code=consumer_ods, name="Consumer organisation name" + ) + # TODO: Get consumer URL for issuer. Use CDG API URL for now. issuer = "https://clinical-data-gateway-api.sandbox.nhs.uk" audience = provider_endpoint - requesting_organization = consumer_ods token = JWT( issuer=issuer, subject=requesting_practitioner.id, audience=audience, - requesting_device=requesting_device.json, - requesting_organization=requesting_organization, - requesting_practitioner=requesting_practitioner.json, + requesting_device=requesting_device.to_dict(), + requesting_organization=requesting_organization.to_dict(), + requesting_practitioner=requesting_practitioner.to_dict(), ) return token diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 467a38d9..360301c1 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -44,9 +44,8 @@ provider_stub = GpProviderStub() post = provider_stub.post # type: ignore -ARS_FHIR_BASE = "FHIR/STU3" -FHIR_RESOURCE = "patient" -ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" +# Default endpoint path for access record structured interaction (standard GP Connect) +ARS_ENDPOINT_PATH = "FHIR/STU3/patient/$gpc.getstructuredrecord" TIMEOUT: int | None = None # None used for quicker dev, adjust as needed @@ -58,10 +57,12 @@ class GpProviderClient: including fetching structured patient records. Attributes: - provider_endpoint (str): The FHIR API endpoint for the provider. + provider_endpoint (str): The base URL for the provider (from SDS). provider_asid (str): The ASID for the provider. consumer_asid (str): The ASID for the consumer. token (JWT): JWT object for authentication with the provider API. + endpoint_path (str): The endpoint path for the operation + (default: "FHIR/STU3/patient/$gpc.getstructuredrecord"). Methods: access_structured_record(trace_id: str, body: str) -> Response: @@ -69,12 +70,18 @@ class GpProviderClient: """ def __init__( - self, provider_endpoint: str, provider_asid: str, consumer_asid: str, token: JWT + self, + provider_endpoint: str, + provider_asid: str, + consumer_asid: str, + token: JWT, + endpoint_path: str = ARS_ENDPOINT_PATH, ) -> None: self.provider_endpoint = provider_endpoint self.provider_asid = provider_asid self.consumer_asid = consumer_asid self.token = token + self.endpoint_path = endpoint_path def _build_headers(self, trace_id: str) -> dict[str, str]: """ @@ -82,8 +89,8 @@ def _build_headers(self, trace_id: str) -> dict[str, str]: """ # TODO: Post-steel-thread, probably check whether JWT is valid/not expired return { - "Content-Type": "application/fhir+json", - "Accept": "application/fhir+json", + "Content-Type": "application/fhir+json;charset=utf-8", + "Accept": "application/fhir+json;charset=utf-8", "Ssp-InteractionID": ACCESS_RECORD_STRUCTURED_INTERACTION_ID, "Ssp-To": self.provider_asid, "Ssp-From": self.consumer_asid, @@ -102,8 +109,7 @@ def access_structured_record( headers = self._build_headers(trace_id) - endpoint_path = "/".join([ARS_FHIR_BASE, FHIR_RESOURCE, ARS_FHIR_OPERATION]) - url = urljoin(self.provider_endpoint, endpoint_path) + url = urljoin(self.provider_endpoint, self.endpoint_path) response = post( url, @@ -119,6 +125,11 @@ def access_structured_record( errstr += f"{response.status_code}: " errstr += f"{get_http_text(response.status_code)}: {response.reason}\n" errstr += response.text + errstr += "\nHeaders were:\n" + for header, value in headers.items(): + errstr += f"{header}: {value}\n" + errstr += "\nBody payload was:\n" + errstr += body raise ProviderRequestFailedError(error_reason=errstr) from err return response diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 07d4e587..21f50e4b 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -65,9 +65,9 @@ def dummy_jwt() -> JWT: issuer="https://example.com", subject="user-123", audience="https://provider.example.com", - requesting_device='{"device": "info"}', - requesting_organization="ORG-123", - requesting_practitioner='{"practitioner": "info"}', + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, ) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 257a7d95..a55abbc2 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -349,4 +349,4 @@ def test_controller_creates_jwt_token_with_correct_claims( assert jwt_token.audience == provider_endpoint # Verify the requesting organization matches the consumer ODS - assert jwt_token.requesting_organization == consumer_ods + assert jwt_token.requesting_organization["identifier"][0]["value"] == consumer_ods diff --git a/gateway-api/stubs/stubs/sds/stub.py b/gateway-api/stubs/stubs/sds/stub.py index afbd6e3b..b95a5206 100644 --- a/gateway-api/stubs/stubs/sds/stub.py +++ b/gateway-api/stubs/stubs/sds/stub.py @@ -403,6 +403,20 @@ def _seed_default_devices(self) -> None: "asid": "ASIDforGPWithoutEndpoint", "display": "GP with no provider endpoint - testing error handling", }, + { + "org_ods": "S44444", + "party_key": "S44444-0000809", + "device_id": "B2B2E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "200000000359", + "display": "Dummy ODS/ASID for Orange Box", + }, + { + "org_ods": "S55555", + "party_key": "S55555-0000809", + "device_id": "B3B3E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "918999198738", + "display": "Dummy ODS/ASID for Orange Box", + }, ] # Iterate through test data and create devices @@ -445,6 +459,13 @@ def _seed_default_endpoints(self) -> None: "asid": "asid_A12345", "address": "https://a12345.example.com/fhir", }, + { + "org_ods": "S55555", + "party_key": "S55555-0000809", + "endpoint_id": "E3E3E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "918999198738", + "address": "https://orange.testlab.nhs.uk/B82617/STU3/1/gpconnect/structured/fhir/", + }, ] # Iterate through test data and create endpoints From 50e7b0f9ac84f058740b59bfb99d37e383370bcc Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:21:30 +0000 Subject: [PATCH 11/14] Orange box connection working --- .../src/gateway_api/provider/client.py | 2 +- .../orange_box_trigger_9690937278.json | 34 +++++++++++++++++++ .../stubs/stubs/data/patients/patients.py | 1 + gateway-api/stubs/stubs/pds/stub.py | 1 + gateway-api/stubs/stubs/sds/stub.py | 2 +- 5 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 gateway-api/stubs/stubs/data/patients/orange_box_trigger_9690937278.json diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 360301c1..fca1c465 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -45,7 +45,7 @@ post = provider_stub.post # type: ignore # Default endpoint path for access record structured interaction (standard GP Connect) -ARS_ENDPOINT_PATH = "FHIR/STU3/patient/$gpc.getstructuredrecord" +ARS_ENDPOINT_PATH = "Patient/$gpc.getstructuredrecord" TIMEOUT: int | None = None # None used for quicker dev, adjust as needed diff --git a/gateway-api/stubs/stubs/data/patients/orange_box_trigger_9690937278.json b/gateway-api/stubs/stubs/data/patients/orange_box_trigger_9690937278.json new file mode 100644 index 00000000..1660e792 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/orange_box_trigger_9690937278.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9690937278", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9690937278" + } + ], + "name": [ + { + "use": "official", + "family": "Samual", + "given": ["Lucien"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "male", + "birthDate": "1938-12-11", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "S55555", + "period": {"start": "2020-01-01", "end": "9999-12-31"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/patients.py b/gateway-api/stubs/stubs/data/patients/patients.py index e21ce2d1..1b6a5a0d 100644 --- a/gateway-api/stubs/stubs/data/patients/patients.py +++ b/gateway-api/stubs/stubs/data/patients/patients.py @@ -26,3 +26,4 @@ def load_patient(filename: str) -> dict[str, Any]: "blank_endpoint_sds_result_9000000013.json" ) ALICE_JONES_9999999999 = load_patient("alice_jones_9999999999.json") + ORANGE_BOX_TRIGGER_9690937278 = load_patient("orange_box_trigger_9690937278.json") diff --git a/gateway-api/stubs/stubs/pds/stub.py b/gateway-api/stubs/stubs/pds/stub.py index ed88b446..c7c29014 100644 --- a/gateway-api/stubs/stubs/pds/stub.py +++ b/gateway-api/stubs/stubs/pds/stub.py @@ -52,6 +52,7 @@ def __init__(self, strict_headers: bool = True) -> None: ("9000000011", Patients.BLANK_ASID_SDS_RESULT_9000000011), ("9000000012", Patients.INDUCE_PROVIDER_ERROR_9000000012), ("9000000013", Patients.BLANK_ENDPOINT_SDS_RESULT_9000000013), + ("9690937278", Patients.ORANGE_BOX_TRIGGER_9690937278), ] for nhs_number, patient in test_patients: self.upsert_patient( diff --git a/gateway-api/stubs/stubs/sds/stub.py b/gateway-api/stubs/stubs/sds/stub.py index b95a5206..fdd04f5c 100644 --- a/gateway-api/stubs/stubs/sds/stub.py +++ b/gateway-api/stubs/stubs/sds/stub.py @@ -415,7 +415,7 @@ def _seed_default_devices(self) -> None: "party_key": "S55555-0000809", "device_id": "B3B3E921-92CA-4A88-A550-2DBB36F703AF", "asid": "918999198738", - "display": "Dummy ODS/ASID for Orange Box", + "display": "ODS/ASID triggering Orange Box", }, ] From e8919a164cd8e1b047b7237098cb119d136499df Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:36:57 +0000 Subject: [PATCH 12/14] Pass $STUB_PROVIDER --- Makefile | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 40b5f7fe..cf612034 100644 --- a/Makefile +++ b/Makefile @@ -56,9 +56,17 @@ publish: # Publish the project artefact @Pipeline deploy: clean build # Deploy the project artefact to the target environment @Pipeline @if [[ -n "$${IN_BUILD_CONTAINER}" ]]; then \ echo "Starting using local docker network ..." ; \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local -d ${IMAGE_NAME} ; \ + if [[ -n "$${STUB_PROVIDER}" ]]; then \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local -e STUB_PROVIDER=$${STUB_PROVIDER} -d ${IMAGE_NAME} ; \ + else \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local -d ${IMAGE_NAME} ; \ + fi ; \ else \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 -d ${IMAGE_NAME} ; \ + if [[ -n "$${STUB_PROVIDER}" ]]; then \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 -e STUB_PROVIDER=$${STUB_PROVIDER} -d ${IMAGE_NAME} ; \ + else \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 -d ${IMAGE_NAME} ; \ + fi ; \ fi clean:: stop # Clean-up project resources (main) @Operations From 89dffb266bd873b86e3b069df216c6b67a3550c5 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:49:44 +0000 Subject: [PATCH 13/14] Pass STUB env vars --- Makefile | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index cf612034..e5ad5732 100644 --- a/Makefile +++ b/Makefile @@ -54,19 +54,21 @@ publish: # Publish the project artefact @Pipeline # TODO: Implement the artefact publishing step deploy: clean build # Deploy the project artefact to the target environment @Pipeline - @if [[ -n "$${IN_BUILD_CONTAINER}" ]]; then \ + @PROVIDER_STRING="" ; \ + if [[ -n "$${STUB_PROVIDER}" ]]; then \ + PROVIDER_STRING="$${PROVIDER_STRING} -e STUB_PROVIDER=$${STUB_PROVIDER}" ; \ + fi ; \ + if [[ -n "$${STUB_PDS}" ]]; then \ + PROVIDER_STRING="$${PROVIDER_STRING} -e STUB_PDS=$${STUB_PDS}" ; \ + fi ; \ + if [[ -n "$${STUB_SDS}" ]]; then \ + PROVIDER_STRING="$${PROVIDER_STRING} -e STUB_SDS=$${STUB_SDS}" ; \ + fi ; \ + if [[ -n "$${IN_BUILD_CONTAINER}" ]]; then \ echo "Starting using local docker network ..." ; \ - if [[ -n "$${STUB_PROVIDER}" ]]; then \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local -e STUB_PROVIDER=$${STUB_PROVIDER} -d ${IMAGE_NAME} ; \ - else \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local -d ${IMAGE_NAME} ; \ - fi ; \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local $${PROVIDER_STRING} -d ${IMAGE_NAME} ; \ else \ - if [[ -n "$${STUB_PROVIDER}" ]]; then \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 -e STUB_PROVIDER=$${STUB_PROVIDER} -d ${IMAGE_NAME} ; \ - else \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 -d ${IMAGE_NAME} ; \ - fi ; \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 $${PROVIDER_STRING} -d ${IMAGE_NAME} ; \ fi clean:: stop # Clean-up project resources (main) @Operations From e8c4acdc8b9cf95a09cc6ee8e210bc063db23530 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:54:18 +0000 Subject: [PATCH 14/14] Create call script --- gateway-api/scripts/call_gateway.py | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 gateway-api/scripts/call_gateway.py diff --git a/gateway-api/scripts/call_gateway.py b/gateway-api/scripts/call_gateway.py new file mode 100644 index 00000000..1f9ca25e --- /dev/null +++ b/gateway-api/scripts/call_gateway.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import sys +from uuid import uuid4 + +import requests + + +def main() -> None: + # Parse command-line arguments + parser = argparse.ArgumentParser( + description="POST request to GPC getstructuredrecord endpoint" + ) + parser.add_argument( + "nhs_number", help="NHS number to search for (e.g., 9690937278)" + ) + args = parser.parse_args() + + # Check if BASE_URL is set + base_url = os.environ.get("BASE_URL") + if not base_url: + print("Error: BASE_URL environment variable is not set") + sys.exit(1) + + # Endpoint URL + url = f"{base_url}/patient/$gpc.getstructuredrecord" + + # Request headers + headers = { + "Content-Type": "application/fhir+json", + "Accept": "application/fhir+json", + "Ssp-TraceID": str(uuid4()), + "Ods-From": "S44444", + } + + # Request body + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": args.nhs_number, + }, + } + ], + } + + # Make the POST request + try: + response = requests.post(url, headers=headers, json=payload, timeout=10) + response.raise_for_status() + + print(f"Status Code: {response.status_code}") + print(f"Response:\n{json.dumps(response.json(), indent=2)}") + + except requests.exceptions.RequestException as e: + errtext = f"Error: {e}\n" + if e.response is not None: + errtext += f"Status Code: {e.response.status_code}\n" + errtext += f"Response Body: {e.response.text}\n" + print(errtext) + sys.exit(1) + + +if __name__ == "__main__": + main()