Skip to content

Commit 0a93c14

Browse files
Merge remote-tracking branch 'origin' into feature/GPCAPIM-304_update_docs
2 parents 86bf723 + 6a8c6e8 commit 0a93c14

21 files changed

Lines changed: 792 additions & 54 deletions

File tree

gateway-api/poetry.lock

Lines changed: 23 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gateway-api/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ authors = [
66
{name = "Your Name", email = "you@example.com"}
77
]
88
readme = "README.md"
9-
requires-python = ">3.13,<4.0.0"
9+
requires-python = ">=3.14,<4.0.0"
1010

1111
[tool.poetry.dependencies]
1212
clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-common.git", tag = "v0.1.0" }
1313
flask = "^3.1.2"
1414
types-flask = "^1.1.6"
1515
requests = "^2.32.5"
16+
pyjwt = "^2.11.0"
1617

1718
[tool.poetry]
1819
packages = [{include = "gateway_api", from = "src"},
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .device import Device
2+
from .jwt import JWT
3+
from .practitioner import Practitioner
4+
5+
__all__ = ["JWT", "Device", "Practitioner"]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass(frozen=True, kw_only=True)
5+
class Device:
6+
system: str
7+
value: str
8+
model: str
9+
version: str
10+
11+
@property
12+
def json(self) -> str:
13+
outstr = f"""
14+
{{
15+
"resourceType": "Device",
16+
"identifier": [
17+
{{
18+
"system": "{self.system}",
19+
"value": "{self.value}"
20+
}}
21+
],
22+
"model": "{self.model}",
23+
"version": "{self.version}"
24+
}}
25+
"""
26+
return outstr.strip()
27+
28+
def __str__(self) -> str:
29+
return self.json
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from dataclasses import dataclass, field
2+
from datetime import UTC, datetime
3+
from time import time
4+
from typing import Any
5+
6+
import jwt as pyjwt
7+
8+
9+
@dataclass(frozen=True, kw_only=True)
10+
class JWT:
11+
issuer: str
12+
subject: str
13+
audience: str
14+
requesting_device: str
15+
requesting_organization: str
16+
requesting_practitioner: str
17+
18+
# Time fields
19+
issued_at: int = field(default_factory=lambda: int(time()))
20+
expiration: int = field(default_factory=lambda: int(time()) + 300)
21+
22+
# These are here for future proofing but are not expected ever to be changed
23+
algorithm: str | None = None
24+
type: str = "JWT"
25+
reason_for_request: str = "directcare"
26+
requested_scope: str = "patient/*.read"
27+
28+
@property
29+
def issue_time(self) -> str:
30+
return datetime.fromtimestamp(self.issued_at, tz=UTC).isoformat()
31+
32+
@property
33+
def exp_time(self) -> str:
34+
return datetime.fromtimestamp(self.expiration, tz=UTC).isoformat()
35+
36+
def encode(self) -> str:
37+
return pyjwt.encode(
38+
self.payload(),
39+
key=None,
40+
algorithm=self.algorithm,
41+
headers={"typ": self.type},
42+
)
43+
44+
@staticmethod
45+
def decode(token: str) -> "JWT":
46+
token_dict = pyjwt.decode(
47+
token,
48+
options={"verify_signature": False}, # NOSONAR S5659 (not signed)
49+
)
50+
51+
return JWT(
52+
issuer=token_dict["iss"],
53+
subject=token_dict["sub"],
54+
audience=token_dict["aud"],
55+
expiration=token_dict["exp"],
56+
issued_at=token_dict["iat"],
57+
requesting_device=token_dict["requesting_device"],
58+
requesting_organization=token_dict["requesting_organization"],
59+
requesting_practitioner=token_dict["requesting_practitioner"],
60+
)
61+
62+
def payload(self) -> dict[str, Any]:
63+
return {
64+
"iss": self.issuer,
65+
"sub": self.subject,
66+
"aud": self.audience,
67+
"exp": self.expiration,
68+
"iat": self.issued_at,
69+
"requesting_device": self.requesting_device,
70+
"requesting_organization": self.requesting_organization,
71+
"requesting_practitioner": self.requesting_practitioner,
72+
"reason_for_request": self.reason_for_request,
73+
"requested_scope": self.requested_scope,
74+
}
75+
76+
def __str__(self) -> str:
77+
return self.encode()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass(kw_only=True)
5+
class Practitioner:
6+
id: str
7+
sds_userid: str
8+
role_profile_id: str
9+
userid_url: str
10+
userid_value: str
11+
family_name: str
12+
given_name: str | None = None
13+
prefix: str | None = None
14+
15+
def __post_init__(self) -> None:
16+
given = "" if self.given_name is None else f',"given":["{self.given_name}"]'
17+
prefix = "" if self.prefix is None else f',"prefix":["{self.prefix}"]'
18+
self._name_str = f'[{{"family": "{self.family_name}"{given}{prefix}}}]'
19+
20+
@property
21+
def json(self) -> str:
22+
user_id_system = "https://fhir.nhs.uk/Id/sds-user-id"
23+
role_id_system = "https://fhir.nhs.uk/Id/sds-role-profile-id"
24+
25+
outstr = f"""
26+
{{
27+
"resourceType": "Practitioner",
28+
"id": "{self.id}",
29+
"identifier": [
30+
{{
31+
"system": "{user_id_system}",
32+
"value": "{self.sds_userid}"
33+
}},
34+
{{
35+
"system": "{role_id_system}",
36+
"value": "{self.role_profile_id}"
37+
}},
38+
{{
39+
"system": "{self.userid_url}",
40+
"value": "{self.userid_value}"
41+
}}
42+
],
43+
"name": {self._name_str}
44+
}}
45+
"""
46+
return outstr.strip()
47+
48+
def __str__(self) -> str:
49+
return self.json
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Unit tests for :mod:`gateway_api.clinical_jwt.device`.
3+
"""
4+
5+
from json import loads
6+
7+
from gateway_api.clinical_jwt import Device
8+
9+
10+
def test_device_creation_with_all_required_fields() -> None:
11+
"""
12+
Test that a Device instance can be created with all required fields.
13+
"""
14+
device = Device(
15+
system="https://consumersupplier.com/Id/device-identifier",
16+
value="CONS-APP-4",
17+
model="Consumer product name",
18+
version="5.3.0",
19+
)
20+
21+
assert device.system == "https://consumersupplier.com/Id/device-identifier"
22+
assert device.value == "CONS-APP-4"
23+
assert device.model == "Consumer product name"
24+
assert device.version == "5.3.0"
25+
26+
27+
def test_device_json_property_returns_valid_json_structure() -> None:
28+
"""
29+
Test that the json property returns a valid JSON structure for requesting_device.
30+
"""
31+
input_device = Device(
32+
system="https://consumersupplier.com/Id/device-identifier",
33+
value="CONS-APP-4",
34+
model="Consumer product name",
35+
version="5.3.0",
36+
)
37+
38+
json_output = input_device.json
39+
jdict = loads(json_output)
40+
41+
output_device = Device(
42+
system=jdict["identifier"][0]["system"],
43+
value=jdict["identifier"][0]["value"],
44+
model=jdict["model"],
45+
version=jdict["version"],
46+
)
47+
48+
assert input_device == output_device
49+
50+
51+
def test_device_str_returns_json() -> None:
52+
"""
53+
Test that __str__ returns the same value as the json property.
54+
"""
55+
device = Device(
56+
system="https://test.com/device",
57+
value="TEST-001",
58+
model="Test Model",
59+
version="1.0.0",
60+
)
61+
62+
assert str(device) == device.json

0 commit comments

Comments
 (0)