import json import requests import logging import urllib.parse as urlparse from .auth import FHIRAuth FHIRJSONMimeType = "application/fhir+json" logger = logging.getLogger(__name__) class FHIRUnauthorizedException(Exception): """Indicating a 401 response.""" def __init__(self, response): self.response = response class FHIRPermissionDeniedException(Exception): """Indicating a 403 response.""" def __init__(self, response): self.response = response class FHIRNotFoundException(Exception): """Indicating a 404 response.""" def __init__(self, response): self.response = response class FHIRServer: """Handles talking to a FHIR server.""" def __init__(self, client, base_uri=None, state=None): self.client = client self.auth = None self.base_uri = None self.aud = None # Use a single requests Session for all "requests" self.session = requests.Session() # A URI can't possibly be less than 11 chars # make sure we end with "/", otherwise the last path component will be # lost when creating URLs with urllib if base_uri is not None and len(base_uri) > 10: self.base_uri = base_uri if "/" == base_uri[-1] else base_uri + "/" self.aud = base_uri self._capability = None if state is not None: self.from_state(state) if not self.base_uri or len(self.base_uri) <= 10: raise Exception( "FHIRServer must be initialized with `base_uri` or `state` containing the base-URI, but neither happened" ) def should_save_state(self): if self.client is not None: self.client.save_state() # MARK: Server CapabilityStatement @property def capabilityStatement(self): self.get_capability() return self._capability def get_capability(self, force=False): """Returns the server's CapabilityStatement, retrieving it if needed or forced. """ if self._capability is None or force: logger.info(f"Fetching CapabilityStatement from {self.base_uri}") from .models import capabilitystatement conf = capabilitystatement.CapabilityStatement.read_from("metadata", self) self._capability = conf security = None try: security = conf.rest[0].security except Exception: logger.info( "No REST security statement found in server capability statement" ) settings = { "aud": self.aud, "app_id": self.client.app_id if self.client is not None else None, "app_secret": self.client.app_secret if self.client is not None else None, "redirect_uri": self.client.redirect if self.client is not None else None, "jwt_token": self.client.jwt_token if self.client is not None else None, } self.auth = FHIRAuth.from_capability_security(security, settings) self.should_save_state() # MARK: Authorization @property def desired_scope(self): return self.client.desired_scope if self.client is not None else None @property def launch_token(self): return self.client.launch_token if self.client is not None else None @property def authorize_uri(self): if self.auth is None: self.get_capability() return self.auth.authorize_uri(self) def handle_callback(self, url): if self.auth is None: raise Exception( "Not ready to handle callback, I do not have an auth instance" ) return self.auth.handle_callback(url, self) def authorize(self): if self.auth is None: raise Exception("Not ready to authorize, I do not have an auth instance") return self.auth.authorize(self) if self.auth is not None else None def reauthorize(self): if self.auth is None: raise Exception("Not ready to reauthorize, I do not have an auth instance") return self.auth.reauthorize(self) if self.auth is not None else None # MARK: Requests @property def ready(self): """Check whether the server is ready to make calls, i.e. is has fetched its capability statement and its `auth` instance is ready. :returns: True if the server can make authenticated calls """ return self.auth.ready if self.auth is not None else False def prepare(self): """Check whether the server is ready to make calls, i.e. is has fetched its capability statement and its `auth` instance is ready. This method will fetch the capability statement if it hasn't already been fetched. :returns: True if the server can make authenticated calls """ if self.auth is None: self.get_capability() return self.auth.ready if self.auth is not None else False def request_json(self, path, nosign=False): """Perform a request for JSON data against the server's base with the given relative path. :param str path: The path to append to `base_uri` :param bool nosign: If set to True, the request will not be signed :throws: Exception on HTTP status >= 400 :returns: Decoded JSON response """ res = self._get(path, nosign=nosign) return res.json() def request_data(self, path, headers=None, nosign=False): """Perform a data request data against the server's base with the given relative path. """ res = self._get(path, headers=headers, nosign=nosign) return res.content def _get(self, path, headers=None, nosign=False): """Issues a GET request. :returns: The response object """ assert self.base_uri and path url = urlparse.urljoin(self.base_uri, path) header_defaults = { "Accept": FHIRJSONMimeType, "Accept-Charset": "UTF-8", } # merge in user headers with defaults if headers: header_defaults.update(headers) # use the merged headers in the request headers = header_defaults if not nosign and self.auth is not None and self.auth.can_sign_headers(): headers = self.auth.signed_headers(headers) # perform the request but intercept 401 responses, raising our own Exception res = self.session.get(url, headers=headers) self.raise_for_status(res) return res def put_json(self, path, resource_json, nosign=False): """Performs a PUT request of the given JSON, which should represent a resource, to the given relative path. :param str path: The path to append to `base_uri` :param dict resource_json: The JSON representing the resource :param bool nosign: If set to True, the request will not be signed :throws: Exception on HTTP status >= 400 :returns: The response object """ url = urlparse.urljoin(self.base_uri, path) headers = { "Content-type": FHIRJSONMimeType, "Accept": FHIRJSONMimeType, "Accept-Charset": "UTF-8", } if not nosign and self.auth is not None and self.auth.can_sign_headers(): headers = self.auth.signed_headers(headers) # perform the request but intercept 401 responses, raising our own Exception res = self.session.put(url, headers=headers, data=json.dumps(resource_json)) self.raise_for_status(res) return res def post_json(self, path, resource_json, nosign=False): """Performs a POST of the given JSON, which should represent a resource, to the given relative path. :param str path: The path to append to `base_uri` :param dict resource_json: The JSON representing the resource :param bool nosign: If set to True, the request will not be signed :throws: Exception on HTTP status >= 400 :returns: The response object """ url = urlparse.urljoin(self.base_uri, path) headers = { "Content-type": FHIRJSONMimeType, "Accept": FHIRJSONMimeType, "Accept-Charset": "UTF-8", } if not nosign and self.auth is not None and self.auth.can_sign_headers(): headers = self.auth.signed_headers(headers) # perform the request but intercept 401 responses, raising our own Exception res = self.session.post(url, headers=headers, data=json.dumps(resource_json)) self.raise_for_status(res) return res def post_as_form(self, url, formdata, auth=None): """Performs a POST request with form-data, expecting to receive JSON. This method is used in the OAuth2 token exchange and thus doesn't request fhir+json. :throws: Exception on HTTP status >= 400 :returns: The response object """ res = self.session.post(url, data=formdata, auth=auth) self.raise_for_status(res) return res def delete_json(self, path, nosign=False): """Issues a DELETE command against the given relative path, accepting a JSON response. :param str path: The relative URL path to issue a DELETE against :param bool nosign: If set to True, the request will not be signed :returns: The response object """ url = urlparse.urljoin(self.base_uri, path) headers = { "Accept": FHIRJSONMimeType, "Accept-Charset": "UTF-8", } if not nosign and self.auth is not None and self.auth.can_sign_headers(): headers = self.auth.signed_headers(headers) # perform the request but intercept 401 responses, raising our own Exception res = self.session.delete(url, headers=headers) self.raise_for_status(res) return res def raise_for_status(self, response): if response.status_code < 400: return if 401 == response.status_code: raise FHIRUnauthorizedException(response) elif 403 == response.status_code: raise FHIRPermissionDeniedException(response) elif 404 == response.status_code: raise FHIRNotFoundException(response) else: response.raise_for_status() # MARK: State Handling @property def state(self): """Return current state.""" return { "base_uri": self.base_uri, "auth_type": self.auth.auth_type if self.auth is not None else "none", "auth": self.auth.state if self.auth is not None else None, } def from_state(self, state): """Update ivars from given state information.""" assert state self.base_uri = state.get("base_uri") or self.base_uri self.auth = FHIRAuth.create(state.get("auth_type"), state=state.get("auth"))