diff --git a/healthcare/api-client/datasets/datasets.py b/healthcare/api-client/datasets/datasets.py index 50875c13a15..6b109b7c5dc 100644 --- a/healthcare/api-client/datasets/datasets.py +++ b/healthcare/api-client/datasets/datasets.py @@ -25,7 +25,7 @@ def get_client(service_account_json, api_key): """Returns an authorized API client by discovering the Healthcare API and creating a service object using the service account credentials JSON.""" api_scopes = ['https://www.googleapis.com/auth/cloud-platform'] - api_version = 'v1alpha2' + api_version = 'v1beta1' discovery_api = 'https://healthcare.googleapis.com/$discovery/rest' service_name = 'healthcare' @@ -33,7 +33,7 @@ def get_client(service_account_json, api_key): service_account_json) scoped_credentials = credentials.with_scopes(api_scopes) - discovery_url = '{}?labels=CHC_ALPHA&version={}&key={}'.format( + discovery_url = '{}?labels=CHC_BETA&version={}&key={}'.format( discovery_api, api_version, api_key) return discovery.build( @@ -237,6 +237,80 @@ def deidentify_dataset( # [END healthcare_deidentify_dataset] +# [START healthcare_dataset_get_iam_policy] +def get_dataset_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id): + """Gets the IAM policy for the specified dataset.""" + client = get_client(service_account_json, api_key) + dataset_name = 'projects/{}/locations/{}/datasets/{}'.format( + project_id, cloud_region, dataset_id) + + request = client.projects().locations().datasets().getIamPolicy( + resource=dataset_name) + response = request.execute() + + print('etag: {}'.format(response.get('name'))) + return response +# [END healthcare_dataset_get_iam_policy] + + +# [START healthcare_dataset_set_iam_policy] +def set_dataset_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + member, + role, + etag=None): + """Sets the IAM policy for the specified dataset. + + A single member will be assigned a single role. A member can be any of: + + - allUsers, that is, anyone + - allAuthenticatedUsers, anyone authenticated with a Google account + - user:email, as in 'user:somebody@example.com' + - group:email, as in 'group:admins@example.com' + - domain:domainname, as in 'domain:example.com' + - serviceAccount:email, + as in 'serviceAccount:my-other-app@appspot.gserviceaccount.com' + + A role can be any IAM role, such as 'roles/viewer', 'roles/owner', + or 'roles/editor' + """ + client = get_client(service_account_json, api_key) + dataset_name = 'projects/{}/locations/{}/datasets/{}'.format( + project_id, cloud_region, dataset_id) + + policy = { + "bindings": [ + { + "role": role, + "members": [ + member + ] + } + ] + } + + if etag is not None: + policy['etag'] = etag + + request = client.projects().locations().datasets().setIamPolicy( + resource=dataset_name, body={'policy': policy}) + response = request.execute() + + print('etag: {}'.format(response.get('name'))) + print('bindings: {}'.format(response.get('bindings'))) + return response +# [END healthcare_dataset_set_iam_policy] + + def parse_command_line_args(): """Parses command line arguments.""" @@ -286,6 +360,16 @@ def parse_command_line_args(): help='The data to keeplist, for example "PatientID" ' 'or "StudyInstanceUID"') + parser.add_argument( + '--member', + default=None, + help='Member to add to IAM policy (e.g. "domain:example.com")') + + parser.add_argument( + '--role', + default=None, + help='IAM Role to give to member (e.g. "roles/viewer")') + command = parser.add_subparsers(dest='command') command.add_parser('create-dataset', help=create_dataset.__doc__) @@ -293,6 +377,8 @@ def parse_command_line_args(): command.add_parser('get-dataset', help=get_dataset.__doc__) command.add_parser('list-datasets', help=list_datasets.__doc__) command.add_parser('patch-dataset', help=patch_dataset.__doc__) + command.add_parser('get_iam_policy', help=get_dataset_iam_policy.__doc__) + command.add_parser('set_iam_policy', help=set_dataset_iam_policy.__doc__) command.add_parser('deidentify-dataset', help=deidentify_dataset.__doc__) @@ -356,6 +442,24 @@ def run_command(args): args.destination_dataset_id, args.keeplist_tags) + elif args.command == 'get_iam_policy': + get_dataset_iam_policy( + args.service_account_json, + args.api_key, + args.project_id, + args.cloud_region, + args.dataset_id) + + elif args.command == 'set_iam_policy': + set_dataset_iam_policy( + args.service_account_json, + args.api_key, + args.project_id, + args.cloud_region, + args.dataset_id, + args.member, + args.role) + def main(): args = parse_command_line_args() diff --git a/healthcare/api-client/datasets/datasets_test.py b/healthcare/api-client/datasets/datasets_test.py index 61c423d2864..1eeb981a523 100644 --- a/healthcare/api-client/datasets/datasets_test.py +++ b/healthcare/api-client/datasets/datasets_test.py @@ -13,7 +13,6 @@ # limitations under the License. import os -import pytest import time import datasets @@ -29,7 +28,6 @@ time_zone = 'UTC' -@pytest.mark.skip(reason='disable until API whitelisted / enabled') def test_CRUD_dataset(capsys): datasets.create_dataset( service_account_json, @@ -57,7 +55,6 @@ def test_CRUD_dataset(capsys): assert 'Deleted dataset' in out -@pytest.mark.skip(reason='disable until API whitelisted / enabled') def test_patch_dataset(capsys): datasets.create_dataset( service_account_json, @@ -84,7 +81,6 @@ def test_patch_dataset(capsys): assert 'UTC' in out -@pytest.mark.skip(reason='disable until API whitelisted / enabled') def test_deidentify_dataset(capsys): datasets.create_dataset( service_account_json, @@ -116,3 +112,44 @@ def test_deidentify_dataset(capsys): # Check that de-identify worked assert 'De-identified data written to' in out + + +def test_get_set_dataset_iam_policy(capsys): + datasets.create_dataset( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id) + + get_response = datasets.get_dataset_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id) + + set_response = datasets.set_dataset_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + 'serviceAccount:python-docs-samples-tests@appspot.gserviceaccount.com', + 'roles/viewer') + + # Clean up + datasets.delete_dataset( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id) + + out, _ = capsys.readouterr() + + assert 'etag' in get_response + assert 'bindings' in set_response + assert len(set_response['bindings']) == 1 + assert 'python-docs-samples-tests' in str(set_response['bindings']) + assert 'roles/viewer' in str(set_response['bindings']) diff --git a/healthcare/api-client/dicom/dicom_stores.py b/healthcare/api-client/dicom/dicom_stores.py index f1c4f719623..7bb4f8d42e2 100644 --- a/healthcare/api-client/dicom/dicom_stores.py +++ b/healthcare/api-client/dicom/dicom_stores.py @@ -25,7 +25,7 @@ def get_client(service_account_json, api_key): """Returns an authorized API client by discovering the Healthcare API and creating a service object using the service account credentials JSON.""" api_scopes = ['https://www.googleapis.com/auth/cloud-platform'] - api_version = 'v1alpha' + api_version = 'v1beta1' discovery_api = 'https://healthcare.googleapis.com/$discovery/rest' service_name = 'healthcare' @@ -33,14 +33,15 @@ def get_client(service_account_json, api_key): service_account_json) scoped_credentials = credentials.with_scopes(api_scopes) - discovery_url = '{}?labels=CHC_ALPHA&version={}&key={}'.format( + discovery_url = '{}?labels=CHC_BETA&version={}&key={}'.format( discovery_api, api_version, api_key) return discovery.build( service_name, api_version, discoveryServiceUrl=discovery_url, - credentials=scoped_credentials) + credentials=scoped_credentials, + cache_discovery=False) # [END healthcare_get_client] @@ -212,12 +213,8 @@ def export_dicom_instance( dicom_store_parent, dicom_store_id) body = { - "outputConfig": - { - "gcsDestination": - { - "uriPrefix": 'gs://{}'.format(uri_prefix) - } + "gcsDestination": { + "uriPrefix": 'gs://{}'.format(uri_prefix) } } @@ -253,12 +250,8 @@ def import_dicom_instance( dicom_store_parent, dicom_store_id) body = { - "inputConfig": - { - "gcsSource": - { - "contentUri": 'gs://{}'.format(content_uri) - } + "gcsSource": { + "uri": 'gs://{}'.format(content_uri) } } @@ -277,6 +270,86 @@ def import_dicom_instance( # [END healthcare_import_dicom_instance] +# [START healthcare_dicom_store_get_iam_policy] +def get_dicom_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + dicom_store_id): + """Gets the IAM policy for the specified dicom store.""" + client = get_client(service_account_json, api_key) + dicom_store_parent = 'projects/{}/locations/{}/datasets/{}'.format( + project_id, cloud_region, dataset_id) + dicom_store_name = '{}/dicomStores/{}'.format( + dicom_store_parent, dicom_store_id) + + request = client.projects().locations().datasets().dicomStores( + ).getIamPolicy(resource=dicom_store_name) + response = request.execute() + + print('etag: {}'.format(response.get('name'))) + return response +# [END healthcare_dicom_store_get_iam_policy] + + +# [START healthcare_dicom_store_set_iam_policy] +def set_dicom_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + dicom_store_id, + member, + role, + etag=None): + """Sets the IAM policy for the specified dicom store. + + A single member will be assigned a single role. A member can be any of: + + - allUsers, that is, anyone + - allAuthenticatedUsers, anyone authenticated with a Google account + - user:email, as in 'user:somebody@example.com' + - group:email, as in 'group:admins@example.com' + - domain:domainname, as in 'domain:example.com' + - serviceAccount:email, + as in 'serviceAccount:my-other-app@appspot.gserviceaccount.com' + + A role can be any IAM role, such as 'roles/viewer', 'roles/owner', + or 'roles/editor' + """ + client = get_client(service_account_json, api_key) + dicom_store_parent = 'projects/{}/locations/{}/datasets/{}'.format( + project_id, cloud_region, dataset_id) + dicom_store_name = '{}/dicomStores/{}'.format( + dicom_store_parent, dicom_store_id) + + policy = { + "bindings": [ + { + "role": role, + "members": [ + member + ] + } + ] + } + + if etag is not None: + policy['etag'] = etag + + request = client.projects().locations().datasets().dicomStores( + ).setIamPolicy(resource=dicom_store_name, body={'policy': policy}) + response = request.execute() + + print('etag: {}'.format(response.get('name'))) + print('bindings: {}'.format(response.get('bindings'))) + return response +# [END healthcare_dicom_store_set_iam_policy] + + def parse_command_line_args(): """Parses command line arguments.""" @@ -342,6 +415,16 @@ def parse_command_line_args(): help='Specifies the output format. If the format is unspecified, the' 'default functionality is to export to DICOM.') + parser.add_argument( + '--member', + default=None, + help='Member to add to IAM policy (e.g. "domain:example.com")') + + parser.add_argument( + '--role', + default=None, + help='IAM Role to give to member (e.g. "roles/viewer")') + command = parser.add_subparsers(dest='command') command.add_parser('create-dicom-store', help=create_dicom_store.__doc__) @@ -349,7 +432,12 @@ def parse_command_line_args(): command.add_parser('get-dicom-store', help=get_dicom_store.__doc__) command.add_parser('list-dicom-stores', help=list_dicom_stores.__doc__) command.add_parser('patch-dicom-store', help=patch_dicom_store.__doc__) - + command.add_parser( + 'get_iam_policy', + help=get_dicom_store_iam_policy.__doc__) + command.add_parser( + 'set_iam_policy', + help=set_dicom_store_iam_policy.__doc__) command.add_parser( 'export-dicom-store', help=export_dicom_instance.__doc__) @@ -432,6 +520,26 @@ def run_command(args): args.dicom_store_id, args.content_uri) + elif args.command == 'get_iam_policy': + get_dicom_store_iam_policy( + args.service_account_json, + args.api_key, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id) + + elif args.command == 'set_iam_policy': + set_dicom_store_iam_policy( + args.service_account_json, + args.api_key, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.member, + args.role) + def main(): args = parse_command_line_args() diff --git a/healthcare/api-client/dicom/dicom_stores_test.py b/healthcare/api-client/dicom/dicom_stores_test.py index ecffa9d49b1..42d232b2aa4 100644 --- a/healthcare/api-client/dicom/dicom_stores_test.py +++ b/healthcare/api-client/dicom/dicom_stores_test.py @@ -33,7 +33,7 @@ RESOURCES = os.path.join(os.path.dirname(__file__), 'resources') bucket = os.environ['CLOUD_STORAGE_BUCKET'] -dcm_file_name = 'IM-0002-0001-JPEG-BASELINE.dcm' +dcm_file_name = 'dicom_00000001_000.dcm' content_uri = bucket + '/' + dcm_file_name dcm_file = os.path.join(RESOURCES, dcm_file_name) @@ -58,7 +58,6 @@ def test_dataset(): dataset_id) -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_CRUD_dicom_store(test_dataset, capsys): dicom_stores.create_dicom_store( service_account_json, @@ -100,7 +99,6 @@ def test_CRUD_dicom_store(test_dataset, capsys): assert 'Deleted DICOM store' in out -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_patch_dicom_store(test_dataset, capsys): dicom_stores.create_dicom_store( service_account_json, @@ -133,7 +131,6 @@ def test_patch_dicom_store(test_dataset, capsys): assert 'Patched DICOM store' in out -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_import_dicom_instance(test_dataset, capsys): dicom_stores.create_dicom_store( service_account_json, @@ -166,7 +163,6 @@ def test_import_dicom_instance(test_dataset, capsys): assert 'Imported DICOM instance' in out -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_export_dicom_instance(test_dataset, capsys): dicom_stores.create_dicom_store( service_account_json, @@ -197,3 +193,48 @@ def test_export_dicom_instance(test_dataset, capsys): out, _ = capsys.readouterr() assert 'Exported DICOM instance' in out + + +def test_get_set_dicom_store_iam_policy(test_dataset, capsys): + dicom_stores.create_dicom_store( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + dicom_store_id) + + get_response = dicom_stores.get_dicom_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + dicom_store_id) + + set_response = dicom_stores.set_dicom_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + dicom_store_id, + 'serviceAccount:python-docs-samples-tests@appspot.gserviceaccount.com', + 'roles/viewer') + + # Clean up + dicom_stores.delete_dicom_store( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + dicom_store_id) + + out, _ = capsys.readouterr() + + assert 'etag' in get_response + assert 'bindings' in set_response + assert len(set_response['bindings']) == 1 + assert 'python-docs-samples-tests' in str(set_response['bindings']) + assert 'roles/viewer' in str(set_response['bindings']) diff --git a/healthcare/api-client/dicom/dicomweb.py b/healthcare/api-client/dicom/dicomweb.py index fba977a0933..8909ebe26f4 100644 --- a/healthcare/api-client/dicom/dicomweb.py +++ b/healthcare/api-client/dicom/dicomweb.py @@ -16,16 +16,11 @@ import json import os -import email -from email import encoders -from email.mime import application -from email.mime import multipart - from google.auth.transport import requests from googleapiclient.errors import HttpError from google.oauth2 import service_account -_BASE_URL = 'https://healthcare.googleapis.com/v1alpha' +_BASE_URL = 'https://healthcare.googleapis.com/v1beta1' def get_session(service_account_json): @@ -65,30 +60,16 @@ def dicomweb_store_instance( # Make an authenticated API request session = get_session(service_account_json) - with open(dcm_file) as dcm: + with open(dcm_file, 'rb') as dcm: dcm_content = dcm.read() - # All requests to store an instance are multipart messages, as designated - # by the multipart/related portion of the Content-Type. This means that - # the request is made up of multiple sets of data that are combined after - # the request completes. Each of these sets of data must be separated using - # a boundary, as designated by the boundary portion of the Content-Type. - multipart_body = multipart.MIMEMultipart( - subtype='related', boundary=email.generator._make_boundary()) - part = application.MIMEApplication( - dcm_content, 'dicom', _encoder=encoders.encode_noop) - multipart_body.attach(part) - boundary = multipart_body.get_boundary() - - content_type = ( - 'multipart/related; type="application/dicom"; ' + - 'boundary="%s"') % boundary + content_type = 'application/dicom' headers = {'Content-Type': content_type} try: response = session.post( dicomweb_path, - data=multipart_body.as_string(), + data=dcm_content, headers=headers) response.raise_for_status() print('Stored DICOM instance:') diff --git a/healthcare/api-client/dicom/dicomweb_test.py b/healthcare/api-client/dicom/dicomweb_test.py index 9a9e4a10279..0a936387cb9 100644 --- a/healthcare/api-client/dicom/dicomweb_test.py +++ b/healthcare/api-client/dicom/dicomweb_test.py @@ -25,7 +25,7 @@ cloud_region = 'us-central1' api_key = os.environ['API_KEY'] -base_url = 'https://healthcare.googleapis.com/v1alpha' +base_url = 'https://healthcare.googleapis.com/v1beta1' project_id = os.environ['GOOGLE_CLOUD_PROJECT'] service_account_json = os.environ['GOOGLE_APPLICATION_CREDENTIALS'] @@ -33,11 +33,11 @@ dicom_store_id = 'test_dicom_store_{}'.format(int(time.time())) RESOURCES = os.path.join(os.path.dirname(__file__), 'resources') -dcm_file_name = 'IM-0002-0001-JPEG-BASELINE.dcm' +dcm_file_name = 'dicom_00000001_000.dcm' dcm_file = os.path.join(RESOURCES, dcm_file_name) # The study_uid is not assigned by the server and is part of the # metadata of dcm_file -study_uid = '1.2.840.113619.2.176.3596.3364818.7819.1259708454.105' +study_uid = '1.3.6.1.4.1.11129.5.5.111396399361969898205364400549799252857604' @pytest.fixture(scope='module') @@ -82,7 +82,6 @@ def test_dicom_store(): dicom_store_id) -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_dicomweb_store_instance(test_dataset, test_dicom_store, capsys): dicomweb.dicomweb_store_instance( service_account_json, @@ -108,7 +107,6 @@ def test_dicomweb_store_instance(test_dataset, test_dicom_store, capsys): study_uid) -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_dicomweb_search_instance(test_dataset, test_dicom_store, capsys): dicomweb.dicomweb_store_instance( service_account_json, @@ -142,7 +140,6 @@ def test_dicomweb_search_instance(test_dataset, test_dicom_store, capsys): study_uid) -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_dicomweb_retrieve_study(test_dataset, test_dicom_store, capsys): dicomweb.dicomweb_store_instance( service_account_json, @@ -177,7 +174,6 @@ def test_dicomweb_retrieve_study(test_dataset, test_dicom_store, capsys): study_uid) -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_dicomweb_delete_study(test_dataset, test_dicom_store, capsys): dicomweb.dicomweb_store_instance( service_account_json, diff --git a/healthcare/api-client/dicom/resources/dicom_00000001_000.dcm b/healthcare/api-client/dicom/resources/dicom_00000001_000.dcm new file mode 100644 index 00000000000..1fd33b19339 Binary files /dev/null and b/healthcare/api-client/dicom/resources/dicom_00000001_000.dcm differ diff --git a/healthcare/api-client/fhir/fhir_resources.py b/healthcare/api-client/fhir/fhir_resources.py index 76b54894dc8..f5ca217e386 100644 --- a/healthcare/api-client/fhir/fhir_resources.py +++ b/healthcare/api-client/fhir/fhir_resources.py @@ -20,7 +20,7 @@ from googleapiclient.errors import HttpError from google.oauth2 import service_account -_BASE_URL = 'https://healthcare.googleapis.com/v1alpha' +_BASE_URL = 'https://healthcare.googleapis.com/v1beta1' # [START healthcare_get_session] @@ -56,7 +56,7 @@ def create_resource( url = '{}/projects/{}/locations/{}'.format(base_url, project_id, cloud_region) - fhir_store_path = '{}/datasets/{}/fhirStores/{}/resources/{}'.format( + fhir_store_path = '{}/datasets/{}/fhirStores/{}/fhir/{}'.format( url, dataset_id, fhir_store_id, resource_type) # Make an authenticated API request @@ -115,7 +115,8 @@ def delete_resource( try: response = session.delete(resource_path, headers=headers) - response.raise_for_status() + if response.status_code != 404: # Don't consider missing to be error + response.raise_for_status() print(response) print('Deleted Resource: {}'.format(resource_id)) return response @@ -139,7 +140,7 @@ def get_resource( url = '{}/projects/{}/locations/{}'.format(base_url, project_id, cloud_region) - resource_path = '{}/datasets/{}/fhirStores/{}/resources/{}/{}'.format( + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}/{}'.format( url, dataset_id, fhir_store_id, resource_type, resource_id) # Make an authenticated API request @@ -160,6 +161,198 @@ def get_resource( # [END healthcare_get_resource] +# [START healthcare_list_resource_history] +def list_resource_history( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id): + """Gets the history of a resource.""" + url = '{}/projects/{}/locations/{}'.format(base_url, + project_id, cloud_region) + + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}/{}'.format( + url, dataset_id, fhir_store_id, resource_type, resource_id) + + # Make an authenticated API request + session = get_session(service_account_json) + + headers = { + 'Content-Type': 'application/fhir+json;charset=utf-8' + } + + response = session.get(resource_path + '/_history', headers=headers) + response.raise_for_status() + + resource = response.json() + + print(json.dumps(resource, indent=2)) + + return resource +# [END healthcare_list_resource_history] + + +# [START healthcare_get_resource_history] +def get_resource_history( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id, + version_id): + """Gets a version resource.""" + url = '{}/projects/{}/locations/{}'.format(base_url, + project_id, cloud_region) + + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}/{}'.format( + url, dataset_id, fhir_store_id, resource_type, resource_id) + resource_path += '/_history/{}'.format(version_id) + + # Make an authenticated API request + session = get_session(service_account_json) + + headers = { + 'Content-Type': 'application/fhir+json;charset=utf-8' + } + + response = session.get(resource_path, headers=headers) + response.raise_for_status() + + resource = response.json() + + print(json.dumps(resource, indent=2)) + + return resource +# [END healthcare_get_resource_history] + + +# [START healthcare_export_fhir_resources] +def export_resources( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + gcs_destination): + """Exports resources in a FHIR store.""" + url = '{}/projects/{}/locations/{}'.format(base_url, + project_id, cloud_region) + + resource_path = '{}/datasets/{}/fhirStores/{}'.format( + url, dataset_id, fhir_store_id) + + # Make an authenticated API request + session = get_session(service_account_json) + + headers = { + 'Content-Type': 'application/fhir+json;charset=utf-8' + } + + body = { + 'gcsDestination': { + 'uriPrefix': gcs_destination + } + } + + response = session.post( + resource_path + ':export', headers=headers, json=body) + response.raise_for_status() + + resource = response.json() + + print(json.dumps(resource, indent=2)) + + return resource +# [END healthcare_export_fhir_resources] + + +# [START healthcare_import_fhir_resources] +def import_resources( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + gcs_source): + """Exports resources in a FHIR store.""" + url = '{}/projects/{}/locations/{}'.format(base_url, + project_id, cloud_region) + + resource_path = '{}/datasets/{}/fhirStores/{}'.format( + url, dataset_id, fhir_store_id) + + # Make an authenticated API request + session = get_session(service_account_json) + + headers = { + 'Content-Type': 'application/fhir+json;charset=utf-8' + } + + body = { + 'gcsSource': { + 'uriPrefix': gcs_source + }, + 'gcsErrorDestination': { + 'uriPrefix': gcs_source + '_errors' + } + } + + response = session.post( + resource_path + ':import', headers=headers, json=body) + response.raise_for_status() + + resource = response.json() + + print(json.dumps(resource, indent=2)) + + return resource +# [END healthcare_import_fhir_resources] + + +# [START healthcare_delete_resource_purge] +def delete_resource_purge( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id): + """Deletes versions of a resource (excluding current version).""" + url = '{}/projects/{}/locations/{}'.format(base_url, + project_id, cloud_region) + + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}/{}'.format( + url, dataset_id, fhir_store_id, resource_type, resource_id) + resource_path += '/$purge' + + # Make an authenticated API request + session = get_session(service_account_json) + + headers = { + 'Content-Type': 'application/fhir+json;charset=utf-8' + } + + response = session.delete(resource_path, headers=headers) + response.raise_for_status() + + if response.status_code < 400: + print('{} deleted'.format(response.status_code)) + + return response +# [END healthcare_delete_resource_purge] + + # [START healthcare_update_resource] def update_resource( service_account_json, @@ -174,7 +367,7 @@ def update_resource( url = '{}/projects/{}/locations/{}'.format(base_url, project_id, cloud_region) - resource_path = '{}/datasets/{}/fhirStores/{}/resources/{}/{}'.format( + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}/{}'.format( url, dataset_id, fhir_store_id, resource_type, resource_id) # Make an authenticated API request @@ -201,6 +394,78 @@ def update_resource( # [END healthcare_update_resource] +# [START healthcare_conditional_update_resource] +def conditional_update_resource( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id): + """Updates an existing resource specified by search criteria.""" + url = '{}/projects/{}/locations/{}'.format(base_url, + project_id, cloud_region) + + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}'.format( + url, dataset_id, fhir_store_id, resource_type) + + # Make an authenticated API request + session = get_session(service_account_json) + + headers = { + 'Content-Type': 'application/fhir+json;charset=utf-8' + } + + body = { + 'resourceType': resource_type, + 'active': True, + 'id': resource_id, + } + + response = session.put(resource_path, headers=headers, json=body) + response.raise_for_status() + resource = response.json() + + print('Conditionally updated') + print(json.dumps(resource, indent=2)) + + return resource +# [END healthcare_conditional_update_resource] + + +# [START healthcare_conditional_delete_resource] +def conditional_delete_resource( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id): + """Deletes an existing resource specified by search criteria.""" + url = '{}/projects/{}/locations/{}'.format(base_url, + project_id, cloud_region) + + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}'.format( + url, dataset_id, fhir_store_id, resource_type) + resource_path += '?id={}'.format(resource_id) + + # Make an authenticated API request + session = get_session(service_account_json) + + response = session.delete(resource_path) + if response.status_code != 404: # Don't consider missing to be error + response.raise_for_status() + + print('Conditionally deleted. Status = {}'.format(response.status_code)) + + return response +# [END healthcare_conditional_delete_resource] + + # [START healthcare_patch_resource] def patch_resource( service_account_json, @@ -215,7 +480,7 @@ def patch_resource( url = '{}/projects/{}/locations/{}'.format(base_url, project_id, cloud_region) - resource_path = '{}/datasets/{}/fhirStores/{}/resources/{}/{}'.format( + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}/{}'.format( url, dataset_id, fhir_store_id, resource_type, resource_id) # Make an authenticated API request @@ -244,6 +509,50 @@ def patch_resource( # [END healthcare_patch_resource] +# [START healthcare_conditional_patch_resource] +def conditional_patch_resource( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id): + """Updates part of an existing resource..""" + url = '{}/projects/{}/locations/{}'.format(base_url, + project_id, cloud_region) + + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}'.format( + url, dataset_id, fhir_store_id, resource_type) + resource_path += '?id={}'.format(resource_id) + + # Make an authenticated API request + session = get_session(service_account_json) + + headers = { + 'Content-Type': 'application/json-patch+json' + } + + body = json.dumps([ + { + 'op': 'replace', + 'path': '/active', + 'value': True + } + ]) + + response = session.patch(resource_path, headers=headers, data=body) + response.raise_for_status() + + resource = response.json() + + print(json.dumps(resource, indent=2)) + + return resource +# [END healthcare_conditional_patch_resource] + + # [START healthcare_search_resources_get] def search_resources_get( service_account_json, @@ -258,7 +567,7 @@ def search_resources_get( url = '{}/projects/{}/locations/{}'.format(base_url, project_id, cloud_region) - resource_path = '{}/datasets/{}/fhirStores/{}/resources/{}'.format( + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}'.format( url, dataset_id, fhir_store_id, resource_type) # Make an authenticated API request @@ -327,10 +636,9 @@ def get_patient_everything( url = '{}/projects/{}/locations/{}'.format(base_url, project_id, cloud_region) - resource_parent = '{}/datasets/{}/fhirStores/{}'.format( - url, dataset_id, fhir_store_id) - resource_path = '{}/resources/Patient/{}/$everything'.format( - resource_parent, resource_id) + resource_path = '{}/datasets/{}/fhirStores/{}/fhir/{}/{}'.format( + url, dataset_id, fhir_store_id, 'Patient', resource_id) + resource_path += '/$everything' # Make an authenticated API request session = get_session(service_account_json) @@ -362,7 +670,7 @@ def get_metadata( url = '{}/projects/{}/locations/{}'.format(base_url, project_id, cloud_region) - fhir_store_path = '{}/datasets/{}/fhirStores/{}/metadata'.format( + fhir_store_path = '{}/datasets/{}/fhirStores/{}/fhir/metadata'.format( url, dataset_id, fhir_store_id) # Make an authenticated API request @@ -379,6 +687,40 @@ def get_metadata( # [END healthcare_get_metadata] +# [START healthcare_fhir_execute_bundle] +def execute_bundle( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + bundle): + """Executes the operations in the given bundle.""" + url = '{}/projects/{}/locations/{}'.format(base_url, + project_id, cloud_region) + + resource_path = '{}/datasets/{}/fhirStores/{}/fhir'.format( + url, dataset_id, fhir_store_id) + + # Make an authenticated API request + session = get_session(service_account_json) + + headers = { + 'Content-Type': 'application/fhir+json;charset=utf-8' + } + + response = session.post(resource_path, headers=headers, json=bundle) + response.raise_for_status() + + resource = response.json() + + print(json.dumps(resource, indent=2)) + + return resource +# [END healthcare_fhir_execute_bundle] + + def parse_command_line_args(): """Parses command line arguments.""" @@ -426,13 +768,55 @@ def parse_command_line_args(): default=None, help='Name of a FHIR resource') + parser.add_argument( + '--bundle', + default=None, + help='Name of file containing bundle of operations to execute') + + parser.add_argument( + '--uri_prefix', + default=None, + help='Prefix of gs:// URIs for import and export') + + parser.add_argument( + '--version_id', + default=None, + help='Version of a FHIR resource') + command = parser.add_subparsers(dest='command') command.add_parser('create-resource', help=create_resource.__doc__) command.add_parser('delete-resource', help=create_resource.__doc__) + command.add_parser( + 'conditional-delete-resource', + help=conditional_delete_resource.__doc__) command.add_parser('get-resource', help=get_resource.__doc__) + command.add_parser( + 'list-resource-history', + help=list_resource_history.__doc__) + command.add_parser( + 'export-resources', + help=export_resources.__doc__) + command.add_parser( + 'export-resources', + help=export_resources.__doc__) + command.add_parser( + 'execute_bundle', + help=execute_bundle.__doc__) + command.add_parser( + 'get-resource-history', + help=get_resource_history.__doc__) + command.add_parser( + 'delete-resource-purge', + help=delete_resource_purge.__doc__) command.add_parser('update-resource', help=update_resource.__doc__) + command.add_parser( + 'conditional-update-resource', + help=conditional_update_resource.__doc__) command.add_parser('patch-resource', help=patch_resource.__doc__) + command.add_parser( + 'conditional-patch-resource', + help=conditional_patch_resource.__doc__) command.add_parser( 'search-resources-get', help=search_resources_get.__doc__) @@ -475,6 +859,17 @@ def run_command(args): args.resource_type, args.resource_id) + elif args.command == 'conditional-delete-resource': + conditional_delete_resource( + args.service_account_json, + args.base_url, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.resource_type, + args.resource_id) + elif args.command == 'get-resource': get_resource( args.service_account_json, @@ -486,6 +881,50 @@ def run_command(args): args.resource_type, args.resource_id) + elif args.command == 'execute_bundle': + get_resource( + args.service_account_json, + args.base_url, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.bundle) + + elif args.command == 'list-resource-history': + list_resource_history( + args.service_account_json, + args.base_url, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.resource_type, + args.resource_id) + + elif args.command == 'get-resource-history': + get_resource_history( + args.service_account_json, + args.base_url, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.resource_type, + args.resource_id, + args.version_id) + + elif args.command == 'delete-resource-purge': + delete_resource_purge( + args.service_account_json, + args.base_url, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.resource_type, + args.resource_id) + elif args.command == 'update-resource': update_resource( args.service_account_json, @@ -497,6 +936,17 @@ def run_command(args): args.resource_type, args.resource_id) + elif args.command == 'conditional-update-resource': + conditional_update_resource( + args.service_account_json, + args.base_url, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.resource_type, + args.resource_id) + elif args.command == 'patch-resource': patch_resource( args.service_account_json, @@ -508,6 +958,16 @@ def run_command(args): args.resource_type, args.resource_id) + elif args.command == 'conditional-patch-resource': + conditional_patch_resource( + args.service_account_json, + args.base_url, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.resource_type) + elif args.command == 'search-resources-get': search_resources_get( args.service_account_json, @@ -518,6 +978,26 @@ def run_command(args): args.fhir_store_id, args.resource_type) + elif args.command == 'export-resources': + export_resources( + args.service_account_json, + args.base_url, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.uri_prefix) + + elif args.command == 'import-resources': + import_resources( + args.service_account_json, + args.base_url, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.uri_prefix) + elif args.command == 'search-resources-post': search_resources_post( args.service_account_json, diff --git a/healthcare/api-client/fhir/fhir_resources_test.py b/healthcare/api-client/fhir/fhir_resources_test.py index d82b80fc861..4ac3c874091 100644 --- a/healthcare/api-client/fhir/fhir_resources_test.py +++ b/healthcare/api-client/fhir/fhir_resources_test.py @@ -25,7 +25,7 @@ cloud_region = 'us-central1' api_key = os.environ['API_KEY'] -base_url = 'https://healthcare.googleapis.com/v1alpha' +base_url = 'https://healthcare.googleapis.com/v1beta1' project_id = os.environ['GOOGLE_CLOUD_PROJECT'] service_account_json = os.environ['GOOGLE_APPLICATION_CREDENTIALS'] @@ -75,9 +75,8 @@ def test_fhir_store(): fhir_store_id) -@pytest.mark.skip(reason='Disable until API whitelisted.') def test_CRUD_search_resource(test_dataset, test_fhir_store, capsys): - fhir_resources.create_resource( + response = fhir_resources.create_resource( service_account_json, base_url, project_id, @@ -86,7 +85,10 @@ def test_CRUD_search_resource(test_dataset, test_fhir_store, capsys): fhir_store_id, resource_type) - resource = fhir_resources.search_resources_get( + # Save the resource_id because you need to pass it into later tests + resource_id = response.json()['id'] + + fhir_resources.search_resources_get( service_account_json, base_url, project_id, @@ -95,10 +97,6 @@ def test_CRUD_search_resource(test_dataset, test_fhir_store, capsys): fhir_store_id, resource_type) - # Save the resource_id from the object returned by search_resources() - # because you need to pass it into get_resource() and delete_resource() - resource_id = resource["entry"][0]["resource"]["id"] - fhir_resources.get_resource( service_account_json, base_url, @@ -119,6 +117,16 @@ def test_CRUD_search_resource(test_dataset, test_fhir_store, capsys): resource_type, resource_id) + fhir_resources.conditional_update_resource( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id) + fhir_resources.patch_resource( service_account_json, base_url, @@ -129,6 +137,57 @@ def test_CRUD_search_resource(test_dataset, test_fhir_store, capsys): resource_type, resource_id) + fhir_resources.conditional_patch_resource( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id) + + history = fhir_resources.list_resource_history( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id) + + fhir_resources.get_resource_history( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id, + history['entry'][-1]['resource']['meta']['versionId']) + + fhir_resources.delete_resource_purge( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id) + + fhir_resources.conditional_delete_resource( + service_account_json, + base_url, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + resource_type, + resource_id) + fhir_resources.delete_resource( service_account_json, base_url, @@ -144,14 +203,16 @@ def test_CRUD_search_resource(test_dataset, test_fhir_store, capsys): # Check that create/search worked assert 'Created Resource' in out assert 'id' in out + assert 'Conditionally updated' in out assert 'search' in out + assert 'link' in out + assert ' deleted' in out assert resource_id in out assert 'Deleted Resource' in out -@pytest.mark.skip(reason='Disable until API whitelisted.') def test_get_patient_everything(test_dataset, test_fhir_store, capsys): - fhir_resources.create_resource( + response = fhir_resources.create_resource( service_account_json, base_url, project_id, @@ -160,7 +221,10 @@ def test_get_patient_everything(test_dataset, test_fhir_store, capsys): fhir_store_id, resource_type) - resource = fhir_resources.search_resources_get( + # Save the resource_id because you need to pass it into later tests + resource_id = response.json()['id'] + + fhir_resources.search_resources_get( service_account_json, base_url, project_id, @@ -169,10 +233,6 @@ def test_get_patient_everything(test_dataset, test_fhir_store, capsys): fhir_store_id, resource_type) - # Save the resource_id from the object returned by search_resources() - # because you need to pass it into get_resource() and delete_resource() - resource_id = resource["entry"][0]["resource"]["id"] - fhir_resources.get_patient_everything( service_account_json, base_url, @@ -197,7 +257,6 @@ def test_get_patient_everything(test_dataset, test_fhir_store, capsys): assert 'id' in out -@pytest.mark.skip(reason="no way of currently testing this") def test_get_metadata(test_dataset, test_fhir_store, capsys): fhir_resources.get_metadata( service_account_json, diff --git a/healthcare/api-client/fhir/fhir_stores.py b/healthcare/api-client/fhir/fhir_stores.py index 3d856db06a5..3624b37b053 100644 --- a/healthcare/api-client/fhir/fhir_stores.py +++ b/healthcare/api-client/fhir/fhir_stores.py @@ -25,7 +25,7 @@ def get_client(service_account_json, api_key): """Returns an authorized API client by discovering the Healthcare API and creating a service object using the service account credentials JSON.""" api_scopes = ['https://www.googleapis.com/auth/cloud-platform'] - api_version = 'v1alpha' + api_version = 'v1beta1' discovery_api = 'https://healthcare.googleapis.com/$discovery/rest' service_name = 'healthcare' @@ -33,7 +33,7 @@ def get_client(service_account_json, api_key): service_account_json) scoped_credentials = credentials.with_scopes(api_scopes) - discovery_url = '{}?labels=CHC_ALPHA&version={}&key={}'.format( + discovery_url = '{}?labels=CHC_BETA&version={}&key={}'.format( discovery_api, api_version, api_key) return discovery.build( @@ -220,9 +220,8 @@ def export_fhir_store_gcs( fhir_store_parent, fhir_store_id) body = { - "gcsDestinationLocation": - { - "gcsUri": 'gs://{}'.format(gcs_uri) + "gcsDestination": { + "uriPrefix": 'gs://{}/fhir_export'.format(gcs_uri) } } @@ -258,13 +257,9 @@ def import_fhir_store( fhir_store_parent, fhir_store_id) body = { - "gcsSourceLocation": - { - "gcsUri": 'gs://{}'.format(gcs_uri) - }, - "gcsErrorLocation": - { - "gcsUri": 'gs://{}/errors'.format(gcs_uri) + "contentStructure": "CONTENT_STRUCTURE_UNSPECIFIED", + "gcsSource": { + "uri": 'gs://{}'.format(gcs_uri) } } @@ -283,6 +278,86 @@ def import_fhir_store( # [END healthcare_import_fhir_store] +# [START healthcare_get_iam_policy] +def get_fhir_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + fhir_store_id): + """Gets the IAM policy for the specified FHIR store.""" + client = get_client(service_account_json, api_key) + fhir_store_parent = 'projects/{}/locations/{}/datasets/{}'.format( + project_id, cloud_region, dataset_id) + fhir_store_name = '{}/fhirStores/{}'.format( + fhir_store_parent, fhir_store_id) + + request = client.projects().locations().datasets().fhirStores( + ).getIamPolicy(resource=fhir_store_name) + response = request.execute() + + print('etag: {}'.format(response.get('name'))) + return response +# [END healthcare_get_iam_policy] + + +# [START healthcare_set_iam_policy] +def set_fhir_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + member, + role, + etag=None): + """Sets the IAM policy for the specified FHIR store. + + A single member will be assigned a single role. A member can be any of: + + - allUsers, that is, anyone + - allAuthenticatedUsers, anyone authenticated with a Google account + - user:email, as in 'user:somebody@example.com' + - group:email, as in 'group:admins@example.com' + - domain:domainname, as in 'domain:example.com' + - serviceAccount:email, + as in 'serviceAccount:my-other-app@appspot.gserviceaccount.com' + + A role can be any IAM role, such as 'roles/viewer', 'roles/owner', + or 'roles/editor' + """ + client = get_client(service_account_json, api_key) + fhir_store_parent = 'projects/{}/locations/{}/datasets/{}'.format( + project_id, cloud_region, dataset_id) + fhir_store_name = '{}/fhirStores/{}'.format( + fhir_store_parent, fhir_store_id) + + policy = { + "bindings": [ + { + "role": role, + "members": [ + member + ] + } + ] + } + + if etag is not None: + policy['etag'] = etag + + request = client.projects().locations().datasets().fhirStores( + ).setIamPolicy(resource=fhir_store_name, body={'policy': policy}) + response = request.execute() + + print('etag: {}'.format(response.get('name'))) + print('bindings: {}'.format(response.get('bindings'))) + return response +# [END healthcare_set_iam_policy] + + def parse_command_line_args(): """Parses command line arguments.""" @@ -333,6 +408,16 @@ def parse_command_line_args(): 'should be import or to which result files' 'should be written (e.g., "bucket-id/path/to/destination/dir").') + parser.add_argument( + '--member', + default=None, + help='Member to add to IAM policy (e.g. "domain:example.com")') + + parser.add_argument( + '--role', + default=None, + help='IAM Role to give to member (e.g. "roles/viewer")') + command = parser.add_subparsers(dest='command') command.add_parser('create-fhir-store', help=create_fhir_store.__doc__) @@ -346,6 +431,12 @@ def parse_command_line_args(): command.add_parser( 'export-fhir-store-gcs', help=export_fhir_store_gcs.__doc__) + command.add_parser( + 'get_iam_policy', + help=get_fhir_store_iam_policy.__doc__) + command.add_parser( + 'set_iam_policy', + help=set_fhir_store_iam_policy.__doc__) return parser.parse_args() @@ -422,6 +513,26 @@ def run_command(args): args.fhir_store_id, args.gcs_uri) + elif args.command == 'get_iam_policy': + get_fhir_store_iam_policy( + args.service_account_json, + args.api_key, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id) + + elif args.command == 'set_iam_policy': + set_fhir_store_iam_policy( + args.service_account_json, + args.api_key, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.member, + args.role) + def main(): args = parse_command_line_args() diff --git a/healthcare/api-client/fhir/fhir_stores_test.py b/healthcare/api-client/fhir/fhir_stores_test.py index 1dea3c10e0a..f23307e9068 100644 --- a/healthcare/api-client/fhir/fhir_stores_test.py +++ b/healthcare/api-client/fhir/fhir_stores_test.py @@ -60,7 +60,6 @@ def test_dataset(): dataset_id) -@pytest.mark.skip(reason='disable until API whitelisted / enabled') def test_CRUD_fhir_store(test_dataset, capsys): fhir_stores.create_fhir_store( service_account_json, @@ -102,7 +101,6 @@ def test_CRUD_fhir_store(test_dataset, capsys): assert 'Deleted FHIR store' in out -@pytest.mark.skip(reason='disable until API whitelisted / enabled') def test_patch_fhir_store(test_dataset, capsys): fhir_stores.create_fhir_store( service_account_json, @@ -150,6 +148,7 @@ def test_import_fhir_store_gcs(test_dataset, capsys): blob.upload_from_filename(resource_file) + time.sleep(5) # Give new blob time to propagate fhir_stores.import_fhir_store( service_account_json, api_key, @@ -205,3 +204,48 @@ def test_export_fhir_store_gcs(test_dataset, capsys): out, _ = capsys.readouterr() assert 'Exported FHIR resources to bucket' in out + + +def test_get_set_fhir_store_iam_policy(test_dataset, capsys): + fhir_stores.create_fhir_store( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + fhir_store_id) + + get_response = fhir_stores.get_fhir_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + fhir_store_id) + + set_response = fhir_stores.set_fhir_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + fhir_store_id, + 'serviceAccount:python-docs-samples-tests@appspot.gserviceaccount.com', + 'roles/viewer') + + # Clean up + fhir_stores.delete_fhir_store( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + fhir_store_id) + + out, _ = capsys.readouterr() + + assert 'etag' in get_response + assert 'bindings' in set_response + assert len(set_response['bindings']) == 1 + assert 'python-docs-samples-tests' in str(set_response['bindings']) + assert 'roles/viewer' in str(set_response['bindings']) diff --git a/healthcare/api-client/fhir/requirements.txt b/healthcare/api-client/fhir/requirements.txt index af65f3e9dce..10c96210675 100644 --- a/healthcare/api-client/fhir/requirements.txt +++ b/healthcare/api-client/fhir/requirements.txt @@ -2,4 +2,5 @@ google-api-python-client==1.7.8 google-auth-httplib2==0.0.3 google-auth==1.6.2 google-cloud==0.34.0 +google-cloud-storage==1.14.0 requests==2.21.0 diff --git a/healthcare/api-client/fhir/resources/Patient.json b/healthcare/api-client/fhir/resources/Patient.json new file mode 100644 index 00000000000..1b852d98633 --- /dev/null +++ b/healthcare/api-client/fhir/resources/Patient.json @@ -0,0 +1,2 @@ +{"birthDate":"1970-01-01","gender":"female","id":"b5525e54-cf0a-4412-96fe-c44596231d92","name":[{"family":"Smith","given":["Darcy"],"use":"official"}],"resourceType":"Patient"} +{"active":false,"id":"d9c75b09-4dcc-44ea-ad66-cc4ec6bac945","resourceType":"Patient"} diff --git a/healthcare/api-client/fhir/resources/execute_bundle.json b/healthcare/api-client/fhir/resources/execute_bundle.json new file mode 100644 index 00000000000..aff94f83702 --- /dev/null +++ b/healthcare/api-client/fhir/resources/execute_bundle.json @@ -0,0 +1,62 @@ +{ + "type":"%s", + "entry":[ + { + "request":{ + "method":"POST", + "url":"Patient" + }, + "resource":{ + "resourceType":"Patient", + "active":false, + "name":[ + { + "use":"usual", + "text":"Marcus Smith", + "family":"Smith", + "given":[ + "Marcus" + ] + } + ], + "gender":"male" + } + }, + { + "request":{ + "method":"POST", + "url":"Observation" + }, + "resource":{ + "resourceType":"Observation", + "status":"final", + "valueQuantity":{ + "value":99, + "unit":"mg", + "system":"si" + }, + "code":{ + "coding":[ + { + "system":"system1", + "code":"value1" + } + ] + } + } + }, + { + "request": { + "method": "GET", + "url": "/Patient?gender=male" + } + }, + { + "request": { + "method": "GET", + "url": "/Observation?status=final" + } + } + ], + "resourceType":"Bundle" +} diff --git a/healthcare/api-client/hl7v2/hl7v2_messages.py b/healthcare/api-client/hl7v2/hl7v2_messages.py index 35baba5f7ce..a40b92e2823 100644 --- a/healthcare/api-client/hl7v2/hl7v2_messages.py +++ b/healthcare/api-client/hl7v2/hl7v2_messages.py @@ -26,7 +26,7 @@ def get_client(service_account_json, api_key): """Returns an authorized API client by discovering the Healthcare API and creating a service object using the service account credentials JSON.""" api_scopes = ['https://www.googleapis.com/auth/cloud-platform'] - api_version = 'v1alpha' + api_version = 'v1beta1' discovery_api = 'https://healthcare.googleapis.com/$discovery/rest' service_name = 'healthcare' @@ -34,7 +34,7 @@ def get_client(service_account_json, api_key): service_account_json) scoped_credentials = credentials.with_scopes(api_scopes) - discovery_url = '{}?labels=CHC_ALPHA&version={}&key={}'.format( + discovery_url = '{}?labels=CHC_BETA&version={}&key={}'.format( discovery_api, api_version, api_key) return discovery.build( diff --git a/healthcare/api-client/hl7v2/hl7v2_messages_test.py b/healthcare/api-client/hl7v2/hl7v2_messages_test.py index 185f69875fc..505a96816af 100644 --- a/healthcare/api-client/hl7v2/hl7v2_messages_test.py +++ b/healthcare/api-client/hl7v2/hl7v2_messages_test.py @@ -77,7 +77,6 @@ def test_hl7v2_store(): hl7v2_store_id) -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_CRUD_hl7v2_message(test_dataset, test_hl7v2_store, capsys): hl7v2_messages.create_hl7v2_message( service_account_json, @@ -125,7 +124,6 @@ def test_CRUD_hl7v2_message(test_dataset, test_hl7v2_store, capsys): assert 'Deleted HL7v2 message' in out -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_ingest_hl7v2_message(test_dataset, test_hl7v2_store, capsys): hl7v2_messages.ingest_hl7v2_message( service_account_json, @@ -173,7 +171,6 @@ def test_ingest_hl7v2_message(test_dataset, test_hl7v2_store, capsys): assert 'Deleted HL7v2 message' in out -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_patch_hl7v2_message(test_dataset, test_hl7v2_store, capsys): hl7v2_messages.create_hl7v2_message( service_account_json, diff --git a/healthcare/api-client/hl7v2/hl7v2_stores.py b/healthcare/api-client/hl7v2/hl7v2_stores.py index e1388fada7d..aa34f36f617 100644 --- a/healthcare/api-client/hl7v2/hl7v2_stores.py +++ b/healthcare/api-client/hl7v2/hl7v2_stores.py @@ -25,7 +25,7 @@ def get_client(service_account_json, api_key): """Returns an authorized API client by discovering the Healthcare API and creating a service object using the service account credentials JSON.""" api_scopes = ['https://www.googleapis.com/auth/cloud-platform'] - api_version = 'v1alpha' + api_version = 'v1beta1' discovery_api = 'https://healthcare.googleapis.com/$discovery/rest' service_name = 'healthcare' @@ -33,7 +33,7 @@ def get_client(service_account_json, api_key): service_account_json) scoped_credentials = credentials.with_scopes(api_scopes) - discovery_url = '{}?labels=CHC_ALPHA&version={}&key={}'.format( + discovery_url = '{}?labels=CHC_BETA&version={}&key={}'.format( discovery_api, api_version, api_key) return discovery.build( @@ -194,6 +194,86 @@ def patch_hl7v2_store( # [END healthcare_patch_hl7v2_store] +# [START healthcare_hl7v2_store_get_iam_policy] +def get_hl7v2_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + hl7v2_store_id): + """Gets the IAM policy for the specified hl7v2 store.""" + client = get_client(service_account_json, api_key) + hl7v2_store_parent = 'projects/{}/locations/{}/datasets/{}'.format( + project_id, cloud_region, dataset_id) + hl7v2_store_name = '{}/hl7V2Stores/{}'.format( + hl7v2_store_parent, hl7v2_store_id) + + request = client.projects().locations().datasets().hl7V2Stores( + ).getIamPolicy(resource=hl7v2_store_name) + response = request.execute() + + print('etag: {}'.format(response.get('name'))) + return response +# [END healthcare_hl7v2_store_get_iam_policy] + + +# [START healthcare_hl7v2_store_set_iam_policy] +def set_hl7v2_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + hl7v2_store_id, + member, + role, + etag=None): + """Sets the IAM policy for the specified hl7v2 store. + + A single member will be assigned a single role. A member can be any of: + + - allUsers, that is, anyone + - allAuthenticatedUsers, anyone authenticated with a Google account + - user:email, as in 'user:somebody@example.com' + - group:email, as in 'group:admins@example.com' + - domain:domainname, as in 'domain:example.com' + - serviceAccount:email, + as in 'serviceAccount:my-other-app@appspot.gserviceaccount.com' + + A role can be any IAM role, such as 'roles/viewer', 'roles/owner', + or 'roles/editor' + """ + client = get_client(service_account_json, api_key) + hl7v2_store_parent = 'projects/{}/locations/{}/datasets/{}'.format( + project_id, cloud_region, dataset_id) + hl7v2_store_name = '{}/hl7V2Stores/{}'.format( + hl7v2_store_parent, hl7v2_store_id) + + policy = { + "bindings": [ + { + "role": role, + "members": [ + member + ] + } + ] + } + + if etag is not None: + policy['etag'] = etag + + request = client.projects().locations().datasets().hl7V2Stores( + ).setIamPolicy(resource=hl7v2_store_name, body={'policy': policy}) + response = request.execute() + + print('etag: {}'.format(response.get('name'))) + print('bindings: {}'.format(response.get('bindings'))) + return response +# [END healthcare_hl7v2_store_set_iam_policy] + + def parse_command_line_args(): """Parses command line arguments.""" @@ -237,6 +317,16 @@ def parse_command_line_args(): help='The Cloud Pub/Sub topic where notifications of changes ' 'are published') + parser.add_argument( + '--member', + default=None, + help='Member to add to IAM policy (e.g. "domain:example.com")') + + parser.add_argument( + '--role', + default=None, + help='IAM Role to give to member (e.g. "roles/viewer")') + command = parser.add_subparsers(dest='command') command.add_parser('create-hl7v2-store', help=create_hl7v2_store.__doc__) @@ -244,6 +334,12 @@ def parse_command_line_args(): command.add_parser('get-hl7v2-store', help=get_hl7v2_store.__doc__) command.add_parser('list-hl7v2-stores', help=list_hl7v2_stores.__doc__) command.add_parser('patch-hl7v2-store', help=patch_hl7v2_store.__doc__) + command.add_parser( + 'get_iam_policy', + help=get_hl7v2_store_iam_policy.__doc__) + command.add_parser( + 'set_iam_policy', + help=set_hl7v2_store_iam_policy.__doc__) return parser.parse_args() @@ -300,6 +396,26 @@ def run_command(args): args.hl7v2_store_id, args.pubsub_topic) + elif args.command == 'get_hl7v2_store_iam_policy': + get_hl7v2_store_iam_policy( + args.service_account_json, + args.api_key, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id) + + elif args.command == 'set_hl7v2_store_iam_policy': + set_hl7v2_store_iam_policy( + args.service_account_json, + args.api_key, + args.project_id, + args.cloud_region, + args.dataset_id, + args.fhir_store_id, + args.member, + args.role) + def main(): args = parse_command_line_args() diff --git a/healthcare/api-client/hl7v2/hl7v2_stores_test.py b/healthcare/api-client/hl7v2/hl7v2_stores_test.py index 7768e832e18..ab59ae1aa7b 100644 --- a/healthcare/api-client/hl7v2/hl7v2_stores_test.py +++ b/healthcare/api-client/hl7v2/hl7v2_stores_test.py @@ -52,7 +52,6 @@ def test_dataset(): dataset_id) -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_CRUD_hl7v2_store(test_dataset, capsys): hl7v2_stores.create_hl7v2_store( service_account_json, @@ -94,7 +93,6 @@ def test_CRUD_hl7v2_store(test_dataset, capsys): assert 'Deleted HL7v2 store' in out -@pytest.mark.skip(reason='disable until have access to healthcare api') def test_patch_hl7v2_store(test_dataset, capsys): hl7v2_stores.create_hl7v2_store( service_account_json, @@ -125,3 +123,48 @@ def test_patch_hl7v2_store(test_dataset, capsys): out, _ = capsys.readouterr() assert 'Patched HL7v2 store' in out + + +def test_get_set_hl7v2_store_iam_policy(test_dataset, capsys): + hl7v2_stores.create_hl7v2_store( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + hl7v2_store_id) + + get_response = hl7v2_stores.get_hl7v2_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + hl7v2_store_id) + + set_response = hl7v2_stores.set_hl7v2_store_iam_policy( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + hl7v2_store_id, + 'serviceAccount:python-docs-samples-tests@appspot.gserviceaccount.com', + 'roles/viewer') + + # Clean up + hl7v2_stores.delete_hl7v2_store( + service_account_json, + api_key, + project_id, + cloud_region, + dataset_id, + hl7v2_store_id) + + out, _ = capsys.readouterr() + + assert 'etag' in get_response + assert 'bindings' in set_response + assert len(set_response['bindings']) == 1 + assert 'python-docs-samples-tests' in str(set_response['bindings']) + assert 'roles/viewer' in str(set_response['bindings'])