diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b051c6c --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +client diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..afb764e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.9.6 + +LABEL version="1.0" +LABEL description="Demo of a Medicare claims data sample app" + +WORKDIR / + +COPY . . + +RUN pip install --upgrade pip +RUN pip install -r requirements/req.dev.txt +RUN pip install debugpy + +EXPOSE 3001 + +CMD ["sh", "-c", "python -m debugpy --listen 0.0.0.0:5678 app.py"] \ No newline at end of file diff --git a/README.md b/README.md index 7a6dc54..bfbbb76 100644 --- a/README.md +++ b/README.md @@ -36,18 +36,45 @@ The configuration parameters are: - The app's callback url - The version number of the API - The app's environment (the BB2.0 web location where the app is registered) +- The FHIR call retry settings +- Enable / Disable Token refresh if FHIR request processing detected the access token expired -| Parameter | Value | Comments | -| ------------ | ----------------------- | ------------------------------- | -| environment | "SANDBOX" or "PRODUCTION" | BB2 API environment (default="SANDBOX") -| version | 1 or 2 | BB2 API version (default=2) | -| client_id | "foo" | oauth2 client id of the app | -| client_secret | "bar" | oauth2 client secret of the app | -| callback_url | "https://www.fake.com/callback" | oauth2 callback URL of the app | +| Parameter | Value | Comments | +| ------------- | ------------------------------- | --------------------------------------- | +| environment | "SANDBOX" or "PRODUCTION" | BB2 API environment (default="SANDBOX") | +| version | 1 or 2 | BB2 API version (default=2) | +| client_id | "foo" | oauth2 client id of the app | +| client_secret | "bar" | oauth2 client secret of the app | +| callback_url | "https://www.fake.com/callback" | oauth2 callback URL of the app | For application registration and client id and client secret, please refer to: [Blue Button 2.0 API Docs - Try the API](https://bluebutton.cms.gov/developers/#try-the-api) +Auth Token Refresh on Expire: + +SDK FHIR requests will check if the access token is expired before the data end point call, if the access token is expired, then a token refresh is performed with the refresh token in the current auth token object, this behavior can be disabled by setting configuration parameter as below: + +"token_refresh_on_expire": false + +By default, token_refresh_on_expire is true. + +FHIR requests retry: + +Retry is enabled by default for FHIR requests, retry_settings: parameters for exponential back off retry algorithm + +| retry parameter | value (default) | Comments | +| ----------------- | -------------------- | -------------------------------- | +| backoff_factor | 5 | back off factor in seconds | +| total | 3 | max retries | +| status_forcelist | [500, 502, 503, 504] | error response codes to retry on | + +the exponential back off factor (in seconds) is used to calculate interval between retries by below formular, where i starts from 0: + +backoff factor * (2 ** (i - 1)) + +e.g. for backoff_factor is 5 seconds, it will generate wait intervals: 2.5, 5, 10, ... + +to disable the retry: set total = 0 There are three ways to configure the SDK when instantiating a `BlueButton` class instance: @@ -62,6 +89,11 @@ There are three ways to configure the SDK when instantiating a `BlueButton` clas "client_secret": "bar", "callback_url": "https://www.fake.com/callback", "version": 2, + "retry_settings": { + "total": 3, + "backoff_factor": 5, + "status_forcelist": [500, 502, 503, 504] + } } ``` * JSON config file: @@ -79,7 +111,12 @@ There are three ways to configure the SDK when instantiating a `BlueButton` clas "client_id": "foo", "client_secret": "bar", "callback_url": "https://www.fake.com/callback", - "version": 2 + "version": 2, + "retry_settings": { + "total": 3, + "backoff_factor": 5, + "status_forcelist": [500, 502, 503, 504] + } } ``` @@ -150,7 +187,7 @@ def authorization_callback(): Check the scope of the current access token as shown below: """ - scopes = auth_token.scope; + scopes = auth_token.scope # iterate scope entries here or check if a permission is in the scope if "patient/Patient.read" in scopes: @@ -185,6 +222,22 @@ def authorization_callback(): try: eob_data = bb.get_explaination_of_benefit_data(config) result['eob_data'] = eob_data['response'].json() + eob_data = eob_data['response'].json() + result['eob_data'] = eob_data + + # fhir search response can contain large number of resources, + # e.g. it is not unusual an EOB search of a beneficiary would result + # in hundreds of EOB resources, by default they are chunked into pages + # of 10 resources each, e.g. the above call bb.get_explaination_of_benefit_data(config) + # return the 1st page of EOBs, in the format of a FHIR bundle resource + # with a link section where page navigation urls with the link name as: + # 'first', 'last', 'self', 'next', 'previous', which indicating the + # pagination relation relative to the current page. + + # Use bb.get_pages(data, config) to get all the pages + eob_pages = bb.get_pages(eob_data, config) + result['eob_pages'] = eob_pages['pages'] + auth_token = eob_pages['auth_token'] pt_data = bb.get_patient_data(config) result['patient_data'] = pt_data['response'].json() diff --git a/app.py b/app.py new file mode 100644 index 0000000..3810ae3 --- /dev/null +++ b/app.py @@ -0,0 +1,94 @@ +from flask import redirect, request, Flask +from cms_bluebutton.cms_bluebutton import BlueButton + + +app = Flask(__name__) +bb = BlueButton() + +auth_data = bb.generate_auth_data() + +# AuthorizationToken holds access grant info: +# access token, expire in, expire at, token type, scope, refreh token, etc. +# it is associated with current logged in user in real app, +# check SDK python docs for more details. + +auth_token = None + + +@app.route('/', methods=['GET']) +def get_auth_url(): + redirect_url = bb.generate_authorize_url(auth_data) + return redirect(redirect_url, code=302) + + +@app.route('/api/bluebutton/callback/', methods=['GET']) +def authorization_callback(): + request_query = request.args + code = request_query.get('code') + state = request_query.get('state') + + auth_token = bb.get_authorization_token(auth_data, code, state) + + print("============== check auth token =================") + print(auth_token.get_dict()) + + # pre-emptively refresh token + print("============== pre-emptively refresh auth token =================") + auth_token = bb.refresh_auth_token(auth_token) + + print("============== check refreshed auth token =================") + print(auth_token.get_dict()) + + config = { + "auth_token": auth_token, + "params": {}, + "url": "to be overriden" + } + + result = {} + + print("============== before data requests =================") + # fetch eob, patient, coverage, profile + try: + eob_data = bb.get_explaination_of_benefit_data(config) + print("============== EOB pass auth token =================") + auth_token = eob_data['auth_token'] + print("============== after EOB request =================") + eob_data = eob_data['response'].json() + result['eob_data'] = eob_data + # fhir search response could contain large number of resources, + # by default they are chunked into pages of 10 resources each, + # the response above might be the 1st page of EOBs, it is in the + # format of a FHIR bundle resource with a link section where + # page navigation urls such as 'first', 'last', 'self', 'next', 'previous' + # might present depending on the current page. + + # Use bb.get_pages(data, config) to get all the pages + + print("============== get pages EOB request =================") + eob_pages = bb.get_pages(eob_data, config) + result['eob_pages'] = eob_pages['pages'] + auth_token = eob_pages['auth_token'] + pt_data = bb.get_patient_data(config) + print("============== Patient pass auth token =================") + auth_token = pt_data['auth_token'] + print("============== after Patient request =================") + result['patient_data'] = pt_data['response'].json() + coverage_data = bb.get_coverage_data(config) + print("============== Coverage pass auth token =================") + auth_token = coverage_data['auth_token'] + print("============== after Coverage request =================") + result['coverage_data'] = coverage_data['response'].json() + profile_data = bb.get_profile_data(config) + print("============== Profile pass auth token =================") + auth_token = profile_data['auth_token'] + print("============== after Profile request =================") + result['profile_data'] = profile_data['response'].json() + except Exception as ex: + print(ex) + + return result + + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=3001) diff --git a/cms_bluebutton/auth.py b/cms_bluebutton/auth.py index 808597a..ac29df2 100755 --- a/cms_bluebutton/auth.py +++ b/cms_bluebutton/auth.py @@ -42,8 +42,7 @@ def set_dict(self, auth_token_dict): auth_token_dict.get("expires_at") ).astimezone(datetime.timezone.utc) else: - self.expires_at = datetime.datetime.now(datetime.timezone.utc) - +datetime.timedelta(seconds=self.expires_in) + self.expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=self.expires_in) self.patient = auth_token_dict.get("patient") self.refresh_token = auth_token_dict.get("refresh_token") @@ -51,27 +50,18 @@ def set_dict(self, auth_token_dict): self.token_type = auth_token_dict.get("token_type") -def refresh_auth_token(bb, auth_token): +def refresh_auth_token(bb, auth_token) -> AuthorizationToken: data = { "client_id": bb.client_id, "grant_type": "refresh_token", "refresh_token": auth_token.refresh_token, } - - headers = SDK_HEADERS - - token_response = requests.post( - url=bb.auth_token_url, - data=data, - headers=headers, - auth=(bb.client_id, bb.client_secret), - ) - + token_response = _do_post(data, bb, (bb.client_id, bb.client_secret)) token_response.raise_for_status() return AuthorizationToken(token_response.json()) -def generate_authorize_url(bb, auth_data): +def generate_authorize_url(bb, auth_data) -> str: params = { "client_id": bb.client_id, "redirect_uri": bb.callback_url, @@ -94,13 +84,13 @@ def base64_url_encode(buffer): return buffer_result -def get_random_string(length): +def get_random_string(length) -> str: letters = string.ascii_letters + string.digits + string.punctuation result = "".join(random.choice(letters) for i in range(length)) return result -def generate_pkce_data(): +def generate_pkce_data() -> dict: verifier = generate_random_state(32) code_challenge = base64.urlsafe_b64encode( hashlib.sha256(verifier.encode("ASCII")).digest() @@ -108,17 +98,17 @@ def generate_pkce_data(): return {"code_challenge": code_challenge.decode("utf-8"), "verifier": verifier} -def generate_random_state(num): +def generate_random_state(num) -> str: return base64_url_encode(get_random_string(num)) -def generate_auth_data(): +def generate_auth_data() -> dict: auth_data = {"state": generate_random_state(32)} auth_data.update(generate_pkce_data()) return auth_data -def get_access_token_from_code(bb, auth_data, callback_code): +def get_access_token_from_code(bb, auth_data, callback_code) -> dict: data = { "client_id": bb.client_id, "client_secret": bb.client_secret, @@ -129,14 +119,7 @@ def get_access_token_from_code(bb, auth_data, callback_code): "code_challenge": auth_data["code_challenge"], } - mp_encoder = MultipartEncoder(data) - headers = SDK_HEADERS - headers["content-type"] = mp_encoder.content_type - token_response = requests.post( - url=bb.auth_token_url, - data=mp_encoder, - headers=headers - ) + token_response = _do_post(data, bb, None) token_response.raise_for_status() token_dict = token_response.json() token_dict["expires_at"] = datetime.datetime.now( @@ -157,3 +140,19 @@ def get_authorization_token(bb, auth_data, callback_code, callback_state): raise ValueError("Provided callback state does not match.") return AuthorizationToken(get_access_token_from_code(bb, auth_data, callback_code)) + + +def _do_post(data, bb, auth): + mp_encoder = MultipartEncoder(data) + headers = SDK_HEADERS + headers["content-type"] = mp_encoder.content_type + return requests.post( + url=bb.auth_token_url, + data=mp_encoder, + headers=headers + ) if not auth else requests.post( + url=bb.auth_token_url, + data=mp_encoder, + headers=headers, + auth=auth + ) diff --git a/cms_bluebutton/cms_bluebutton.py b/cms_bluebutton/cms_bluebutton.py index 9fe2456..2d71d86 100644 --- a/cms_bluebutton/cms_bluebutton.py +++ b/cms_bluebutton/cms_bluebutton.py @@ -28,6 +28,11 @@ def __init__(self, config=DEFAULT_CONFIG_FILE_LOCATION): self.client_secret = None self.callback_url = None self.version = 2 # Default to BB2 version 2 + self.token_refresh_on_expire = True + # initilized with default + self.retry_config = {"total": 3, + "backoff_factor": 5, + "status_forcelist": [500, 502, 503, 504]} self.base_url = None @@ -64,12 +69,12 @@ def set_configuration(self, config): # Check environment setting env = config_dict.get("environment", None) - if env in ["SANDBOX", "PRODUCTION"]: + if env in ["SANDBOX", "PRODUCTION", "LOCAL", "TEST"]: self.base_url = ENVIRONMENT_URLS.get(env, None) else: raise ValueError( "Error: Configuration environment must be set to" - " SANDBOX or PRODUCTION in: {}".format(config) + " SANDBOX or PRODUCTION or LOCAL in: {}".format(config) ) # Check other settings are provided @@ -88,6 +93,13 @@ def set_configuration(self, config): self.version = config_dict.get("version", 2) self.auth_base_url = "{}/v{}/o/authorize".format(self.base_url, self.version) self.auth_token_url = "{}/v{}/o/token/".format(self.base_url, self.version) + self.token_refresh_on_expire = config_dict.get("token_refresh_on_expire", True) + retrycfg = config_dict.get("retry_settings") + if retrycfg: + # override default with normalization + self.retry_config["total"] = retrycfg.get("total", 3) + self.retry_config["backoff_factor"] = retrycfg.get("backoff_factor", 5) + self.retry_config["status_forcelist"] = retrycfg.get("status_forcelist", [500, 502, 503, 504]) def get_patient_data(self, config): config["url"] = FHIR_RESOURCE_TYPE["Patient"] @@ -105,6 +117,30 @@ def get_profile_data(self, config): config["url"] = FHIR_RESOURCE_TYPE["Profile"] return fhir_request(self, config) + def extract_page_nav_url(self, data, relation): + if data and data['resourceType'] == "Bundle" and data['type'] == "searchset" and data['link']: + for lnk in data['link']: + if lnk['relation'] == relation: + return lnk['url'] + return None + + def extract_next_page_url(self, data): + return self.extract_page_nav_url(data, 'next') + + def get_pages(self, data, config): + bundle = data + pages = [bundle] + page_url = self.extract_next_page_url(bundle) + auth_token = config["auth_token"] + while page_url: + config["url"] = page_url + next_page = fhir_request(self, config) + bundle = next_page['response'].json() + auth_token = next_page["auth_token"] + pages.append(bundle) + page_url = self.extract_next_page_url(bundle) + return {"auth_token": auth_token, "pages": pages} + def get_custom_data(self, config): return fhir_request(self, config) diff --git a/cms_bluebutton/constants.py b/cms_bluebutton/constants.py index 2b4be4f..b143e8f 100644 --- a/cms_bluebutton/constants.py +++ b/cms_bluebutton/constants.py @@ -10,6 +10,8 @@ ENVIRONMENT_URLS = { "SANDBOX": "https://sandbox.bluebutton.cms.gov", "PRODUCTION": "https://api.bluebutton.cms.gov", + "TEST": "https://test.bluebutton.cms.gov", + "LOCAL": "http://localhost:8000", } # Supported FHIR resource paths diff --git a/cms_bluebutton/fhir_request.py b/cms_bluebutton/fhir_request.py index db2465f..7bf7c8b 100644 --- a/cms_bluebutton/fhir_request.py +++ b/cms_bluebutton/fhir_request.py @@ -1,33 +1,42 @@ import requests from requests.adapters import HTTPAdapter, Retry - +from .auth import refresh_auth_token from .constants import SDK_HEADERS def fhir_request(bb, config): auth_token = config["auth_token"] - new_auth_token = handle_expired(bb, auth_token) + if bb.token_refresh_on_expire: + auth_token = handle_expired(bb, auth_token) + + url_param = config["url"] + full_url = None - if new_auth_token is not None: - auth_token = new_auth_token + if url_param.startswith(bb.base_url): + # allow full url passed in from config as long as it roots from base url + full_url = url_param + else: + full_url = "{}/v{}/{}".format(bb.base_url, bb.version, config["url"]) - retry_config = Retry( - total=3, backoff_factor=5, status_forcelist=[500, 502, 503, 504] - ) - full_url = "{}/v{}/{}".format(bb.base_url, bb.version, config["url"]) headers = SDK_HEADERS headers["Authorization"] = "Bearer " + auth_token.access_token - adapter = HTTPAdapter(max_retries=retry_config) + + adapter = HTTPAdapter() + + if bb.retry_config.get("total") > 0: + adapter = HTTPAdapter(max_retries=Retry( + total=bb.retry_config.get("total"), + backoff_factor=bb.retry_config.get("backoff_factor"), + status_forcelist=bb.retry_config.get("status_forcelist") + )) + sesh = requests.Session() sesh.mount("https://", adapter) sesh.mount("http://", adapter) response = sesh.get(url=full_url, params=config["params"], headers=headers) - return {"auth_token": new_auth_token, "response": response} + return {"auth_token": auth_token, "response": response} def handle_expired(bb, auth_token): - if auth_token.access_token_expired(): - return bb.refresh_auth_token(auth_token) - else: - return None + return refresh_auth_token(bb, auth_token) if auth_token.access_token_expired() else auth_token diff --git a/cms_bluebutton/tests/test_configs.py b/cms_bluebutton/tests/test_configs.py index 1b8de50..ded7188 100644 --- a/cms_bluebutton/tests/test_configs.py +++ b/cms_bluebutton/tests/test_configs.py @@ -59,6 +59,32 @@ def test_valid_config(): assert bb.client_secret == "" assert bb.callback_url == "https://www.fake-prod.com/your/callback/here" assert bb.version == 1 + assert bb.token_refresh_on_expire + assert bb.retry_config.get("total") == 3 + assert bb.retry_config.get("backoff_factor") == 5 + assert bb.retry_config.get("status_forcelist") == [500, 502, 503, 504] + + +def test_valid_config_w_retry(): + # valid config sbx + bb = BlueButton(config=CONFIGS_DIR + "json/bluebutton-sample-config-valid-w-retry.json") + assert bb.retry_config.get("total") == 7 + assert bb.retry_config.get("backoff_factor") == 10 + assert bb.retry_config.get("status_forcelist") == [500, 502] + + +def test_valid_config_w_retry_disable(): + # valid config sbx + bb = BlueButton(config=CONFIGS_DIR + "json/bluebutton-sample-config-retry-disable.json") + assert bb.retry_config.get("total") == 0 + assert bb.retry_config.get("backoff_factor") == 7 + assert bb.retry_config.get("status_forcelist") == [500, 502, 508] + + +def test_valid_config_w_token_refresh_disable(): + # valid config sbx + bb = BlueButton(config=CONFIGS_DIR + "json/bluebutton-sample-config-disable-token-refresh-on-expire.json") + assert not bb.token_refresh_on_expire def test_config_setting_environment(): diff --git a/cms_bluebutton/tests/test_configs/json/bluebutton-sample-config-disable-token-refresh-on-expire.json b/cms_bluebutton/tests/test_configs/json/bluebutton-sample-config-disable-token-refresh-on-expire.json new file mode 100644 index 0000000..aee80eb --- /dev/null +++ b/cms_bluebutton/tests/test_configs/json/bluebutton-sample-config-disable-token-refresh-on-expire.json @@ -0,0 +1,8 @@ +{ + "environment": "PRODUCTION", + "client_id": "", + "client_secret": "", + "callback_url": "https://www.fake-prod.com/your/callback/here", + "version": 2, + "token_refresh_on_expire": false +} diff --git a/cms_bluebutton/tests/test_configs/json/bluebutton-sample-config-retry-disable.json b/cms_bluebutton/tests/test_configs/json/bluebutton-sample-config-retry-disable.json new file mode 100644 index 0000000..f56af94 --- /dev/null +++ b/cms_bluebutton/tests/test_configs/json/bluebutton-sample-config-retry-disable.json @@ -0,0 +1,12 @@ +{ + "environment": "PRODUCTION", + "client_id": "", + "client_secret": "", + "callback_url": "https://www.fake-prod.com/your/callback/here", + "version": 1, + "retry_settings": { + "total": 0, + "backoff_factor": 7, + "status_forcelist": [500, 502, 508] + } +} diff --git a/cms_bluebutton/tests/test_configs/json/bluebutton-sample-config-valid-w-retry.json b/cms_bluebutton/tests/test_configs/json/bluebutton-sample-config-valid-w-retry.json new file mode 100644 index 0000000..fb4c514 --- /dev/null +++ b/cms_bluebutton/tests/test_configs/json/bluebutton-sample-config-valid-w-retry.json @@ -0,0 +1,12 @@ +{ + "environment": "PRODUCTION", + "client_id": "", + "client_secret": "", + "callback_url": "https://www.fake-prod.com/your/callback/here", + "version": 1, + "retry_settings": { + "total": 7, + "backoff_factor": 10, + "status_forcelist": [500, 502] + } +} diff --git a/cms_bluebutton/tests/test_fhir_request.py b/cms_bluebutton/tests/test_fhir_request.py index fbf8a20..6482e12 100644 --- a/cms_bluebutton/tests/test_fhir_request.py +++ b/cms_bluebutton/tests/test_fhir_request.py @@ -1,6 +1,9 @@ +import json import datetime import unittest +from os.path import abspath, curdir from unittest import mock +from urllib.parse import urlparse, parse_qs from cms_bluebutton import AuthorizationToken, BlueButton @@ -23,6 +26,18 @@ def json(self): return self.json_data +class MockResponseWithRaiseForStatus: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + def raise_for_status(self): + pass + + class MockSession: def __init__(self, json_data, status_code): self.response = MockResponse(json_data, status_code) @@ -34,10 +49,66 @@ def get(self, *args, **kwargs): return self.response +class MockSessionSearchPage: + def __init__(self, json_data, status_code): + self.response = MockResponse(json_data, status_code) + + def mount(self, *args, **kwargs): + return + + def get(self, *args, **kwargs): + eob_url = kwargs['url'] + parsed_url = urlparse(eob_url) + qps = parse_qs(parsed_url.query) + if 'startIndex' in qps: + pg_idx = int(qps["startIndex"][0]) // 10 + with open(abspath(curdir) + "/tests/fixtures/eobs/eob_p{}.json".format(pg_idx), "r") as f: + return MockResponse(json.load(f), 200) + else: + # first page (bundle of eobs) + with open(abspath(curdir) + "/tests/fixtures/eobs/eob_p0.json", "r") as f: + return MockResponse(json.load(f), 200) + + +class MockSessionTokenRefresh: + def __init__(self, json_data, status_code): + self.response = MockResponse(json_data, status_code) + + def mount(self, *args, **kwargs): + return + + def get(self, *args, **kwargs): + endpoint_url = kwargs['url'] + parsed_url = urlparse(endpoint_url) + if parsed_url.path.endswith('Patient/'): + # patient + return self.response + else: + raise ValueError("Unexpected GET path={}".format(parsed_url.path)) + + def success_fhir_patient_request_mock(*args, **kwargs): return MockSession({"resourceType": "Patient", "id": "-20140000010000"}, 200) +def mocked_token_refresh_post(*args, **kwargs): + endpoint_url = kwargs['url'] + parsed_url = urlparse(endpoint_url) + if parsed_url.path.endswith('token/'): + # auth token - refreshed + expires_at_str = str(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=36000)) + return MockResponseWithRaiseForStatus({"access_token": "fake_access_token", + "refresh_token": "fake_refresh_token", + "expires_at": expires_at_str, + "patient": "-20140000010000"}, 200) + else: + raise ValueError("Unexpected POST path={}".format(parsed_url.path)) + + +def success_fhir_patient_request_refresh_token_mock(*args, **kwargs): + return MockSessionTokenRefresh({"resourceType": "Patient", "id": "-20140000010000"}, 200) + + def success_fhir_coverage_request_mock(*args, **kwargs): return MockSession({"resourceType": "Bundle", "id": "aaa-111-111-111-aaaa"}, 200) @@ -46,6 +117,10 @@ def success_fhir_eob_request_mock(*args, **kwargs): return MockSession({"resourceType": "Bundle", "id": "bbb-222-222-222-bbbb"}, 200) +def success_fhir_eob_pages_request_mock(*args, **kwargs): + return MockSessionSearchPage({}, 200) + + def success_fhir_profile_request_mock(*args, **kwargs): return MockSession({"sub": "-20140000010000", "patient": "-20140000010000"}, 200) @@ -73,13 +148,28 @@ def generate_mock_config(): } +def generate_mock_config_w_expired_access_token(): + return { + "params": {}, + "auth_token": AuthorizationToken( + { + "access_token": "fake_access_token", + "refresh_token": "fake_refresh_token", + "expires_at": datetime.datetime.now(datetime.timezone.utc) + - datetime.timedelta(seconds=36000), + "patient": "-20140000010000", + } + ), + } + + class TestAPI(unittest.TestCase): @mock.patch("requests.Session", side_effect=success_fhir_patient_request_mock) def test_successful_fhir_patient_request(self, get_request_mock): bb = BlueButton(config=MOCK_BB_CONFIG) config = generate_mock_config() response = bb.get_patient_data(config) - self.assertEqual(response["auth_token"], None) + self.assertIsNotNone(response["auth_token"]) self.assertEqual(response["response"].status_code, 200) self.assertEqual(response["response"].json()["id"], "-20140000010000") self.assertEqual(response["response"].json()["resourceType"], "Patient") @@ -90,7 +180,7 @@ def test_successful_fhir_coverage_request(self, get_request_mock): bb = BlueButton(config=MOCK_BB_CONFIG) config = generate_mock_config() response = bb.get_coverage_data(config) - self.assertEqual(response["auth_token"], None) + self.assertIsNotNone(response["auth_token"]) self.assertEqual(response["response"].status_code, 200) self.assertEqual(response["response"].json()["id"], "aaa-111-111-111-aaaa") self.assertEqual(response["response"].json()["resourceType"], "Bundle") @@ -101,18 +191,68 @@ def test_successful_fhir_eob_request(self, get_request_mock): bb = BlueButton(config=MOCK_BB_CONFIG) config = generate_mock_config() response = bb.get_explaination_of_benefit_data(config) - self.assertEqual(response["auth_token"], None) + self.assertIsNotNone(response["auth_token"]) self.assertEqual(response["response"].status_code, 200) self.assertEqual(response["response"].json()["id"], "bbb-222-222-222-bbbb") self.assertEqual(response["response"].json()["resourceType"], "Bundle") self.assertEqual(get_request_mock.call_count, 1) + @mock.patch("requests.Session", side_effect=success_fhir_eob_pages_request_mock) + def test_successful_fhir_eob_pages_request(self, get_request_mock): + bb = BlueButton(config=MOCK_BB_CONFIG) + config = generate_mock_config() + response = bb.get_explaination_of_benefit_data(config) + self.assertIsNotNone(response["auth_token"]) + self.assertEqual(response["response"].status_code, 200) + self.assertEqual(response["response"].json()["id"], "85a22239-fb03-43b1-a8ba-952dcea76004") + self.assertEqual(response["response"].json()["resourceType"], "Bundle") + self.assertEqual(get_request_mock.call_count, 1) + # fetch all the pages given the 1st page + pages = bb.get_pages(response['response'].json(), config) + self.assertIsNotNone(response["auth_token"]) + self.assertEqual(len(pages["pages"]), 6) + self.assertEqual(get_request_mock.call_count, 6) + + @mock.patch("requests.post", side_effect=mocked_token_refresh_post) + @mock.patch("requests.Session", side_effect=success_fhir_patient_request_refresh_token_mock) + def test_successful_fhir_request_token_refreshed(self, post_mock, get_mock): + bb = BlueButton(config=MOCK_BB_CONFIG) + config = generate_mock_config_w_expired_access_token() + response = bb.get_patient_data(config) + self.assertTrue(config['auth_token'].access_token_expired()) + self.assertIsNotNone(response["auth_token"]) + self.assertFalse(response['auth_token'].access_token_expired()) + self.assertEqual(response["response"].status_code, 200) + self.assertEqual(response["response"].json()["id"], "-20140000010000") + self.assertEqual(response["response"].json()["resourceType"], "Patient") + # mock post called once for token refresh + self.assertEqual(post_mock.call_count, 1) + # mock get called once for fhir resource + self.assertEqual(get_mock.call_count, 1) + + @mock.patch("requests.post", side_effect=mocked_token_refresh_post) + @mock.patch("requests.Session", side_effect=success_fhir_patient_request_refresh_token_mock) + def test_successful_fhir_request_token_refresh_disabled(self, get_mock, post_mock): + bb = BlueButton(config="./tests/test_configs/json/bluebutton-sample-config-disable-token-refresh-on-expire.json") + config = generate_mock_config_w_expired_access_token() + response = bb.get_patient_data(config) + self.assertTrue(config['auth_token'].access_token_expired()) + self.assertIsNotNone(response["auth_token"]) + self.assertTrue(response['auth_token'].access_token_expired()) + self.assertEqual(response["response"].status_code, 200) + self.assertEqual(response["response"].json()["id"], "-20140000010000") + self.assertEqual(response["response"].json()["resourceType"], "Patient") + # mock post for toekn not called + self.assertEqual(post_mock.call_count, 0) + # mock get called once for fhir resource + self.assertEqual(get_mock.call_count, 1) + @mock.patch("requests.Session", side_effect=success_fhir_profile_request_mock) def test_successful_fhir_profile_request(self, get_request_mock): bb = BlueButton(config=MOCK_BB_CONFIG) config = generate_mock_config() response = bb.get_profile_data(config) - self.assertEqual(response["auth_token"], None) + self.assertIsNotNone(response["auth_token"]) self.assertEqual(response["response"].status_code, 200) self.assertEqual(response["response"].json()["sub"], "-20140000010000") self.assertEqual(response["response"].json()["patient"], "-20140000010000") @@ -124,7 +264,7 @@ def test_successful_fhir_custom_request(self, get_request_mock): config = generate_mock_config() config["url"] = "fhir/Coverage/part-a--20140000010000/" response = bb.get_custom_data(config) - self.assertEqual(response["auth_token"], None) + self.assertIsNotNone(response["auth_token"]) self.assertEqual(response["response"].status_code, 200) self.assertEqual(response["response"].json()["id"], "aaa-111-111-111-aaaa") self.assertEqual(response["response"].json()["resourceType"], "Bundle") @@ -135,7 +275,7 @@ def test_500_error_fhir_request(self, get_request_mock): bb = BlueButton(config=MOCK_BB_CONFIG) config = generate_mock_config() response = bb.get_patient_data(config) - self.assertEqual(response["auth_token"], None) + self.assertIsNotNone(response["auth_token"]) self.assertEqual(response["response"].status_code, 500) self.assertEqual(get_request_mock.call_count, 1) @@ -144,6 +284,6 @@ def test_not_found_fhir_request(self, get_request_mock): bb = BlueButton(config=MOCK_BB_CONFIG) config = generate_mock_config() response = bb.get_patient_data(config) - self.assertEqual(response["auth_token"], None) + self.assertIsNotNone(response["auth_token"]) self.assertEqual(response["response"].status_code, 404) self.assertEqual(get_request_mock.call_count, 1) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4dfe29d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3' + +services: + server: + build: + context: ./ + dockerfile: ./Dockerfile + ports: + - "3001:3001" + - "5678:5678" diff --git a/requirements/req.dev.in b/requirements/req.dev.in index 3999596..3f80a6e 100644 --- a/requirements/req.dev.in +++ b/requirements/req.dev.in @@ -1,3 +1,5 @@ +flask==2.1.1 +pyyaml==5.4.1 requests==2.27.1 requests-mock==1.9.3 requests-toolbelt==0.9.1 diff --git a/requirements/req.dev.txt b/requirements/req.dev.txt index f40b020..d2e32e0 100644 --- a/requirements/req.dev.txt +++ b/requirements/req.dev.txt @@ -16,6 +16,10 @@ charset-normalizer==2.0.12 \ --hash=sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597 \ --hash=sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df # via requests +click==8.1.2 \ + --hash=sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e \ + --hash=sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72 + # via flask coverage==6.3.2 \ --hash=sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9 \ --hash=sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d \ @@ -63,14 +67,72 @@ flake8==4.0.1 \ --hash=sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d \ --hash=sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d # via -r requirements/req.dev.in +flask==2.1.1 \ + --hash=sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264 \ + --hash=sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8 + # via -r requirements/req.dev.in idna==3.3 \ --hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \ --hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d # via requests +importlib-metadata==4.11.3 \ + --hash=sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6 \ + --hash=sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539 + # via flask iniconfig==1.1.1 \ --hash=sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3 \ --hash=sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32 # via pytest +itsdangerous==2.1.2 \ + --hash=sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44 \ + --hash=sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a + # via flask +jinja2==3.1.1 \ + --hash=sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119 \ + --hash=sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9 + # via flask +markupsafe==2.1.1 \ + --hash=sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003 \ + --hash=sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88 \ + --hash=sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5 \ + --hash=sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7 \ + --hash=sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a \ + --hash=sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603 \ + --hash=sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1 \ + --hash=sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135 \ + --hash=sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247 \ + --hash=sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6 \ + --hash=sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601 \ + --hash=sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77 \ + --hash=sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02 \ + --hash=sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e \ + --hash=sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63 \ + --hash=sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f \ + --hash=sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980 \ + --hash=sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b \ + --hash=sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812 \ + --hash=sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff \ + --hash=sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96 \ + --hash=sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1 \ + --hash=sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925 \ + --hash=sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a \ + --hash=sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6 \ + --hash=sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e \ + --hash=sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f \ + --hash=sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4 \ + --hash=sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f \ + --hash=sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3 \ + --hash=sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c \ + --hash=sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a \ + --hash=sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417 \ + --hash=sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a \ + --hash=sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a \ + --hash=sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37 \ + --hash=sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452 \ + --hash=sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933 \ + --hash=sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a \ + --hash=sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7 + # via jinja2 mccabe==0.6.1 \ --hash=sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42 \ --hash=sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f @@ -103,6 +165,37 @@ pytest==7.0.1 \ --hash=sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db \ --hash=sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171 # via -r requirements/req.dev.in +pyyaml==5.4.1 \ + --hash=sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf \ + --hash=sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696 \ + --hash=sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393 \ + --hash=sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77 \ + --hash=sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922 \ + --hash=sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5 \ + --hash=sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8 \ + --hash=sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10 \ + --hash=sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc \ + --hash=sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018 \ + --hash=sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e \ + --hash=sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253 \ + --hash=sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347 \ + --hash=sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183 \ + --hash=sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541 \ + --hash=sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb \ + --hash=sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185 \ + --hash=sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc \ + --hash=sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db \ + --hash=sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa \ + --hash=sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46 \ + --hash=sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122 \ + --hash=sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b \ + --hash=sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63 \ + --hash=sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df \ + --hash=sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc \ + --hash=sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247 \ + --hash=sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6 \ + --hash=sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0 + # via -r requirements/req.dev.in requests==2.27.1 \ --hash=sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61 \ --hash=sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d @@ -132,3 +225,11 @@ urllib3==1.26.8 \ # via # -r requirements/req.dev.in # requests +werkzeug==2.1.1 \ + --hash=sha256:3c5493ece8268fecdcdc9c0b112211acd006354723b280d643ec732b6d4063d6 \ + --hash=sha256:f8e89a20aeabbe8a893c24a461d3ee5dad2123b05cc6abd73ceed01d39c3ae74 + # via flask +zipp==3.8.0 \ + --hash=sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad \ + --hash=sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099 + # via importlib-metadata