Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,24 @@ 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 "$${CDG_DEBUG}" ]]; then \
PROVIDER_STRING="$${PROVIDER_STRING} -e CDG_DEBUG=$${CDG_DEBUG}" ; \
fi ; \
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} ; \
$(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local $${PROVIDER_STRING} -d ${IMAGE_NAME} ; \
else \
$(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 -d ${IMAGE_NAME} ; \
$(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
Expand Down
6 changes: 3 additions & 3 deletions gateway-api/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions gateway-api/src/fhir/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from enum import StrEnum


class FHIRSystem(StrEnum):
"""
Enum for FHIR identifier systems used in the clinical data gateway.
"""

NHS_NUMBER = "https://fhir.nhs.uk/Id/nhs-number"
ODS_CODE = "https://fhir.nhs.uk/Id/ods-organization-code"
SDS_USER_ID = "https://fhir.nhs.uk/Id/sds-user-id"
SDS_ROLE_PROFILE_ID = "https://fhir.nhs.uk/Id/sds-role-profile-id"
NHS_SERVICE_INTERACTION_ID = "https://fhir.nhs.uk/Id/nhsServiceInteractionId"
NHS_MHS_PARTY_KEY = "https://fhir.nhs.uk/Id/nhsMhsPartyKey"
NHS_SPINE_ASID = "https://fhir.nhs.uk/Id/nhsSpineASID"
4 changes: 3 additions & 1 deletion gateway-api/src/gateway_api/clinical_jwt/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from .device import Device
from .jwt import JWT
from .organization import Organization
from .practitioner import Practitioner
from .validator import JWTValidator

__all__ = ["JWT", "Device", "Practitioner"]
__all__ = ["JWT", "Device", "Organization", "Practitioner", "JWTValidator"]
28 changes: 10 additions & 18 deletions gateway-api/src/gateway_api/clinical_jwt/device.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Any


@dataclass(frozen=True, kw_only=True)
Expand All @@ -8,22 +9,13 @@ class Device:
model: str
version: str

@property
def json(self) -> str:
outstr = f"""
{{
"resourceType": "Device",
"identifier": [
{{
"system": "{self.system}",
"value": "{self.value}"
}}
],
"model": "{self.model}",
"version": "{self.version}"
}}
def to_dict(self) -> dict[str, Any]:
"""
return outstr.strip()

def __str__(self) -> str:
return self.json
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,
}
14 changes: 9 additions & 5 deletions gateway-api/src/gateway_api/clinical_jwt/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,24 @@ 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()))
expiration: int = field(default_factory=lambda: int(time()) + 300)
expiration: int = 0

# These are here for future proofing but are not expected ever to be changed
algorithm: str | None = None
algorithm: str = "none"
type: str = "JWT"
reason_for_request: str = "directcare"
requested_scope: str = "patient/*.read"

def __post_init__(self) -> None:
if self.expiration == 0:
object.__setattr__(self, "expiration", self.issued_at + 300)

@property
def issue_time(self) -> str:
return datetime.fromtimestamp(self.issued_at, tz=UTC).isoformat()
Expand Down
25 changes: 25 additions & 0 deletions gateway-api/src/gateway_api/clinical_jwt/organization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from dataclasses import dataclass
from typing import Any

from fhir.constants import FHIRSystem


@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": FHIRSystem.ODS_CODE,
"value": self.ods_code,
}
],
"name": self.name,
}
60 changes: 27 additions & 33 deletions gateway-api/src/gateway_api/clinical_jwt/practitioner.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from dataclasses import dataclass
from typing import Any

from fhir.constants import FHIRSystem

@dataclass(kw_only=True)

@dataclass(frozen=True, kw_only=True)
class Practitioner:
id: str
sds_userid: str
Expand All @@ -12,38 +15,29 @@ 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:
user_id_system = "https://fhir.nhs.uk/Id/sds-user-id"
role_id_system = "https://fhir.nhs.uk/Id/sds-role-profile-id"
def _build_name(self) -> list[dict[str, Any]]:
"""Build the name array with proper structure for JWT."""
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 [name_dict]

outstr = f"""
{{
"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}
}}
def to_dict(self) -> dict[str, Any]:
"""
Return the Practitioner as a dictionary suitable for JWT payload.
"""
return outstr.strip()
user_id_system = FHIRSystem.SDS_USER_ID
role_id_system = FHIRSystem.SDS_ROLE_PROFILE_ID

def __str__(self) -> str:
return self.json
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": self._build_name(),
}
19 changes: 1 addition & 18 deletions gateway-api/src/gateway_api/clinical_jwt/test_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
Unit tests for :mod:`gateway_api.clinical_jwt.device`.
"""

from json import loads

from gateway_api.clinical_jwt import Device


Expand Down Expand Up @@ -35,8 +33,7 @@ def test_device_json_property_returns_valid_json_structure() -> None:
version="5.3.0",
)

json_output = input_device.json
jdict = loads(json_output)
jdict = input_device.to_dict()

output_device = Device(
system=jdict["identifier"][0]["system"],
Expand All @@ -46,17 +43,3 @@ def test_device_json_property_returns_valid_json_structure() -> None:
)

assert input_device == output_device


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
50 changes: 25 additions & 25 deletions gateway-api/src/gateway_api/clinical_jwt/test_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,18 @@ 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.algorithm is None
assert token.requesting_device == {"device": "info"}
assert token.requesting_organization == {"org": "info"}
assert token.requesting_practitioner == {"practitioner": "info"}
assert token.algorithm == "none"
assert token.type == "JWT"
assert token.reason_for_request == "directcare"
assert token.requested_scope == "patient/*.read"
Expand All @@ -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
Expand All @@ -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
)

Expand All @@ -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
)

Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand Down
Loading
Loading