Skip to content

Commit f75cafa

Browse files
committed
cpd
1 parent d572bc7 commit f75cafa

File tree

5 files changed

+452
-1
lines changed

5 files changed

+452
-1
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import base64
2+
import json
3+
import jwt
4+
import requests
5+
import time
6+
import uuid
7+
from enum import Enum
8+
9+
from cache import Cache
10+
from models.errors import UnhandledResponseError
11+
12+
13+
class Service(Enum):
14+
PDS = "pds"
15+
IMMUNIZATION = "imms"
16+
17+
18+
class AppRestrictedAuth:
19+
def __init__(self, service: Service, secret_manager_client, environment, cache: Cache):
20+
self.secret_manager_client = secret_manager_client
21+
self.cache = cache
22+
self.cache_key = f"{service.value}_access_token"
23+
24+
self.expiry = 30
25+
self.secret_name = f"imms/pds/{environment}/jwt-secrets" if service == Service.PDS else \
26+
f"imms/immunization/{environment}/jwt-secrets"
27+
28+
self.token_url = f"https://{environment}.api.service.nhs.uk/oauth2/token" \
29+
if environment != "prod" else "https://api.service.nhs.uk/oauth2/token"
30+
31+
def get_service_secrets(self):
32+
kwargs = {"SecretId": self.secret_name}
33+
response = self.secret_manager_client.get_secret_value(**kwargs)
34+
secret_object = json.loads(response['SecretString'])
35+
secret_object['private_key'] = base64.b64decode(secret_object['private_key_b64']).decode()
36+
37+
return secret_object
38+
39+
def create_jwt(self, now: int):
40+
secret_object = self.get_service_secrets()
41+
claims = {
42+
"iss": secret_object['api_key'],
43+
"sub": secret_object['api_key'],
44+
"aud": self.token_url,
45+
"iat": now,
46+
"exp": now + self.expiry,
47+
"jti": str(uuid.uuid4())
48+
}
49+
return jwt.encode(claims, secret_object['private_key'], algorithm='RS512',
50+
headers={"kid": secret_object['kid']})
51+
52+
def get_access_token(self):
53+
now = int(time.time())
54+
cached = self.cache.get(self.cache_key)
55+
if cached and cached["expires_at"] > now:
56+
return cached["token"]
57+
58+
_jwt = self.create_jwt(now)
59+
60+
headers = {
61+
'Content-Type': 'application/x-www-form-urlencoded'
62+
}
63+
data = {
64+
'grant_type': 'client_credentials',
65+
'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
66+
'client_assertion': _jwt
67+
}
68+
token_response = requests.post(self.token_url, data=data, headers=headers)
69+
if token_response.status_code != 200:
70+
raise UnhandledResponseError(response=token_response.text, message="Failed to get access token")
71+
72+
token = token_response.json().get('access_token')
73+
74+
self.cache.put(self.cache_key, {"token": token, "expires_at": now + self.expiry})
75+
76+
return token
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import uuid
2+
from dataclasses import dataclass
3+
from enum import Enum
4+
from typing import Union
5+
6+
7+
class Severity(str, Enum):
8+
error = "error"
9+
warning = "warning"
10+
11+
12+
class Code(str, Enum):
13+
forbidden = "forbidden"
14+
not_found = "not-found"
15+
invalid = "invalid"
16+
server_error = "exception"
17+
invariant = "invariant"
18+
not_supported = "not-supported"
19+
duplicate = "duplicate"
20+
# Added an unauthorized code its used when returning a response for an unauthorized vaccine type search.
21+
unauthorized = "unauthorized"
22+
23+
24+
@dataclass
25+
class UnauthorizedError(RuntimeError):
26+
@staticmethod
27+
def to_operation_outcome() -> dict:
28+
msg = f"Unauthorized request"
29+
return create_operation_outcome(
30+
resource_id=str(uuid.uuid4()),
31+
severity=Severity.error,
32+
code=Code.forbidden,
33+
diagnostics=msg,
34+
)
35+
36+
37+
@dataclass
38+
class UnauthorizedVaxError(RuntimeError):
39+
@staticmethod
40+
def to_operation_outcome() -> dict:
41+
msg = "Unauthorized request for vaccine type"
42+
return create_operation_outcome(
43+
resource_id=str(uuid.uuid4()),
44+
severity=Severity.error,
45+
code=Code.forbidden,
46+
diagnostics=msg,
47+
)
48+
49+
50+
@dataclass
51+
class UnauthorizedVaxOnRecordError(RuntimeError):
52+
@staticmethod
53+
def to_operation_outcome() -> dict:
54+
msg = "Unauthorized request for vaccine type present in the stored immunization resource"
55+
return create_operation_outcome(
56+
resource_id=str(uuid.uuid4()),
57+
severity=Severity.error,
58+
code=Code.forbidden,
59+
diagnostics=msg,
60+
)
61+
62+
63+
@dataclass
64+
class ResourceNotFoundError(RuntimeError):
65+
"""Return this error when the requested FHIR resource does not exist"""
66+
67+
resource_type: str
68+
resource_id: str
69+
70+
def __str__(self):
71+
return f"{self.resource_type} resource does not exist. ID: {self.resource_id}"
72+
73+
def to_operation_outcome(self) -> dict:
74+
return create_operation_outcome(
75+
resource_id=str(uuid.uuid4()),
76+
severity=Severity.error,
77+
code=Code.not_found,
78+
diagnostics=self.__str__(),
79+
)
80+
81+
82+
@dataclass
83+
class ResourceFoundError(RuntimeError):
84+
"""Return this error when the requested FHIR resource does exist"""
85+
86+
resource_type: str
87+
resource_id: str
88+
89+
def __str__(self):
90+
return f"{self.resource_type} resource does exist. ID: {self.resource_id}"
91+
92+
def to_operation_outcome(self) -> dict:
93+
return create_operation_outcome(
94+
resource_id=str(uuid.uuid4()),
95+
severity=Severity.error,
96+
code=Code.not_found,
97+
diagnostics=self.__str__(),
98+
)
99+
100+
101+
@dataclass
102+
class UnhandledResponseError(RuntimeError):
103+
"""Use this error when the response from an external service (ex: dynamodb) can't be handled"""
104+
105+
response: Union[dict, str]
106+
message: str
107+
108+
def __str__(self):
109+
return f"{self.message}\n{self.response}"
110+
111+
def to_operation_outcome(self) -> dict:
112+
return create_operation_outcome(
113+
resource_id=str(uuid.uuid4()),
114+
severity=Severity.error,
115+
code=Code.server_error,
116+
diagnostics=self.__str__(),
117+
)
118+
119+
120+
class MandatoryError(Exception):
121+
def __init__(self, message=None):
122+
self.message = message
123+
124+
125+
class ValidationError(RuntimeError):
126+
def to_operation_outcome(self) -> dict:
127+
pass
128+
129+
130+
@dataclass
131+
class InvalidPatientId(ValidationError):
132+
"""Use this when NHS Number is invalid or doesn't exist"""
133+
134+
patient_identifier: str
135+
136+
def __str__(self):
137+
return f"NHS Number: {self.patient_identifier} is invalid or it doesn't exist."
138+
139+
def to_operation_outcome(self) -> dict:
140+
return create_operation_outcome(
141+
resource_id=str(uuid.uuid4()),
142+
severity=Severity.error,
143+
code=Code.server_error,
144+
diagnostics=self.__str__(),
145+
)
146+
147+
148+
@dataclass
149+
class InconsistentIdError(ValidationError):
150+
"""Use this when the specified id in the message is inconsistent with the path
151+
see: http://hl7.org/fhir/R4/http.html#update"""
152+
153+
imms_id: str
154+
155+
def __str__(self):
156+
return f"The provided id:{self.imms_id} doesn't match with the content of the message"
157+
158+
def to_operation_outcome(self) -> dict:
159+
return create_operation_outcome(
160+
resource_id=str(uuid.uuid4()),
161+
severity=Severity.error,
162+
code=Code.server_error,
163+
diagnostics=self.__str__(),
164+
)
165+
166+
167+
@dataclass
168+
class CustomValidationError(ValidationError):
169+
"""Custom validation error"""
170+
171+
message: str
172+
173+
def __str__(self):
174+
return self.message
175+
176+
def to_operation_outcome(self) -> dict:
177+
return create_operation_outcome(
178+
resource_id=str(uuid.uuid4()),
179+
severity=Severity.error,
180+
code=Code.invariant,
181+
diagnostics=self.__str__(),
182+
)
183+
184+
185+
@dataclass
186+
class IdentifierDuplicationError(RuntimeError):
187+
"""Fine grain validation"""
188+
189+
identifier: str
190+
191+
def __str__(self) -> str:
192+
return f"The provided identifier: {self.identifier} is duplicated"
193+
194+
def to_operation_outcome(self) -> dict:
195+
msg = self.__str__()
196+
return create_operation_outcome(
197+
resource_id=str(uuid.uuid4()),
198+
severity=Severity.error,
199+
code=Code.duplicate,
200+
diagnostics=msg,
201+
)
202+
203+
204+
def create_operation_outcome(resource_id: str, severity: Severity, code: Code, diagnostics: str) -> dict:
205+
"""Create an OperationOutcome object. Do not use `fhir.resource` library since it adds unnecessary validations"""
206+
return {
207+
"resourceType": "OperationOutcome",
208+
"id": resource_id,
209+
"meta": {"profile": ["https://simplifier.net/guide/UKCoreDevelopment2/ProfileUKCore-OperationOutcome"]},
210+
"issue": [
211+
{
212+
"severity": severity,
213+
"code": code,
214+
"details": {
215+
"coding": [
216+
{
217+
"system": "https://fhir.nhs.uk/Codesystem/http-error-codes",
218+
"code": code.upper(),
219+
}
220+
]
221+
},
222+
"diagnostics": diagnostics,
223+
}
224+
],
225+
}
226+
227+
228+
@dataclass
229+
class ParameterException(RuntimeError):
230+
message: str
231+
232+
def __str__(self):
233+
return self.message
234+
235+
236+
class UnauthorizedSystemError(RuntimeError):
237+
def __init__(self, message="Unauthorized system"):
238+
super().__init__(message)
239+
self.message = message
240+
241+
def to_operation_outcome(self) -> dict:
242+
return create_operation_outcome(
243+
resource_id=str(uuid.uuid4()),
244+
severity=Severity.error,
245+
code=Code.forbidden,
246+
diagnostics=self.message,
247+
)
248+
249+
250+
class MessageNotSuccessfulError(Exception):
251+
"""
252+
Generic error message for any scenario which either prevents sending to the Imms API, or which results in a
253+
non-successful response from the Imms API
254+
"""
255+
256+
def __init__(self, message=None):
257+
self.message = message
258+
259+
260+
class RecordProcessorError(Exception):
261+
"""
262+
Exception for re-raising exceptions which have already occurred in the Record Processor.
263+
The diagnostics dictionary received from the Record Processor is passed to the exception as an argument
264+
and is stored as an attribute.
265+
"""
266+
267+
def __init__(self, diagnostics_dictionary: dict):
268+
self.diagnostics_dictionary = diagnostics_dictionary
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import requests
2+
import uuid
3+
4+
from authentication import AppRestrictedAuth
5+
from models.errors import UnhandledResponseError
6+
7+
8+
class PdsService:
9+
def __init__(self, authenticator: AppRestrictedAuth, environment):
10+
self.authenticator = authenticator
11+
12+
self.base_url = f"https://{environment}.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient" \
13+
if environment != "prod" else "https://api.service.nhs.uk/personal-demographics/FHIR/R4/Patient"
14+
15+
def get_patient_details(self, patient_id) -> dict | None:
16+
access_token = self.authenticator.get_access_token()
17+
request_headers = {
18+
'Authorization': f'Bearer {access_token}',
19+
'X-Request-ID': str(uuid.uuid4()),
20+
'X-Correlation-ID': str(uuid.uuid4())
21+
}
22+
response = requests.get(f"{self.base_url}/{patient_id}", headers=request_headers, timeout=5)
23+
24+
if response.status_code == 200:
25+
return response.json()
26+
elif response.status_code == 404:
27+
return None
28+
else:
29+
msg = "Downstream service failed to validate the patient"
30+
raise UnhandledResponseError(response=response.json(), message=msg)

0 commit comments

Comments
 (0)