Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# -*- coding: utf-8 -*- 

2 

3import json 

4import requests 

5import urllib 

6import logging 

7try: # Python 2.x 

8 import urlparse 

9except ImportError as e: # Python 3 

10 import urllib.parse as urlparse 

11 

12from auth import FHIRAuth 

13 

14FHIRJSONMimeType = 'application/fhir+json' 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19class FHIRUnauthorizedException(Exception): 

20 """ Indicating a 401 response. 

21 """ 

22 def __init__(self, response): 

23 self.response = response 

24 

25 

26class FHIRPermissionDeniedException(Exception): 

27 """ Indicating a 403 response. 

28 """ 

29 def __init__(self, response): 

30 self.response = response 

31 

32 

33class FHIRNotFoundException(Exception): 

34 """ Indicating a 404 response. 

35 """ 

36 def __init__(self, response): 

37 self.response = response 

38 

39 

40class FHIRServer(object): 

41 """ Handles talking to a FHIR server. 

42 """ 

43 

44 def __init__(self, client, base_uri=None, state=None): 

45 self.client = client 

46 self.auth = None 

47 self.base_uri = None 

48 self.aud = None 

49 

50 # Use a single requests Session for all "requests" 

51 self.session = requests.Session() 

52 

53 # A URI can't possibly be less than 11 chars 

54 # make sure we end with "/", otherwise the last path component will be 

55 # lost when creating URLs with urllib 

56 if base_uri is not None and len(base_uri) > 10: 

57 self.base_uri = base_uri if '/' == base_uri[-1] else base_uri + '/' 

58 self.aud = base_uri 

59 self._capability = None 

60 if state is not None: 

61 self.from_state(state) 

62 if not self.base_uri or len(self.base_uri) <= 10: 

63 raise Exception("FHIRServer must be initialized with `base_uri` or `state` containing the base-URI, but neither happened") 

64 

65 def should_save_state(self): 

66 if self.client is not None: 

67 self.client.save_state() 

68 

69 

70 # MARK: Server CapabilityStatement 

71 

72 @property 

73 def capabilityStatement(self): 

74 self.get_capability() 

75 return self._capability 

76 

77 def get_capability(self, force=False): 

78 """ Returns the server's CapabilityStatement, retrieving it if needed 

79 or forced. 

80 """ 

81 if self._capability is None or force: 

82 logger.info('Fetching CapabilityStatement from {0}'.format(self.base_uri)) 

83 from models import capabilitystatement 

84 conf = capabilitystatement.CapabilityStatement.read_from('metadata', self) 

85 self._capability = conf 

86 

87 security = None 

88 try: 

89 security = conf.rest[0].security 

90 except Exception as e: 

91 logger.info("No REST security statement found in server capability statement") 

92 

93 settings = { 

94 'aud': self.aud, 

95 'app_id': self.client.app_id if self.client is not None else None, 

96 'app_secret': self.client.app_secret if self.client is not None else None, 

97 'redirect_uri': self.client.redirect if self.client is not None else None, 

98 } 

99 self.auth = FHIRAuth.from_capability_security(security, settings) 

100 self.should_save_state() 

101 

102 

103 # MARK: Authorization 

104 

105 @property 

106 def desired_scope(self): 

107 return self.client.desired_scope if self.client is not None else None 

108 

109 @property 

110 def launch_token(self): 

111 return self.client.launch_token if self.client is not None else None 

112 

113 @property 

114 def authorize_uri(self): 

115 if self.auth is None: 

116 self.get_capability() 

117 return self.auth.authorize_uri(self) 

118 

119 def handle_callback(self, url): 

120 if self.auth is None: 

121 raise Exception("Not ready to handle callback, I do not have an auth instance") 

122 return self.auth.handle_callback(url, self) 

123 

124 def reauthorize(self): 

125 if self.auth is None: 

126 raise Exception("Not ready to reauthorize, I do not have an auth instance") 

127 return self.auth.reauthorize(self) if self.auth is not None else None 

128 

129 

130 # MARK: Requests 

131 

132 @property 

133 def ready(self): 

134 """ Check whether the server is ready to make calls, i.e. is has 

135 fetched its capability statement and its `auth` instance is ready. 

136  

137 :returns: True if the server can make authenticated calls 

138 """ 

139 return self.auth.ready if self.auth is not None else False 

140 

141 def prepare(self): 

142 """ Check whether the server is ready to make calls, i.e. is has 

143 fetched its capability statement and its `auth` instance is ready. 

144 This method will fetch the capability statement if it hasn't already 

145 been fetched. 

146  

147 :returns: True if the server can make authenticated calls 

148 """ 

149 if self.auth is None: 

150 self.get_capability() 

151 return self.auth.ready if self.auth is not None else False 

152 

153 def request_json(self, path, nosign=False): 

154 """ Perform a request for JSON data against the server's base with the 

155 given relative path. 

156  

157 :param str path: The path to append to `base_uri` 

158 :param bool nosign: If set to True, the request will not be signed 

159 :throws: Exception on HTTP status >= 400 

160 :returns: Decoded JSON response 

161 """ 

162 headers = {'Accept': 'application/json'} 

163 res = self._get(path, headers, nosign) 

164 

165 return res.json() 

166 

167 def request_data(self, path, headers={}, nosign=False): 

168 """ Perform a data request data against the server's base with the 

169 given relative path. 

170 """ 

