Coverage for /home/martinb/workspace/client-py/fhirclient/server.py : 36%

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 -*-
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
12from auth import FHIRAuth
14FHIRJSONMimeType = 'application/fhir+json'
16logger = logging.getLogger(__name__)
19class FHIRUnauthorizedException(Exception):
20 """ Indicating a 401 response.
21 """
22 def __init__(self, response):
23 self.response = response
26class FHIRPermissionDeniedException(Exception):
27 """ Indicating a 403 response.
28 """
29 def __init__(self, response):
30 self.response = response
33class FHIRNotFoundException(Exception):
34 """ Indicating a 404 response.
35 """
36 def __init__(self, response):
37 self.response = response
40class FHIRServer(object):
41 """ Handles talking to a FHIR server.
42 """
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
50 # Use a single requests Session for all "requests"
51 self.session = requests.Session()
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")
65 def should_save_state(self):
66 if self.client is not None:
67 self.client.save_state()
70 # MARK: Server CapabilityStatement
72 @property
73 def capabilityStatement(self):
74 self.get_capability()
75 return self._capability
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
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")
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()
103 # MARK: Authorization
105 @property
106 def desired_scope(self):
107 return self.client.desired_scope if self.client is not None else None
109 @property
110 def launch_token(self):
111 return self.client.launch_token if self.client is not None else None
113 @property
114 def authorize_uri(self):
115 if self.auth is None:
116 self.get_capability()
117 return self.auth.authorize_uri(self)
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)
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
130 # MARK: Requests
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.
137 :returns: True if the server can make authenticated calls
138 """
139 return self.auth.ready if self.auth is not None else False
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.
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
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.
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)
165 return res.json()
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
174 def _get(self, path, headers={}, nosign=False):
175 """ Issues a GET request.
177 :returns: The response object
178 """
179 assert self.base_uri and path
180 url = urlparse.urljoin(self.base_uri, path)
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)
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
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.
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)
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
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.
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)
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
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.
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
262 def delete_json(self, path, nosign=False):
263 """ Issues a DELETE command against the given relative path, accepting
264 a JSON response.
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)
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
283 def raise_for_status(self, response):
284 if response.status_code < 400:
285 return
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()
297 # MARK: State Handling
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 }
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'))