171 res = self._get(path, None, nosign) 

172 return res.content 

173 

174 def _get(self, path, headers={}, nosign=False): 

175 """ Issues a GET request. 

176  

177 :returns: The response object 

178 """ 

179 assert self.base_uri and path 

180 url = urlparse.urljoin(self.base_uri, path) 

181 

182 header_defaults = { 

183 'Accept': FHIRJSONMimeType, 

184 'Accept-Charset': 'UTF-8', 

185 } 

186 # merge in user headers with defaults 

187 header_defaults.update(headers) 

188 # use the merged headers in the request 

189 headers = header_defaults 

190 if not nosign and self.auth is not None and self.auth.can_sign_headers(): 

191 headers = self.auth.signed_headers(headers) 

192 

193 # perform the request but intercept 401 responses, raising our own Exception 

194 res = self.session.get(url, headers=headers) 

195 self.raise_for_status(res) 

196 return res 

197 

198 def put_json(self, path, resource_json, nosign=False): 

199 """ Performs a PUT request of the given JSON, which should represent a 

200 resource, to the given relative path. 

201  

202 :param str path: The path to append to `base_uri` 

203 :param dict resource_json: The JSON representing the resource 

204 :param bool nosign: If set to True, the request will not be signed 

205 :throws: Exception on HTTP status >= 400 

206 :returns: The response object 

207 """ 

208 url = urlparse.urljoin(self.base_uri, path) 

209 headers = { 

210 'Content-type': FHIRJSONMimeType, 

211 'Accept': FHIRJSONMimeType, 

212 'Accept-Charset': 'UTF-8', 

213 } 

214 if not nosign and self.auth is not None and self.auth.can_sign_headers(): 

215 headers = self.auth.signed_headers(headers) 

216 

217 # perform the request but intercept 401 responses, raising our own Exception 

218 res = self.session.put(url, headers=headers, data=json.dumps(resource_json)) 

219 self.raise_for_status(res) 

220 return res 

221 

222 def post_json(self, path, resource_json, nosign=False): 

223 """ Performs a POST of the given JSON, which should represent a 

224 resource, to the given relative path. 

225  

226 :param str path: The path to append to `base_uri` 

227 :param dict resource_json: The JSON representing the resource 

228 :param bool nosign: If set to True, the request will not be signed 

229 :throws: Exception on HTTP status >= 400 

230 :returns: The response object 

231 """ 

232 url = urlparse.urljoin(self.base_uri, path) 

233 headers = { 

234 'Content-type': FHIRJSONMimeType, 

235 'Accept': FHIRJSONMimeType, 

236 'Accept-Charset': 'UTF-8', 

237 } 

238 if not nosign and self.auth is not None and self.auth.can_sign_headers(): 

239 headers = self.auth.signed_headers(headers) 

240 

241 # perform the request but intercept 401 responses, raising our own Exception 

242 res = self.session.post(url, headers=headers, data=json.dumps(resource_json)) 

243 self.raise_for_status(res) 

244 return res 

245 

246 def post_as_form(self, url, formdata, auth=None): 

247 """ Performs a POST request with form-data, expecting to receive JSON. 

248 This method is used in the OAuth2 token exchange and thus doesn't 

249 request fhir+json. 

250  

251 :throws: Exception on HTTP status >= 400 

252 :returns: The response object 

253 """ 

254 headers = { 

255 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', 

256 'Accept': 'application/json', 

257 } 

258 res = self.session.post(url, data=formdata, auth=auth) 

259 self.raise_for_status(res) 

260 return res 

261 

262 def delete_json(self, path, nosign=False): 

263 """ Issues a DELETE command against the given relative path, accepting 

264 a JSON response. 

265  

266 :param str path: The relative URL path to issue a DELETE against 

267 :param bool nosign: If set to True, the request will not be signed 

268 :returns: The response object 

269 """ 

270 url = urlparse.urljoin(self.base_uri, path) 

271 headers = { 

272 'Accept': FHIRJSONMimeType, 

273 'Accept-Charset': 'UTF-8', 

274 } 

275 if not nosign and self.auth is not None and self.auth.can_sign_headers(): 

276 headers = self.auth.signed_headers(headers) 

277 

278 # perform the request but intercept 401 responses, raising our own Exception 

279 res = self.session.delete(url) 

280 self.raise_for_status(res) 

281 return res 

282 

283 def raise_for_status(self, response): 

284 if response.status_code < 400: 

285 return 

286 

287 if 401 == response.status_code: 

288 raise FHIRUnauthorizedException(response) 

289 elif 403 == response.status_code: 

290 raise FHIRPermissionDeniedException(response) 

291 elif 404 == response.status_code: 

292 raise FHIRNotFoundException(response) 

293 else: 

294 response.raise_for_status() 

295 

296 

297 # MARK: State Handling 

298 

299 @property 

300 def state(self): 

301 """ Return current state. 

302 """ 

303 return { 

304 'base_uri': self.base_uri, 

305 'auth_type': self.auth.auth_type if self.auth is not None else 'none', 

306 'auth': self.auth.state if self.auth is not None else None, 

307 } 

308 

309 def from_state(self, state): 

310 """ Update ivars from given state information. 

311 """ 

312 assert state 

313 self.base_uri = state.get('base_uri') or self.base_uri 

314 self.auth = FHIRAuth.create(state.get('auth_type'), state=state.get('auth')) 

315