diff --git a/.ci/integration.cloudbuild.yaml b/.ci/integration.cloudbuild.yaml index 3f2b4ae6f5..44a689e636 100644 --- a/.ci/integration.cloudbuild.yaml +++ b/.ci/integration.cloudbuild.yaml @@ -214,6 +214,29 @@ steps: dataform \ dataform + - id: "cloud-healthcare" + name: golang:1 + waitFor: ["compile-test-binary"] + entrypoint: /bin/bash + env: + - "GOPATH=/gopath" + - "HEALTHCARE_PROJECT=$PROJECT_ID" + - "SERVICE_ACCOUNT_EMAIL=$SERVICE_ACCOUNT_EMAIL" + - "HEALTHCARE_REGION=$_REGION" + - "HEALTHCARE_DATASET=$_HEALTHCARE_DATASET" + - "HEALTHCARE_PREPOPULATED_DICOM_STORE=$_HEALTHCARE_PREPOPULATED_DICOM_STORE" + secretEnv: ["CLIENT_ID"] + volumes: + - name: "go" + path: "/gopath" + args: + - -c + - | + .ci/test_with_coverage.sh \ + "Cloud Healthcare API" \ + cloudhealthcare \ + cloudhealthcare + - id: "postgres" name: golang:1 waitFor: ["compile-test-binary"] @@ -937,6 +960,8 @@ substitutions: _ALLOYDB_AI_NL_CLUSTER: "alloydb-ai-nl-testing" _ALLOYDB_AI_NL_INSTANCE: "alloydb-ai-nl-testing-instance" _BIGTABLE_INSTANCE: "bigtable-testing-instance" + _HEALTHCARE_DATASET: "test-dataset" + _HEALTHCARE_PREPOPULATED_DICOM_STORE: "prepopulated-test-dicom-store" _POSTGRES_HOST: 127.0.0.1 _POSTGRES_PORT: "5432" _SPANNER_INSTANCE: "spanner-testing" diff --git a/cmd/root.go b/cmd/root.go index 226713e3a8..c88f376752 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -69,6 +69,21 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/clickhouse/clickhouselistdatabases" _ "github.com/googleapis/genai-toolbox/internal/tools/clickhouse/clickhouselisttables" _ "github.com/googleapis/genai-toolbox/internal/tools/clickhouse/clickhousesql" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarefhirfetchpage" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarefhirpatienteverything" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarefhirpatientsearch" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetdataset" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstore" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstoremetrics" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetfhirresource" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstore" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstoremetrics" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarelistdicomstores" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarelistfhirstores" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcareretrieverendereddicominstance" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicominstances" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomseries" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudmonitoring" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlcreatedatabase" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlcreateusers" @@ -192,6 +207,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/sources/bigtable" _ "github.com/googleapis/genai-toolbox/internal/sources/cassandra" _ "github.com/googleapis/genai-toolbox/internal/sources/clickhouse" + _ "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" _ "github.com/googleapis/genai-toolbox/internal/sources/cloudmonitoring" _ "github.com/googleapis/genai-toolbox/internal/sources/cloudsqladmin" _ "github.com/googleapis/genai-toolbox/internal/sources/cloudsqlmssql" diff --git a/cmd/root_test.go b/cmd/root_test.go index af53a0262f..bd784d2145 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1256,6 +1256,7 @@ func TestPrebuiltTools(t *testing.T) { cloudsqlmysqlobsvconfig, _ := prebuiltconfigs.Get("cloud-sql-mysql-observability") cloudsqlmssqlobsvconfig, _ := prebuiltconfigs.Get("cloud-sql-mssql-observability") serverless_spark_config, _ := prebuiltconfigs.Get("serverless-spark") + cloudhealthcare_config, _ := prebuiltconfigs.Get("cloud-healthcare") // Set environment variables t.Setenv("API_KEY", "your_api_key") @@ -1349,6 +1350,10 @@ func TestPrebuiltTools(t *testing.T) { t.Setenv("NEO4J_USERNAME", "your_neo4j_user") t.Setenv("NEO4J_PASSWORD", "your_neo4j_password") + t.Setenv("CLOUD_HEALTHCARE_PROJECT", "your_gcp_project_id") + t.Setenv("CLOUD_HEALTHCARE_REGION", "your_gcp_region") + t.Setenv("CLOUD_HEALTHCARE_DATASET", "your_healthcare_dataset") + ctx, err := testutils.ContextWithNewLogger() if err != nil { t.Fatalf("unexpected error: %s", err) @@ -1628,6 +1633,24 @@ func TestPrebuiltTools(t *testing.T) { }, }, }, + { + name: "cloud healthcare prebuilt tools", + in: cloudhealthcare_config, + wantToolset: server.ToolsetConfigs{ + "cloud_healthcare_dataset_tools": tools.ToolsetConfig{ + Name: "cloud_healthcare_dataset_tools", + ToolNames: []string{"get_dataset", "list_dicom_stores", "list_fhir_stores"}, + }, + "cloud_healthcare_fhir_tools": tools.ToolsetConfig{ + Name: "cloud_healthcare_fhir_tools", + ToolNames: []string{"get_fhir_store", "get_fhir_store_metrics", "get_fhir_resource", "fhir_patient_search", "fhir_patient_everything", "fhir_fetch_page"}, + }, + "cloud_healthcare_dicom_tools": tools.ToolsetConfig{ + Name: "cloud_healthcare_dicom_tools", + ToolNames: []string{"get_dicom_store", "get_dicom_store_metrics", "search_dicom_studies", "search_dicom_series", "search_dicom_instances", "retrieve_rendered_dicom_instance"}, + }, + }, + }, } for _, tc := range tcs { diff --git a/docs/en/reference/prebuilt-tools.md b/docs/en/reference/prebuilt-tools.md index c124d4eec6..757afce02a 100644 --- a/docs/en/reference/prebuilt-tools.md +++ b/docs/en/reference/prebuilt-tools.md @@ -590,3 +590,33 @@ details on how to connect your AI tools (IDEs) to databases via Toolbox and MCP. * **Tools:** * `execute_cypher`: Executes a Cypher query. * `get_schema`: Retrieves the schema of the Neo4j database. + +## Google Cloud Healthcare API +* `--prebuilt` value: `cloud-healthcare` +* **Environment Variables:** + * `CLOUD_HEALTHCARE_PROJECT`: The GCP project ID. + * `CLOUD_HEALTHCARE_REGION`: The Cloud Healthcare API dataset region. + * `CLOUD_HEALTHCARE_DATASET`: The Cloud Healthcare API dataset ID. + * `CLOUD_HEALTHCARE_USE_CLIENT_OAUTH`: (Optional) If `true`, forwards the client's + OAuth access token for authentication. Defaults to `false`. +* **Permissions:** + * **Healthcare FHIR Resource Reader** (`roles/healthcare.fhirResourceReader`) to read an + search FHIR resources. + * **Healthcare DICOM Viewer** (`roles/healthcare.dicomViewer`) to retrieve DICOM images from a + DICOM store. +* **Tools:** + * `get_dataset`: Gets information about a Cloud Healthcare API dataset. + * `list_dicom_stores`: Lists DICOM stores in a Cloud Healthcare API dataset. + * `list_fhir_stores`: Lists FHIR stores in a Cloud Healthcare API dataset. + * `get_fhir_store`: Gets information about a FHIR store. + * `get_fhir_store_metrics`: Gets metrics for a FHIR store. + * `get_fhir_resource`: Gets a FHIR resource from a FHIR store. + * `fhir_patient_search`: Searches for patient resource(s) based on a set of criteria. + * `fhir_patient_everything`: Retrieves resources related to a given patient. + * `fhir_fetch_page`: Fetches a page of FHIR resources. + * `get_dicom_store`: Gets information about a DICOM store. + * `get_dicom_store_metrics`: Gets metrics for a DICOM store. + * `search_dicom_studies`: Searches for DICOM studies. + * `search_dicom_series`: Searches for DICOM series. + * `search_dicom_instances`: Searches for DICOM instances. + * `retrieve_rendered_dicom_instance`: Retrieves a rendered DICOM instance. diff --git a/docs/en/resources/sources/cloud-healthcare.md b/docs/en/resources/sources/cloud-healthcare.md new file mode 100644 index 0000000000..bf5d9d51dd --- /dev/null +++ b/docs/en/resources/sources/cloud-healthcare.md @@ -0,0 +1,164 @@ +--- +title: "Cloud Healthcare API" +linkTitle: "Cloud Healthcare" +type: docs +weight: 1 +description: > + The Cloud Healthcare API provides a managed solution for storing and + accessing healthcare data in Google Cloud, providing a critical bridge + between existing care systems and applications hosted on Google Cloud. +--- + +## About + +The [Cloud Healthcare API][healthcare-docs] provides a managed solution +for storing and accessing healthcare data in Google Cloud, providing a +critical bridge between existing care systems and applications hosted on +Google Cloud. It supports healthcare data standards such as HL7® FHIR®, +HL7® v2, and DICOM®. It provides a fully managed, highly scalable, +enterprise-grade development environment for building clinical and analytics +solutions securely on Google Cloud. + +A dataset is a container in your Google Cloud project that holds modality-specific +healthcare data. Datasets contain other data stores, such as FHIR stores and DICOM +stores, which in turn hold their own types of healthcare data. + +A single dataset can contain one or many data stores, and those stores can all service +the same modality or different modalities as application needs dictate. Using multiple +stores in the same dataset might be appropriate in various situations. + +If you are new to the Cloud Healthcare API, you can try to +[create and view datasets and stores using curl][healthcare-quickstart-curl]. + +[healthcare-docs]: https://cloud.google.com/healthcare/docs +[healthcare-quickstart-curl]: + https://cloud.google.com/healthcare-api/docs/store-healthcare-data-rest + +## Available Tools + +- [`cloud-healthcare-get-dataset`](../tools/cloudhealthcare/cloud-healthcare-get-dataset.md) + Retrieves a dataset’s details. + +- [`cloud-healthcare-list-fhir-stores`](../tools/cloudhealthcare/cloud-healthcare-list-fhir-stores.md) + Lists the available FHIR stores in the healthcare dataset. + +- [`cloud-healthcare-list-dicom-stores`](../tools/cloudhealthcare/cloud-healthcare-list-dicom-stores.md) + Lists the available DICOM stores in the healthcare dataset. + +- [`cloud-healthcare-get-fhir-store`](../tools/cloudhealthcare/cloud-healthcare-get-fhir-store.md) + Retrieves information about a FHIR store. + +- [`cloud-healthcare-get-fhir-store-metrics`](../tools/cloudhealthcare/cloud-healthcare-get-fhir-store-metrics.md) + Retrieves metrics for a FHIR store. + +- [`cloud-healthcare-get-fhir-resource`](../tools/cloudhealthcare/cloud-healthcare-get-fhir-resource.md) + Retrieves a specific FHIR resource from a FHIR store. + +- [`cloud-healthcare-fhir-patient-search`](../tools/cloudhealthcare/cloud-healthcare-fhir-patient-search.md) + Searches for patients in a FHIR store based on a set of criteria. + +- [`cloud-healthcare-fhir-patient-everything`](../tools/cloudhealthcare/cloud-healthcare-fhir-patient-everything.md) + Retrieves all information for a given patient. + +- [`cloud-healthcare-fhir-fetch-page`](../tools/cloudhealthcare/cloud-healthcare-fhir-fetch-page.md) + Fetches a page of FHIR resources from a given URL. + +- [`cloud-healthcare-get-dicom-store`](../tools/cloudhealthcare/cloud-healthcare-get-dicom-store.md) + Retrieves information about a DICOM store. + +- [`cloud-healthcare-get-dicom-store-metrics`](../tools/cloudhealthcare/cloud-healthcare-get-dicom-store-metrics.md) + Retrieves metrics for a DICOM store. + +- [`cloud-healthcare-search-dicom-studies`](../tools/cloudhealthcare/cloud-healthcare-search-dicom-studies.md) + Searches for DICOM studies in a DICOM store. + +- [`cloud-healthcare-search-dicom-series`](../tools/cloudhealthcare/cloud-healthcare-search-dicom-series.md) + Searches for DICOM series in a DICOM store. + +- [`cloud-healthcare-search-dicom-instances`](../tools/cloudhealthcare/cloud-healthcare-search-dicom-instances.md) + Searches for DICOM instances in a DICOM store. + +- [`cloud-healthcare-retrieve-rendered-dicom-instance`](../tools/cloudhealthcare/cloud-healthcare-retrieve-rendered-dicom-instance.md) + Retrieves a rendered DICOM instance from a DICOM store. + +## Requirements + +### IAM Permissions + +The Cloud Healthcare API uses [Identity and Access Management (IAM)][iam-overview] to control +user and group access to Cloud Healthcare resources like projects, datasets, and stores. + +### Authentication via Application Default Credentials (ADC) + +By **default**, Toolbox will use your [Application Default Credentials +(ADC)][adc] to authorize and authenticate when interacting with the +[Cloud Healthcare API][healthcare-docs]. + +When using this method, you need to ensure the IAM identity associated with your +ADC (such as a service account) has the correct permissions for the queries you +intend to run. Common roles include `roles/healthcare.fhirResourceReader` (which includes +permissions to read and search for FHIR resources) or `roles/healthcare.dicomViewer` (for +retrieving DICOM images). +Follow this [guide][set-adc] to set up your ADC. + +### Authentication via User's OAuth Access Token + +If the `useClientOAuth` parameter is set to `true`, Toolbox will instead use the +OAuth access token for authentication. This token is parsed from the +`Authorization` header passed in with the tool invocation request. This method +allows Toolbox to make queries to the [Cloud Healthcare API][healthcare-docs] on behalf of the +client or the end-user. + +When using this on-behalf-of authentication, you must ensure that the +identity used has been granted the correct IAM permissions. + +[iam-overview]: +[adc]: +[set-adc]: + +## Example + +Initialize a Cloud Healthcare API source that uses ADC: + +```yaml +sources: + my-healthcare-source: + kind: "cloud-healthcare" + project: "my-project-id" + region: "us-central1" + dataset: "my-healthcare-dataset-id" + # allowedFhirStores: # Optional: Restricts tool access to a specific list of FHIR store IDs. + # - "my_fhir_store_1" + # allowedDicomStores: # Optional: Restricts tool access to a specific list of DICOM store IDs. + # - "my_dicom_store_1" + # - "my_dicom_store_2" +``` + +Initialize a Cloud Healthcare API source that uses the client's access token: + +```yaml +sources: + my-healthcare-client-auth-source: + kind: "cloud-healthcare" + project: "my-project-id" + region: "us-central1" + dataset: "my-healthcare-dataset-id" + useClientOAuth: true + # allowedFhirStores: # Optional: Restricts tool access to a specific list of FHIR store IDs. + # - "my_fhir_store_1" + # allowedDicomStores: # Optional: Restricts tool access to a specific list of DICOM store IDs. + # - "my_dicom_store_1" + # - "my_dicom_store_2" +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|--------------------|:--------:|:------------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare". | +| project | string | true | ID of the GCP project that the dataset lives in. | +| region | string | true | Specifies the region (e.g., 'us', 'asia-northeast1') of the healthcare dataset. [Learn More](https://cloud.google.com/healthcare-api/docs/regions) | +| dataset | string | true | ID of the healthcare dataset. | +| allowedFhirStores | []string | false | An optional list of FHIR store IDs that tools using this source are allowed to access. If provided, any tool operation attempting to access a store not in this list will be rejected. If a single store is provided, it will be treated as the default for prebuilt tools. | +| allowedDicomStores | []string | false | An optional list of DICOM store IDs that tools using this source are allowed to access. If provided, any tool operation attempting to access a store not in this list will be rejected. If a single store is provided, it will be treated as the default for prebuilt tools. | +| useClientOAuth | bool | false | If true, forwards the client's OAuth access token from the "Authorization" header to downstream queries. | diff --git a/docs/en/resources/tools/cloudhealthcare/_index.md b/docs/en/resources/tools/cloudhealthcare/_index.md new file mode 100644 index 0000000000..e50f2174b5 --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/_index.md @@ -0,0 +1,8 @@ +--- +title: "Cloud Healthcare API" +linkTitle: "Cloud Healthcare" +type: docs +weight: 1 +description: > + Tools that work with Cloud Healthcare Sources. +--- \ No newline at end of file diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-fhir-fetch-page.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-fhir-fetch-page.md new file mode 100644 index 0000000000..86ef607eee --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-fhir-fetch-page.md @@ -0,0 +1,43 @@ +--- +title: "cloud-healthcare-fhir-fetch-page" +type: docs +weight: 1 +description: > + A "cloud-healthcare-fhir-fetch-page" tool fetches a page of FHIR resources from a given URL. +aliases: +- /resources/tools/cloud-healthcare-fhir-fetch-page +--- + +## About + +A `cloud-healthcare-fhir-fetch-page` tool fetches a page of FHIR resources from a given URL. It's +compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-fhir-fetch-page` can be used for pagination when a previous tool call (like +`cloud-healthcare-fhir-patient-search` or `cloud-healthcare-fhir-patient-everything`) returns a 'next' link in the response bundle. + +## Example + +```yaml +tools: + get_fhir_store: + kind: cloud-healthcare-fhir-fetch-page + source: my-healthcare-source + description: Use this tool to fetch a page of FHIR resources from a FHIR Bundle's entry.link.url +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-fhir-fetch-page". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|-----------|:--------:|:------------:|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| pageURL | string | true | The full URL of the FHIR page to fetch. This would usually be the value of `Bundle.entry.link.url` field within the response returned from FHIR search or FHIR patient everything operations. | diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-fhir-patient-everything.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-fhir-patient-everything.md new file mode 100644 index 0000000000..52db466464 --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-fhir-patient-everything.md @@ -0,0 +1,49 @@ +--- +title: "cloud-healthcare-fhir-patient-everything" +type: docs +weight: 1 +description: > + A "cloud-healthcare-fhir-patient-everything" tool retrieves all information for a given patient. +aliases: +- /resources/tools/cloud-healthcare-fhir-patient-everything +--- + +## About + +A `cloud-healthcare-fhir-patient-everything` tool retrieves resources related to a given patient +from a FHIR store. It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-fhir-patient-everything` returns all the information available for a given +patient ID. It can be configured to only return certain resource types, or only +resources that have been updated after a given time. + +## Example + +```yaml +tools: + fhir_patient_everything: + kind: cloud-healthcare-fhir-patient-everything + source: my-healthcare-source + description: Use this tool to retrieve all the information about a given patient. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|-----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-fhir-patient-everything". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|---------------------|:--------:|:------------:|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| patientID | string | true | The ID of the patient FHIR resource for which the information is required. | +| resourceTypesFilter | string | false | String of comma-delimited FHIR resource types. If provided, only resources of the specified resource type(s) are returned. | +| sinceFilter | string | false | If provided, only resources updated after this time are returned. The time uses the format YYYY-MM-DDThh:mm:ss.sss+zz:zz. The time must be specified to the second and include a time zone. For example, 2015-02-07T13:28:17.239+02:00 or 2017-01-01T00:00:00Z. | +| storeID | string | true* | The FHIR store ID to search in. | + +*If the `allowedFHIRStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-fhir-patient-search.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-fhir-patient-search.md new file mode 100644 index 0000000000..0c792d25bc --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-fhir-patient-search.md @@ -0,0 +1,63 @@ +--- +title: "cloud-healthcare-fhir-patient-search" +type: docs +weight: 1 +description: > + A "cloud-healthcare-fhir-patient-search" tool searches for patients in a FHIR store. +aliases: +- /resources/tools/cloud-healthcare-fhir-patient-search +--- + +## About + +A `cloud-healthcare-fhir-patient-search` tool searches for patients in a FHIR store based on a +set of criteria. It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-fhir-patient-search` returns a list of patients that match the given criteria. + +## Example + +```yaml +tools: + fhir_patient_search: + kind: cloud-healthcare-fhir-patient-search + source: my-healthcare-source + description: Use this tool to search for patients in the FHIR store. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-fhir-patient-search". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|------------------|:--------:|:------------:|--------------------------------------------------------------------------------| +| active | string | false | Whether the patient record is active. | +| city | string | false | The city of the patient's address. | +| country | string | false | The country of the patient's address. | +| postalcode | string | false | The postal code of the patient's address. | +| state | string | false | The state of the patient's address. | +| addressSubstring | string | false | A substring to search for in any address field. | +| birthDateRange | string | false | A date range for the patient's birth date in the format YYYY-MM-DD/YYYY-MM-DD. | +| deathDateRange | string | false | A date range for the patient's death date in the format YYYY-MM-DD/YYYY-MM-DD. | +| deceased | string | false | Whether the patient is deceased. | +| email | string | false | The patient's email address. | +| gender | string | false | The patient's gender. | +| addressUse | string | false | The use of the patient's address. | +| name | string | false | The patient's name. | +| givenName | string | false | A portion of the given name of the patient. | +| familyName | string | false | A portion of the family name of the patient. | +| phone | string | false | The patient's phone number. | +| language | string | false | The patient's preferred language. | +| identifier | string | false | An identifier for the patient. | +| summary | boolean | false | Requests the server to return a subset of the resource. True by default. | +| storeID | string | true* | The FHIR store ID to search in. | + +*If the `allowedFHIRStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-dataset.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-dataset.md new file mode 100644 index 0000000000..0bcdb6896d --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-dataset.md @@ -0,0 +1,37 @@ +--- +title: "cloud-healthcare-get-dataset" +type: docs +weight: 1 +description: > + A "cloud-healthcare-get-dataset" tool retrieves metadata for the Healthcare dataset in the source. +aliases: +- /resources/tools/cloud-healthcare-get-dataset +--- + +## About + +A `cloud-healthcare-get-dataset` tool retrieves metadata for a Healthcare dataset. +It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-get-dataset` returns the metadata of the healthcare dataset +configured in the source. It takes no extra parameters. + +## Example + +```yaml +tools: + get_dataset: + kind: cloud-healthcare-get-dataset + source: my-healthcare-source + description: Use this tool to get healthcare dataset metadata. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:------------------------------------------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-get-dataset". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-dicom-store-metrics.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-dicom-store-metrics.md new file mode 100644 index 0000000000..0fcbaaa62d --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-dicom-store-metrics.md @@ -0,0 +1,44 @@ +--- +title: "cloud-healthcare-get-dicom-store-metrics" +type: docs +weight: 1 +description: > + A "cloud-healthcare-get-dicom-store-metrics" tool retrieves metrics for a DICOM store. +aliases: +- /resources/tools/cloud-healthcare-get-dicom-store-metrics +--- + +## About + +A `cloud-healthcare-get-dicom-store-metrics` tool retrieves metrics for a DICOM store. It's +compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-get-dicom-store-metrics` returns the metrics of a DICOM store. + +## Example + +```yaml +tools: + get_dicom_store_metrics: + kind: cloud-healthcare-get-dicom-store-metrics + source: my-healthcare-source + description: Use this tool to get metrics for a DICOM store. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|-----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-get-dicom-store-metrics". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|-----------|:--------:|:------------:|----------------------------------------| +| storeID | string | true* | The DICOM store ID to get metrics for. | + +*If the `allowedDICOMStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-dicom-store.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-dicom-store.md new file mode 100644 index 0000000000..9828b06aea --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-dicom-store.md @@ -0,0 +1,44 @@ +--- +title: "cloud-healthcare-get-dicom-store" +type: docs +weight: 1 +description: > + A "cloud-healthcare-get-dicom-store" tool retrieves information about a DICOM store. +aliases: +- /resources/tools/cloud-healthcare-get-dicom-store +--- + +## About + +A `cloud-healthcare-get-dicom-store` tool retrieves information about a DICOM store. It's +compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-get-dicom-store` returns the details of a DICOM store. + +## Example + +```yaml +tools: + get_dicom_store: + kind: cloud-healthcare-get-dicom-store + source: my-healthcare-source + description: Use this tool to get information about a DICOM store. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-get-dicom-store". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|-----------|:--------:|:------------:|----------------------------------------| +| storeID | string | true* | The DICOM store ID to get details for. | + +*If the `allowedDICOMStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-fhir-resource.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-fhir-resource.md new file mode 100644 index 0000000000..7534a9c39b --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-fhir-resource.md @@ -0,0 +1,47 @@ +--- +title: "cloud-healthcare-get-fhir-resource" +linkTitle: "cloud-healthcare-get-fhir-resource" +type: docs +weight: 1 +description: > + A "cloud-healthcare-get-fhir-resource" tool retrieves a specific FHIR resource. +aliases: +- /resources/tools/cloud-healthcare-get-fhir-resource +--- + +## About + +A `cloud-healthcare-get-fhir-resource` tool retrieves a specific FHIR resource from a FHIR store. +It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-get-fhir-resource` returns a single FHIR resource, identified by its type and ID. + +## Example + +```yaml +tools: + get_fhir_resource: + kind: cloud-healthcare-get-fhir-resource + source: my-healthcare-source + description: Use this tool to retrieve a specific FHIR resource. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-get-fhir-resource". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|--------------|:--------:|:------------:|------------------------------------------------------------------| +| resourceType | string | true | The FHIR resource type to retrieve (e.g., Patient, Observation). | +| resourceID | string | true | The ID of the FHIR resource to retrieve. | +| storeID | string | true* | The FHIR store ID to retrieve the resource from. | + +*If the `allowedFHIRStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-fhir-store-metrics.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-fhir-store-metrics.md new file mode 100644 index 0000000000..e5209e6118 --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-fhir-store-metrics.md @@ -0,0 +1,44 @@ +--- +title: "cloud-healthcare-get-fhir-store-metrics" +type: docs +weight: 1 +description: > + A "cloud-healthcare-get-fhir-store-metrics" tool retrieves metrics for a FHIR store. +aliases: +- /resources/tools/cloud-healthcare-get-fhir-store-metrics +--- + +## About + +A `cloud-healthcare-get-fhir-store-metrics` tool retrieves metrics for a FHIR store. It's +compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-get-fhir-store-metrics` returns the metrics of a FHIR store. + +## Example + +```yaml +tools: + get_fhir_store_metrics: + kind: cloud-healthcare-get-fhir-store-metrics + source: my-healthcare-source + description: Use this tool to get metrics for a FHIR store. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-get-fhir-store-metrics". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|-----------|:--------:|:------------:|---------------------------------------| +| storeID | string | true* | The FHIR store ID to get metrics for. | + +*If the `allowedFHIRStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-fhir-store.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-fhir-store.md new file mode 100644 index 0000000000..a9ce8d4aee --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-get-fhir-store.md @@ -0,0 +1,44 @@ +--- +title: "cloud-healthcare-get-fhir-store" +type: docs +weight: 1 +description: > + A "cloud-healthcare-get-fhir-store" tool retrieves information about a FHIR store. +aliases: +- /resources/tools/cloud-healthcare-get-fhir-store +--- + +## About + +A `cloud-healthcare-get-fhir-store` tool retrieves information about a FHIR store. It's +compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-get-fhir-store` returns the details of a FHIR store. + +## Example + +```yaml +tools: + get_fhir_store: + kind: cloud-healthcare-get-fhir-store + source: my-healthcare-source + description: Use this tool to get information about a FHIR store. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-get-fhir-store". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|-----------|:--------:|:------------:|---------------------------------------| +| storeID | string | true* | The FHIR store ID to get details for. | + +*If the `allowedFHIRStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-list-dicom-stores.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-list-dicom-stores.md new file mode 100644 index 0000000000..6ba7157795 --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-list-dicom-stores.md @@ -0,0 +1,37 @@ +--- +title: "cloud-healthcare-list-dicom-stores" +type: docs +weight: 1 +description: > + A "cloud-healthcare-list-dicom-stores" lists the available DICOM stores in the healthcare dataset. +aliases: +- /resources/tools/cloud-healthcare-list-dicom-stores +--- + +## About + +A `cloud-healthcare-list-dicom-stores` lists the available DICOM stores in the healthcare dataset. +It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-list-dicom-stores` returns the details of the available DICOM stores in the +dataset of the healthcare source. It takes no extra parameters. + +## Example + +```yaml +tools: + list_dicom_stores: + kind: cloud-healthcare-list-dicom-stores + source: my-healthcare-source + description: Use this tool to list DICOM stores in the healthcare dataset. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-list-dicom-stores". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-list-fhir-stores.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-list-fhir-stores.md new file mode 100644 index 0000000000..24b5e5e839 --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-list-fhir-stores.md @@ -0,0 +1,37 @@ +--- +title: "cloud-healthcare-list-fhir-stores" +type: docs +weight: 1 +description: > + A "cloud-healthcare-list-fhir-stores" lists the available FHIR stores in the healthcare dataset. +aliases: +- /resources/tools/cloud-healthcare-list-fhir-stores +--- + +## About + +A `cloud-healthcare-list-fhir-stores` lists the available FHIR stores in the healthcare dataset. +It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-list-fhir-stores` returns the details of the available FHIR stores in the +dataset of the healthcare source. It takes no extra parameters. + +## Example + +```yaml +tools: + list_fhir_stores: + kind: cloud-healthcare-list-fhir-stores + source: my-healthcare-source + description: Use this tool to list FHIR stores in the healthcare dataset. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-list-fhir-stores". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-retrieve-rendered-dicom-instance.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-retrieve-rendered-dicom-instance.md new file mode 100644 index 0000000000..faf56e46cd --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-retrieve-rendered-dicom-instance.md @@ -0,0 +1,48 @@ +--- +title: "cloud-healthcare-retrieve-rendered-dicom-instance" +type: docs +weight: 1 +description: > + A "cloud-healthcare-retrieve-rendered-dicom-instance" tool retrieves a rendered DICOM instance from a DICOM store. +aliases: +- /resources/tools/cloud-healthcare-retrieve-rendered-dicom-instance +--- + +## About + +A `cloud-healthcare-retrieve-rendered-dicom-instance` tool retrieves a rendered DICOM instance from a DICOM store. +It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-retrieve-rendered-dicom-instance` returns a base64 encoded string of the image in JPEG format. + +## Example + +```yaml +tools: + retrieve_rendered_dicom_instance: + kind: cloud-healthcare-retrieve-rendered-dicom-instance + source: my-healthcare-source + description: Use this tool to retrieve a rendered DICOM instance from the DICOM store. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|--------------------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-retrieve-rendered-dicom-instance". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|-------------------|:--------:|:------------:|--------------------------------------------------------------------------------------------------| +| StudyInstanceUID | string | true | The UID of the DICOM study. | +| SeriesInstanceUID | string | true | The UID of the DICOM series. | +| SOPInstanceUID | string | true | The UID of the SOP instance. | +| FrameNumber | integer | false | The frame number to retrieve (1-based). Only applicable to multi-frame instances. Defaults to 1. | +| storeID | string | true* | The DICOM store ID to retrieve from. | + +*If the `allowedDICOMStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-search-dicom-instances.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-search-dicom-instances.md new file mode 100644 index 0000000000..895129e5eb --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-search-dicom-instances.md @@ -0,0 +1,55 @@ +--- +title: "cloud-healthcare-search-dicom-instances" +type: docs +weight: 1 +description: > + A "cloud-healthcare-search-dicom-instances" tool searches for DICOM instances in a DICOM store. +aliases: +- /resources/tools/cloud-healthcare-search-dicom-instances +--- + +## About + +A `cloud-healthcare-search-dicom-instances` tool searches for DICOM instances in a DICOM store based on a +set of criteria. It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`search-dicom-instances` returns a list of DICOM instances that match the given criteria. + +## Example + +```yaml +tools: + search_dicom_instances: + kind: cloud-healthcare-search-dicom-instances + source: my-healthcare-source + description: Use this tool to search for DICOM instances in the DICOM store. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-search-dicom-instances". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|------------------------|:--------:|:------------:|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| StudyInstanceUID | string | false | The UID of the DICOM study. | +| PatientName | string | false | The name of the patient. | +| PatientID | string | false | The ID of the patient. | +| AccessionNumber | string | false | The accession number of the study. | +| ReferringPhysicianName | string | false | The name of the referring physician. | +| StudyDate | string | false | The date of the study in the format `YYYYMMDD`. You can also specify a date range in the format `YYYYMMDD-YYYYMMDD`. | +| SeriesInstanceUID | string | false | The UID of the DICOM series. | +| Modality | string | false | The modality of the series. | +| SOPInstanceUID | string | false | The UID of the SOP instance. | +| fuzzymatching | boolean | false | Whether to enable fuzzy matching for patient names. Fuzzy matching will perform tokenization and normalization of both the value of PatientName in the query and the stored value. It will match if any search token is a prefix of any stored token. For example, if PatientName is "John^Doe", then "jo", "Do" and "John Doe" will all match. However "ohn" will not match. | +| includefield | []string | false | List of attributeIDs to include in the output, such as DICOM tag IDs or keywords. Set to `["all"]` to return all available tags. | +| storeID | string | true* | The DICOM store ID to search in. | + +*If the `allowedDICOMStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-search-dicom-series.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-search-dicom-series.md new file mode 100644 index 0000000000..1a8e76a183 --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-search-dicom-series.md @@ -0,0 +1,54 @@ +--- +title: "cloud-healthcare-search-dicom-series" +type: docs +weight: 1 +description: > + A "cloud-healthcare-search-dicom-series" tool searches for DICOM series in a DICOM store. +aliases: +- /resources/tools/cloud-healthcare-search-dicom-series +--- + +## About + +A `cloud-healthcare-search-dicom-series` tool searches for DICOM series in a DICOM store based on a +set of criteria. It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-search-dicom-series` returns a list of DICOM series that match the given criteria. + +## Example + +```yaml +tools: + search_dicom_series: + kind: cloud-healthcare-search-dicom-series + source: my-healthcare-source + description: Use this tool to search for DICOM series in the DICOM store. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-search-dicom-series". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|------------------------|:--------:|:------------:|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| StudyInstanceUID | string | false | The UID of the DICOM study. | +| PatientName | string | false | The name of the patient. | +| PatientID | string | false | The ID of the patient. | +| AccessionNumber | string | false | The accession number of the study. | +| ReferringPhysicianName | string | false | The name of the referring physician. | +| StudyDate | string | false | The date of the study in the format `YYYYMMDD`. You can also specify a date range in the format `YYYYMMDD-YYYYMMDD`. | +| SeriesInstanceUID | string | false | The UID of the DICOM series. | +| Modality | string | false | The modality of the series. | +| fuzzymatching | boolean | false | Whether to enable fuzzy matching for patient names. Fuzzy matching will perform tokenization and normalization of both the value of PatientName in the query and the stored value. It will match if any search token is a prefix of any stored token. For example, if PatientName is "John^Doe", then "jo", "Do" and "John Doe" will all match. However "ohn" will not match. | +| includefield | []string | false | List of attributeIDs to include in the output, such as DICOM tag IDs or keywords. Set to `["all"]` to return all available tags. | +| storeID | string | true* | The DICOM store ID to search in. | + +*If the `allowedDIComStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-search-dicom-studies.md b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-search-dicom-studies.md new file mode 100644 index 0000000000..11d9036292 --- /dev/null +++ b/docs/en/resources/tools/cloudhealthcare/cloud-healthcare-search-dicom-studies.md @@ -0,0 +1,52 @@ +--- +title: "cloud-healthcare-search-dicom-studies" +type: docs +weight: 1 +description: > + A "cloud-healthcare-search-dicom-studies" tool searches for DICOM studies in a DICOM store. +aliases: +- /resources/tools/cloud-healthcare-healthcare-search-dicom-studies +--- + +## About + +A `cloud-healthcare-search-dicom-studies` tool searches for DICOM studies in a DICOM store based on a +set of criteria. It's compatible with the following sources: + +- [cloud-healthcare](../../sources/cloud-healthcare.md) + +`cloud-healthcare-search-dicom-studies` returns a list of DICOM studies that match the given criteria. + +## Example + +```yaml +tools: + search_dicom_studies: + kind: cloud-healthcare-search-dicom-studies + source: my-healthcare-source + description: Use this tool to search for DICOM studies in the DICOM store. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "cloud-healthcare-search-dicom-studies". | +| source | string | true | Name of the healthcare source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **field** | **type** | **required** | **description** | +|------------------------|:--------:|:------------:|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| StudyInstanceUID | string | false | The UID of the DICOM study. | +| PatientName | string | false | The name of the patient. | +| PatientID | string | false | The ID of the patient. | +| AccessionNumber | string | false | The accession number of the study. | +| ReferringPhysicianName | string | false | The name of the referring physician. | +| StudyDate | string | false | The date of the study in the format `YYYYMMDD`. You can also specify a date range in the format `YYYYMMDD-YYYYMMDD`. | +| fuzzymatching | boolean | false | Whether to enable fuzzy matching for patient names. Fuzzy matching will perform tokenization and normalization of both the value of PatientName in the query and the stored value. It will match if any search token is a prefix of any stored token. For example, if PatientName is "John^Doe", then "jo", "Do" and "John Doe" will all match. However "ohn" will not match. | +| includefield | []string | false | List of attributeIDs to include in the output, such as DICOM tag IDs or keywords. Set to `["all"]` to return all available tags. | +| storeID | string | true* | The DICOM store ID to search in. | + +*If the `allowedDICOMStores` in the source has length 1, then the `storeID` parameter is not needed. diff --git a/internal/prebuiltconfigs/prebuiltconfigs_test.go b/internal/prebuiltconfigs/prebuiltconfigs_test.go index bc29708514..d2247030a4 100644 --- a/internal/prebuiltconfigs/prebuiltconfigs_test.go +++ b/internal/prebuiltconfigs/prebuiltconfigs_test.go @@ -26,6 +26,7 @@ var expectedToolSources = []string{ "alloydb-postgres", "bigquery", "clickhouse", + "cloud-healthcare", "cloud-sql-mssql-admin", "cloud-sql-mssql-observability", "cloud-sql-mssql", @@ -125,6 +126,7 @@ func TestGetPrebuiltTool(t *testing.T) { mindsdb_config, _ := Get("mindsdb") sqlite_config, _ := Get("sqlite") neo4jconfig, _ := Get("neo4j") + healthcare_config, _ := Get("cloud-healthcare") if len(alloydb_admin_config) <= 0 { t.Fatalf("unexpected error: could not fetch alloydb prebuilt tools yaml") @@ -213,6 +215,9 @@ func TestGetPrebuiltTool(t *testing.T) { if len(neo4jconfig) <= 0 { t.Fatalf("unexpected error: could not fetch neo4j prebuilt tools yaml") } + if len(healthcare_config) <= 0 { + t.Fatalf("unexpected error: could not fetch healthcare prebuilt tools yaml") + } } func TestFailGetPrebuiltTool(t *testing.T) { diff --git a/internal/prebuiltconfigs/tools/cloud-healthcare.yaml b/internal/prebuiltconfigs/tools/cloud-healthcare.yaml new file mode 100644 index 0000000000..0641377ddb --- /dev/null +++ b/internal/prebuiltconfigs/tools/cloud-healthcare.yaml @@ -0,0 +1,103 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the License); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an AS IS BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +sources: + healthcare-source: + kind: cloud-healthcare + project: ${CLOUD_HEALTHCARE_PROJECT} + region: ${CLOUD_HEALTHCARE_REGION} + dataset: ${CLOUD_HEALTHCARE_DATASET} + useClientOAuth: ${CLOUD_HEALTHCARE_USE_CLIENT_OAUTH:false} + +tools: + get_dataset: + kind: cloud-healthcare-get-dataset + description: Use this tool to get the details of a healthcare dataset + source: healthcare-source + list_dicom_stores: + kind: cloud-healthcare-list-dicom-stores + description: Use this tool to list the DICOM stores in the healthcare dataset + source: healthcare-source + list_fhir_stores: + kind: cloud-healthcare-list-fhir-stores + description: Use this tool to list the FHIR stores in the healthcare dataset + source: healthcare-source + get_fhir_store: + kind: cloud-healthcare-get-fhir-store + description: Use this tool to get details about a FHIR store in the healthcare dataset + source: healthcare-source + get_fhir_store_metrics: + kind: cloud-healthcare-get-fhir-store-metrics + description: Use this tool to get metrics about a FHIR store in the healthcare dataset + source: healthcare-source + get_fhir_resource: + kind: cloud-healthcare-get-fhir-resource + description: Use this tool to get a FHIR resource from a FHIR store + source: healthcare-source + fhir_patient_search: + kind: cloud-healthcare-fhir-patient-search + description: Use this tool to search for patient resource(s) in a FHIR store based on a set of criteria + source: healthcare-source + fhir_patient_everything: + kind: cloud-healthcare-fhir-patient-everything + description: Use this tool to retrieve resources related to a given patient from a FHIR store + source: healthcare-source + fhir_fetch_page: + kind: cloud-healthcare-fhir-fetch-page + description: Use this tool to fetche a page of FHIR resources from a FHIR store + source: healthcare-source + get_dicom_store: + kind: cloud-healthcare-get-dicom-store + description: Use this tool to get details about a DICOM store in the healthcare dataset + source: healthcare-source + get_dicom_store_metrics: + kind: cloud-healthcare-get-dicom-store-metrics + description: Use this tool to get metrics about a DICOM store in the healthcare dataset + source: healthcare-source + search_dicom_studies: + kind: cloud-healthcare-search-dicom-studies + description: Use this tool to search for DICOM studies in a DICOM store + source: healthcare-source + search_dicom_series: + kind: cloud-healthcare-search-dicom-series + description: Use this tool to search for DICOM series in a DICOM store + source: healthcare-source + search_dicom_instances: + kind: cloud-healthcare-search-dicom-instances + description: Use this tool to search for DICOM instances in a DICOM store + source: healthcare-source + retrieve_rendered_dicom_instance: + kind: cloud-healthcare-retrieve-rendered-dicom-instance + description: Use this tool to retrieve a base64 encoding of a rendered DICOM instance from a DICOM store in JPEG format + source: healthcare-source + +toolsets: + cloud_healthcare_dataset_tools: + - get_dataset + - list_dicom_stores + - list_fhir_stores + cloud_healthcare_fhir_tools: + - get_fhir_store + - get_fhir_store_metrics + - get_fhir_resource + - fhir_patient_search + - fhir_patient_everything + - fhir_fetch_page + cloud_healthcare_dicom_tools: + - get_dicom_store + - get_dicom_store_metrics + - search_dicom_studies + - search_dicom_series + - search_dicom_instances + - retrieve_rendered_dicom_instance diff --git a/internal/sources/cloudhealthcare/cloud_healthcare.go b/internal/sources/cloudhealthcare/cloud_healthcare.go new file mode 100644 index 0000000000..68bafc047d --- /dev/null +++ b/internal/sources/cloudhealthcare/cloud_healthcare.go @@ -0,0 +1,263 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudhealthcare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/util" + "go.opentelemetry.io/otel/trace" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/googleapi" + "google.golang.org/api/healthcare/v1" + "google.golang.org/api/option" +) + +const SourceKind string = "cloud-healthcare" + +// validate interface +var _ sources.SourceConfig = Config{} + +type HealthcareServiceCreator func(tokenString string) (*healthcare.Service, error) + +func init() { + if !sources.Register(SourceKind, newConfig) { + panic(fmt.Sprintf("source kind %q already registered", SourceKind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (sources.SourceConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + // Healthcare configs + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Project string `yaml:"project" validate:"required"` + Region string `yaml:"region" validate:"required"` + Dataset string `yaml:"dataset" validate:"required"` + AllowedFHIRStores []string `yaml:"allowedFhirStores"` + AllowedDICOMStores []string `yaml:"allowedDicomStores"` + UseClientOAuth bool `yaml:"useClientOAuth"` +} + +func (c Config) SourceConfigKind() string { + return SourceKind +} + +func (c Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.Source, error) { + var service *healthcare.Service + var serviceCreator HealthcareServiceCreator + var tokenSource oauth2.TokenSource + + svc, tok, err := initHealthcareConnection(ctx, tracer, c.Name) + if err != nil { + return nil, fmt.Errorf("error creating service from ADC: %w", err) + } + if c.UseClientOAuth { + serviceCreator, err = newHealthcareServiceCreator(ctx, tracer, c.Name) + if err != nil { + return nil, fmt.Errorf("error constructing service creator: %w", err) + } + } else { + service = svc + tokenSource = tok + } + + dsName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s", c.Project, c.Region, c.Dataset) + if _, err = svc.Projects.Locations.Datasets.FhirStores.Get(dsName).Do(); err != nil { + if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusNotFound { + return nil, fmt.Errorf("dataset '%s' not found", dsName) + } + return nil, fmt.Errorf("failed to verify existence of dataset '%s': %w", dsName, err) + } + + allowedFHIRStores := make(map[string]struct{}) + for _, store := range c.AllowedFHIRStores { + name := fmt.Sprintf("%s/fhirStores/%s", dsName, store) + _, err := svc.Projects.Locations.Datasets.FhirStores.Get(name).Do() + if err != nil { + if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusNotFound { + return nil, fmt.Errorf("allowedFhirStore '%s' not found in dataset '%s'", store, dsName) + } + return nil, fmt.Errorf("failed to verify allowedFhirStore '%s' in datasest '%s': %w", store, dsName, err) + } + allowedFHIRStores[store] = struct{}{} + } + allowedDICOMStores := make(map[string]struct{}) + for _, store := range c.AllowedDICOMStores { + name := fmt.Sprintf("%s/dicomStores/%s", dsName, store) + _, err := svc.Projects.Locations.Datasets.DicomStores.Get(name).Do() + if err != nil { + if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusNotFound { + return nil, fmt.Errorf("allowedDicomStore '%s' not found in dataset '%s'", store, dsName) + } + return nil, fmt.Errorf("failed to verify allowedDicomFhirStore '%s' in datasest '%s': %w", store, dsName, err) + } + allowedDICOMStores[store] = struct{}{} + } + s := &Source{ + name: c.Name, + kind: SourceKind, + project: c.Project, + region: c.Region, + dataset: c.Dataset, + service: service, + serviceCreator: serviceCreator, + tokenSource: tokenSource, + allowedFHIRStores: allowedFHIRStores, + allowedDICOMStores: allowedDICOMStores, + useClientOAuth: c.UseClientOAuth, + } + return s, nil +} + +func newHealthcareServiceCreator(ctx context.Context, tracer trace.Tracer, name string) (func(string) (*healthcare.Service, error), error) { + userAgent, err := util.UserAgentFromContext(ctx) + if err != nil { + return nil, err + } + return func(tokenString string) (*healthcare.Service, error) { + return initHealthcareConnectionWithOAuthToken(ctx, tracer, name, userAgent, tokenString) + }, nil +} + +func initHealthcareConnectionWithOAuthToken(ctx context.Context, tracer trace.Tracer, name string, userAgent string, tokenString string) (*healthcare.Service, error) { + ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceKind, name) + defer span.End() + // Construct token source + token := &oauth2.Token{ + AccessToken: string(tokenString), + } + ts := oauth2.StaticTokenSource(token) + + // Initialize the Healthcare service with tokenSource + service, err := healthcare.NewService(ctx, option.WithUserAgent(userAgent), option.WithTokenSource(ts)) + if err != nil { + return nil, fmt.Errorf("failed to create Healthcare service: %w", err) + } + service.UserAgent = userAgent + return service, nil +} + +func initHealthcareConnection(ctx context.Context, tracer trace.Tracer, name string) (*healthcare.Service, oauth2.TokenSource, error) { + ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceKind, name) + defer span.End() + + cred, err := google.FindDefaultCredentials(ctx, healthcare.CloudHealthcareScope) + if err != nil { + return nil, nil, fmt.Errorf("failed to find default Google Cloud credentials with scope %q: %w", healthcare.CloudHealthcareScope, err) + } + + userAgent, err := util.UserAgentFromContext(ctx) + if err != nil { + return nil, nil, err + } + + service, err := healthcare.NewService(ctx, option.WithUserAgent(userAgent), option.WithCredentials(cred)) + if err != nil { + return nil, nil, fmt.Errorf("failed to create Healthcare service: %w", err) + } + service.UserAgent = userAgent + return service, cred.TokenSource, nil +} + +var _ sources.Source = &Source{} + +type Source struct { + name string `yaml:"name"` + kind string `yaml:"kind"` + project string + region string + dataset string + service *healthcare.Service + serviceCreator HealthcareServiceCreator + tokenSource oauth2.TokenSource + allowedFHIRStores map[string]struct{} + allowedDICOMStores map[string]struct{} + useClientOAuth bool +} + +func (s *Source) SourceKind() string { + return SourceKind +} + +func (s *Source) Project() string { + return s.project +} + +func (s *Source) Region() string { + return s.region +} + +func (s *Source) DatasetID() string { + return s.dataset +} + +func (s *Source) Service() *healthcare.Service { + return s.service +} + +func (s *Source) ServiceCreator() HealthcareServiceCreator { + return s.serviceCreator +} + +func (s *Source) TokenSource() oauth2.TokenSource { + return s.tokenSource +} + +func (s *Source) AllowedFHIRStores() map[string]struct{} { + if len(s.allowedFHIRStores) == 0 { + return nil + } + return s.allowedFHIRStores +} + +func (s *Source) AllowedDICOMStores() map[string]struct{} { + if len(s.allowedDICOMStores) == 0 { + return nil + } + return s.allowedDICOMStores +} + +func (s *Source) IsFHIRStoreAllowed(storeID string) bool { + if len(s.allowedFHIRStores) == 0 { + return true + } + _, ok := s.allowedFHIRStores[storeID] + return ok +} + +func (s *Source) IsDICOMStoreAllowed(storeID string) bool { + if len(s.allowedDICOMStores) == 0 { + return true + } + _, ok := s.allowedDICOMStores[storeID] + return ok +} + +func (s *Source) UseClientAuthorization() bool { + return s.useClientOAuth +} diff --git a/internal/sources/cloudhealthcare/cloud_healthcare_test.go b/internal/sources/cloudhealthcare/cloud_healthcare_test.go new file mode 100644 index 0000000000..688a701cba --- /dev/null +++ b/internal/sources/cloudhealthcare/cloud_healthcare_test.go @@ -0,0 +1,168 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudhealthcare_test + +import ( + "testing" + + "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/testutils" +) + +func TestParseFromYamlCloudHealthcare(t *testing.T) { + tcs := []struct { + desc string + in string + want server.SourceConfigs + }{ + { + desc: "basic example", + in: ` + sources: + my-instance: + kind: cloud-healthcare + project: my-project + region: us-central1 + dataset: my-dataset + `, + want: server.SourceConfigs{ + "my-instance": cloudhealthcare.Config{ + Name: "my-instance", + Kind: cloudhealthcare.SourceKind, + Project: "my-project", + Region: "us-central1", + Dataset: "my-dataset", + UseClientOAuth: false, + }, + }, + }, + { + desc: "use client auth example", + in: ` + sources: + my-instance: + kind: cloud-healthcare + project: my-project + region: us + dataset: my-dataset + useClientOAuth: true + `, + want: server.SourceConfigs{ + "my-instance": cloudhealthcare.Config{ + Name: "my-instance", + Kind: cloudhealthcare.SourceKind, + Project: "my-project", + Region: "us", + Dataset: "my-dataset", + UseClientOAuth: true, + }, + }, + }, + { + desc: "with allowed stores example", + in: ` + sources: + my-instance: + kind: cloud-healthcare + project: my-project + region: us + dataset: my-dataset + allowedFhirStores: + - my-fhir-store + allowedDicomStores: + - my-dicom-store1 + - my-dicom-store2 + `, + want: server.SourceConfigs{ + "my-instance": cloudhealthcare.Config{ + Name: "my-instance", + Kind: cloudhealthcare.SourceKind, + Project: "my-project", + Region: "us", + Dataset: "my-dataset", + AllowedFHIRStores: []string{"my-fhir-store"}, + AllowedDICOMStores: []string{"my-dicom-store1", "my-dicom-store2"}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Sources server.SourceConfigs `yaml:"sources"` + }{} + // Parse contents + err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if !cmp.Equal(tc.want, got.Sources) { + t.Fatalf("incorrect parse: want %v, got %v", tc.want, got.Sources) + } + }) + } +} + +func TestFailParseFromYaml(t *testing.T) { + tcs := []struct { + desc string + in string + err string + }{ + { + desc: "extra field", + in: ` + sources: + my-instance: + kind: cloud-healthcare + project: my-project + region: us-central1 + dataset: my-dataset + foo: bar + `, + err: "unable to parse source \"my-instance\" as \"cloud-healthcare\": [2:1] unknown field \"foo\"\n 1 | dataset: my-dataset\n> 2 | foo: bar\n ^\n 3 | kind: cloud-healthcare\n 4 | project: my-project\n 5 | region: us-central1", + }, + { + desc: "missing required field", + in: ` + sources: + my-instance: + kind: cloud-healthcare + project: my-project + region: us-central1 + `, + err: `unable to parse source "my-instance" as "cloud-healthcare": Key: 'Config.Dataset' Error:Field validation for 'Dataset' failed on the 'required' tag`, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Sources server.SourceConfigs `yaml:"sources"` + }{} + // Parse contents + err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got) + if err == nil { + t.Fatalf("expect parsing to fail") + } + errStr := err.Error() + if errStr != tc.err { + t.Fatalf("unexpected error: got %q, want %q", errStr, tc.err) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarefhirfetchpage/cloudhealthcarefhirfetchpage.go b/internal/tools/cloudhealthcare/cloudhealthcarefhirfetchpage/cloudhealthcarefhirfetchpage.go new file mode 100644 index 0000000000..74ce19e357 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarefhirfetchpage/cloudhealthcarefhirfetchpage.go @@ -0,0 +1,206 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fhirfetchpage + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "google.golang.org/api/healthcare/v1" + + "net/http" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +const kind string = "cloud-healthcare-fhir-fetch-page" +const ( + pageURLKey = "pageURL" +) + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedFHIRStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + urlParameter := tools.NewStringParameter(pageURLKey, "The full URL of the FHIR page to fetch. This would be the value of `Bundle.entry.link.url` field within the response returned from FHIR search or FHIR patient everything operations.") + parameters := tools.Parameters{urlParameter} + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedFHIRStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + url, ok := params.AsMap()[pageURLKey].(string) + if !ok { + return nil, fmt.Errorf("invalid or missing '%s' parameter; expected a string", pageURLKey) + } + + var httpClient *http.Client + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: tokenStr}) + httpClient = oauth2.NewClient(ctx, ts) + } else { + // The t.Service object holds a client with the default credentials. + // However, the client is not exported, so we have to create a new one. + var err error + httpClient, err = google.DefaultClient(ctx, healthcare.CloudHealthcareScope) + if err != nil { + return nil, fmt.Errorf("failed to create default http client: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create http request: %w", err) + } + req.Header.Set("Accept", "application/fhir+json;charset=utf-8") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get fhir page from %q: %w", url, err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("could not read response: %w", err) + } + if resp.StatusCode > 299 { + return nil, fmt.Errorf("read: status %d %s: %s", resp.StatusCode, resp.Status, respBytes) + } + var jsonMap map[string]interface{} + if err := json.Unmarshal([]byte(string(respBytes)), &jsonMap); err != nil { + return nil, fmt.Errorf("could not unmarshal response as json: %w", err) + } + return jsonMap, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarefhirfetchpage/cloudhealthcarefhirfetchpage_test.go b/internal/tools/cloudhealthcare/cloudhealthcarefhirfetchpage/cloudhealthcarefhirfetchpage_test.go new file mode 100644 index 0000000000..de85dd8674 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarefhirfetchpage/cloudhealthcarefhirfetchpage_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fhirfetchpage_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + fhirfetchpage "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarefhirfetchpage" +) + +func TestParseFromYamlHealthcareFHIRFetchPage(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-fhir-fetch-page + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": fhirfetchpage.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-fhir-fetch-page", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarefhirpatienteverything/cloudhealthcarefhirpatienteverything.go b/internal/tools/cloudhealthcare/cloudhealthcarefhirpatienteverything/cloudhealthcarefhirpatienteverything.go new file mode 100644 index 0000000000..bfbfb5fca3 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarefhirpatienteverything/cloudhealthcarefhirpatienteverything.go @@ -0,0 +1,229 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fhirpatienteverything + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/googleapi" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-fhir-patient-everything" +const ( + patientIDKey = "patientID" + typeFilterKey = "resourceTypesFilter" + sinceFilterKey = "sinceFilter" +) + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedFHIRStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + idParameter := tools.NewStringParameter(patientIDKey, "The ID of the patient FHIR resource for which the information is required") + typeFilterParameter := tools.NewArrayParameterWithDefault(typeFilterKey, []any{}, "List of FHIR resource types. If provided, only resources of the specified resource type(s) are returned.", tools.NewStringParameter("resourceType", "A FHIR resource type")) + sinceFilterParameter := tools.NewStringParameterWithDefault(sinceFilterKey, "", "If provided, only resources updated after this time are returned. The time uses the format YYYY-MM-DDThh:mm:ss.sss+zz:zz. The time must be specified to the second and include a time zone. For example, 2015-02-07T13:28:17.239+02:00 or 2017-01-01T00:00:00Z") + parameters := tools.Parameters{idParameter, typeFilterParameter, sinceFilterParameter} + if len(s.AllowedFHIRStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The FHIR store ID to retrieve the resource from.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedFHIRStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + patientID, ok := params.AsMap()[patientIDKey].(string) + if !ok { + return nil, fmt.Errorf("invalid or missing '%s' parameter; expected a string", patientIDKey) + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + name := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/fhirStores/%s/fhir/Patient/%s", t.Project, t.Region, t.Dataset, storeID, patientID) + var opts []googleapi.CallOption + if val, ok := params.AsMap()[typeFilterKey]; ok { + types, ok := val.([]any) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string array", typeFilterKey) + } + typeFilterSlice, err := tools.ConvertAnySliceToTyped(types, "string") + if err != nil { + return nil, fmt.Errorf("can't convert '%s' to array of strings: %s", typeFilterKey, err) + } + if len(typeFilterSlice.([]string)) != 0 { + opts = append(opts, googleapi.QueryParameter("_type", strings.Join(typeFilterSlice.([]string), ","))) + } + } + if since, ok := params.AsMap()[sinceFilterKey]; ok { + sinceStr, ok := since.(string) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string", sinceFilterKey) + } + if sinceStr != "" { + opts = append(opts, googleapi.QueryParameter("_since", sinceStr)) + } + } + + resp, err := svc.Projects.Locations.Datasets.FhirStores.Fhir.PatientEverything(name).Do(opts...) + if err != nil { + return nil, fmt.Errorf("failed to call patient everything for %q: %w", name, err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("could not read response: %w", err) + } + if resp.StatusCode > 299 { + return nil, fmt.Errorf("patient-everything: status %d %s: %s", resp.StatusCode, resp.Status, respBytes) + } + var jsonMap map[string]interface{} + if err := json.Unmarshal([]byte(string(respBytes)), &jsonMap); err != nil { + return nil, fmt.Errorf("could not unmarshal response as json: %w", err) + } + return jsonMap, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarefhirpatienteverything/cloudhealthcarefhirpatienteverything_test.go b/internal/tools/cloudhealthcare/cloudhealthcarefhirpatienteverything/cloudhealthcarefhirpatienteverything_test.go new file mode 100644 index 0000000000..703fd0c2d3 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarefhirpatienteverything/cloudhealthcarefhirpatienteverything_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fhirpatienteverything_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + fhirpatienteverything "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarefhirpatienteverything" +) + +func TestParseFromYamlHealthcareFHIRPatientEverything(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-fhir-patient-everything + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": fhirpatienteverything.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-fhir-patient-everything", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarefhirpatientsearch/cloudhealthcarefhirpatientsearch.go b/internal/tools/cloudhealthcare/cloudhealthcarefhirpatientsearch/cloudhealthcarefhirpatientsearch.go new file mode 100644 index 0000000000..8ef67ed96c --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarefhirpatientsearch/cloudhealthcarefhirpatientsearch.go @@ -0,0 +1,302 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fhirpatientsearch + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/googleapi" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-fhir-patient-search" +const ( + activeKey = "active" + cityKey = "city" + countryKey = "country" + postalCodeKey = "postalcode" + stateKey = "state" + addressSubstringKey = "addressSubstring" + birthDateRangeKey = "birthDateRange" + deathDateRangeKey = "deathDateRange" + deceasedKey = "deceased" + emailKey = "email" + genderKey = "gender" + addressUseKey = "addressUse" + nameKey = "name" + givenNameKey = "givenName" + familyNameKey = "familyName" + phoneKey = "phone" + languageKey = "language" + identifierKey = "identifier" + summaryKey = "summary" +) + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedFHIRStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{ + tools.NewStringParameterWithDefault(activeKey, "", "Whether the patient record is active. Use true or false"), + tools.NewStringParameterWithDefault(cityKey, "", "The city of the patient's address"), + tools.NewStringParameterWithDefault(countryKey, "", "The country of the patient's address"), + tools.NewStringParameterWithDefault(postalCodeKey, "", "The postal code of the patient's address"), + tools.NewStringParameterWithDefault(stateKey, "", "The state of the patient's address"), + tools.NewStringParameterWithDefault(addressSubstringKey, "", "A substring to search for in any address field"), + tools.NewStringParameterWithDefault(birthDateRangeKey, "", "A date range for the patient's birthdate in the format YYYY-MM-DD/YYYY-MM-DD. Omit the first or second date to indicate open-ended ranges (e.g. '/2000-01-01' or '1950-01-01/')"), + tools.NewStringParameterWithDefault(deathDateRangeKey, "", "A date range for the patient's death date in the format YYYY-MM-DD/YYYY-MM-DD. Omit the first or second date to indicate open-ended ranges (e.g. '/2000-01-01' or '1950-01-01/')"), + tools.NewStringParameterWithDefault(deceasedKey, "", "Whether the patient is deceased. Use true or false"), + tools.NewStringParameterWithDefault(emailKey, "", "The patient's email address"), + tools.NewStringParameterWithDefault(genderKey, "", "The patient's gender. Must be one of 'male', 'female', 'other', or 'unknown'"), + tools.NewStringParameterWithDefault(addressUseKey, "", "The use of the patient's address. Must be one of 'home', 'work', 'temp', 'old', or 'billing'"), + tools.NewStringParameterWithDefault(nameKey, "", "The patient's name. Can be a family name, given name, or both"), + tools.NewStringParameterWithDefault(givenNameKey, "", "A portion of the given name of the patient"), + tools.NewStringParameterWithDefault(familyNameKey, "", "A portion of the family name of the patient"), + tools.NewStringParameterWithDefault(phoneKey, "", "The patient's phone number"), + tools.NewStringParameterWithDefault(languageKey, "", "The patient's preferred language. Must be a valid BCP-47 code (e.g. 'en-US', 'es')"), + tools.NewStringParameterWithDefault(identifierKey, "", "An identifier for the patient"), + tools.NewBooleanParameterWithDefault(summaryKey, true, "Requests the server to return a subset of the resource. Return a limited subset of elements from the resource. Enabled by default to reduce response size. Use get-fhir-resource tool to get full resource details (preferred) or set to false to disable."), + } + + if len(s.AllowedFHIRStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The FHIR store ID to retrieve the resource from.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedFHIRStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + var summary bool + var opts []googleapi.CallOption + for k, v := range params.AsMap() { + if k == common.StoreKey { + continue + } + if k == summaryKey { + var ok bool + summary, ok = v.(bool) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a boolean", summaryKey) + } + continue + } + + val, ok := v.(string) + if !ok { + return nil, fmt.Errorf("invalid parameter '%s'; expected a string", k) + } + if val == "" { + continue + } + switch k { + case activeKey, deceasedKey, emailKey, genderKey, phoneKey, languageKey, identifierKey: + opts = append(opts, googleapi.QueryParameter(k, val)) + case cityKey, countryKey, postalCodeKey, stateKey: + opts = append(opts, googleapi.QueryParameter("address-"+k, val)) + case addressSubstringKey: + opts = append(opts, googleapi.QueryParameter("address", val)) + case birthDateRangeKey, deathDateRangeKey: + key := "birthdate" + if k == deathDateRangeKey { + key = "death-date" + } + parts := strings.Split(val, "/") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid '%s' format; expected YYYY-MM-DD/YYYY-MM-DD", k) + } + var values []string + if parts[0] != "" { + values = append(values, "ge"+parts[0]) + } + if parts[1] != "" { + values = append(values, "le"+parts[1]) + } + if len(values) != 0 { + opts = append(opts, googleapi.QueryParameter(key, values...)) + } + case addressUseKey: + opts = append(opts, googleapi.QueryParameter("address-use", val)) + case nameKey: + parts := strings.Split(val, " ") + for _, part := range parts { + opts = append(opts, googleapi.QueryParameter("name", part)) + } + case givenNameKey: + opts = append(opts, googleapi.QueryParameter("given", val)) + case familyNameKey: + opts = append(opts, googleapi.QueryParameter("family", val)) + default: + return nil, fmt.Errorf("unexpected parameter key %q", k) + } + } + if summary { + opts = append(opts, googleapi.QueryParameter("_summary", "text")) + } + + name := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/fhirStores/%s", t.Project, t.Region, t.Dataset, storeID) + resp, err := svc.Projects.Locations.Datasets.FhirStores.Fhir.SearchType(name, "Patient", &healthcare.SearchResourcesRequest{ResourceType: "Patient"}).Do(opts...) + if err != nil { + return nil, fmt.Errorf("failed to search patient resources: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("could not read response: %w", err) + } + if resp.StatusCode > 299 { + return nil, fmt.Errorf("search: status %d %s: %s", resp.StatusCode, resp.Status, respBytes) + } + var jsonMap map[string]interface{} + if err := json.Unmarshal([]byte(string(respBytes)), &jsonMap); err != nil { + return nil, fmt.Errorf("could not unmarshal response as json: %w", err) + } + return jsonMap, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarefhirpatientsearch/cloudhealthcarefhirpatientsearch_test.go b/internal/tools/cloudhealthcare/cloudhealthcarefhirpatientsearch/cloudhealthcarefhirpatientsearch_test.go new file mode 100644 index 0000000000..072fbc02d4 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarefhirpatientsearch/cloudhealthcarefhirpatientsearch_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fhirpatientsearch_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + fhirpatientsearch "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarefhirpatientsearch" +) + +func TestParseFromYamlHealthcareFHIRPatientSearch(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-fhir-patient-search + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": fhirpatientsearch.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-fhir-patient-search", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetdataset/cloudhealthcaregetdataset.go b/internal/tools/cloudhealthcare/cloudhealthcaregetdataset/cloudhealthcaregetdataset.go new file mode 100644 index 0000000000..f8ca992ab0 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetdataset/cloudhealthcaregetdataset.go @@ -0,0 +1,166 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gethealthcaredataset + +import ( + "context" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-get-dataset" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{} + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + svc := t.Service + var err error + + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + datasetName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s", t.Project, t.Region, t.Dataset) + dataset, err := svc.Projects.Locations.Datasets.Get(datasetName).Do() + if err != nil { + return nil, fmt.Errorf("failed to get dataset %q: %w", datasetName, err) + } + return dataset, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetdataset/cloudhealthcaregetdataset_test.go b/internal/tools/cloudhealthcare/cloudhealthcaregetdataset/cloudhealthcaregetdataset_test.go new file mode 100644 index 0000000000..3e56f2e9e4 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetdataset/cloudhealthcaregetdataset_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gethealthcaredataset_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + getdataset "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetdataset" +) + +func TestParseFromYamlGetHealthcareDataset(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-get-dataset + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": getdataset.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-get-dataset", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstore/cloudhealthcaregetdicomstore.go b/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstore/cloudhealthcaregetdicomstore.go new file mode 100644 index 0000000000..34092759a3 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstore/cloudhealthcaregetdicomstore.go @@ -0,0 +1,176 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getdicomstore + +import ( + "context" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-get-dicom-store" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedDICOMStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{} + if len(s.AllowedDICOMStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The DICOM store ID to get details for.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedDICOMStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + storeName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/dicomStores/%s", t.Project, t.Region, t.Dataset, storeID) + store, err := svc.Projects.Locations.Datasets.DicomStores.Get(storeName).Do() + if err != nil { + return nil, fmt.Errorf("failed to get DICOM store %q: %w", storeName, err) + } + return store, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstore/cloudhealthcaregetdicomstore_test.go b/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstore/cloudhealthcaregetdicomstore_test.go new file mode 100644 index 0000000000..af87f20b9b --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstore/cloudhealthcaregetdicomstore_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getdicomstore_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + getdicomstore "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstore" +) + +func TestParseFromYamlHealthcareGetDICOMStore(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-get-dicom-store + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": getdicomstore.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-get-dicom-store", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstoremetrics/cloudhealthcaregetdicomstoremetrics.go b/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstoremetrics/cloudhealthcaregetdicomstoremetrics.go new file mode 100644 index 0000000000..e21870d1cd --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstoremetrics/cloudhealthcaregetdicomstoremetrics.go @@ -0,0 +1,176 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getdicomstoremetrics + +import ( + "context" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-get-dicom-store-metrics" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedDICOMStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{} + if len(s.AllowedDICOMStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The DICOM store ID to get metrics for.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedDICOMStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + storeName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/dicomStores/%s", t.Project, t.Region, t.Dataset, storeID) + store, err := svc.Projects.Locations.Datasets.DicomStores.GetDICOMStoreMetrics(storeName).Do() + if err != nil { + return nil, fmt.Errorf("failed to get metrics for DICOM store %q: %w", storeName, err) + } + return store, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstoremetrics/cloudhealthcaregetdicomstoremetrics_test.go b/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstoremetrics/cloudhealthcaregetdicomstoremetrics_test.go new file mode 100644 index 0000000000..43523f7b85 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstoremetrics/cloudhealthcaregetdicomstoremetrics_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getdicomstoremetrics_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + getdicomstoremetrics "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetdicomstoremetrics" +) + +func TestParseFromYamlHealthcareGetDICOMStoreMetrics(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-get-dicom-store-metrics + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": getdicomstoremetrics.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-get-dicom-store-metrics", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetfhirresource/cloudhealthcaregetfhirresource.go b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirresource/cloudhealthcaregetfhirresource.go new file mode 100644 index 0000000000..12e6a195f0 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirresource/cloudhealthcaregetfhirresource.go @@ -0,0 +1,208 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getfhirresource + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-get-fhir-resource" +const ( + typeKey = "resourceType" + idKey = "resourceID" +) + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedFHIRStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + typeParameter := tools.NewStringParameter(typeKey, "The FHIR resource type to retrieve (e.g., Patient, Observation).") + idParameter := tools.NewStringParameter(idKey, "The ID of the FHIR resource to retrieve.") + parameters := tools.Parameters{typeParameter, idParameter} + if len(s.AllowedFHIRStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The FHIR store ID to retrieve the resource from.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedFHIRStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + resType, ok := params.AsMap()[typeKey].(string) + if !ok { + return nil, fmt.Errorf("invalid or missing '%s' parameter; expected a string", typeKey) + } + + resID, ok := params.AsMap()[idKey].(string) + if !ok { + return nil, fmt.Errorf("invalid or missing '%s' parameter; expected a string", idKey) + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + name := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/fhirStores/%s/fhir/%s/%s", t.Project, t.Region, t.Dataset, storeID, resType, resID) + call := svc.Projects.Locations.Datasets.FhirStores.Fhir.Read(name) + call.Header().Set("Content-Type", "application/fhir+json;charset=utf-8") + resp, err := call.Do() + if err != nil { + return nil, fmt.Errorf("failed to get fhir resource %q: %w", name, err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("could not read response: %w", err) + } + if resp.StatusCode > 299 { + return nil, fmt.Errorf("read: status %d %s: %s", resp.StatusCode, resp.Status, respBytes) + } + var jsonMap map[string]interface{} + if err := json.Unmarshal([]byte(string(respBytes)), &jsonMap); err != nil { + return nil, fmt.Errorf("could not unmarshal response as json: %w", err) + } + return jsonMap, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetfhirresource/cloudhealthcaregetfhirresource_test.go b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirresource/cloudhealthcaregetfhirresource_test.go new file mode 100644 index 0000000000..7c2ba5277f --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirresource/cloudhealthcaregetfhirresource_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getfhirresource_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + getfhirresource "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetfhirresource" +) + +func TestParseFromYamlHealthcareGetFHIRResource(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-get-fhir-resource + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": getfhirresource.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-get-fhir-resource", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstore/cloudhealthcaregetfhirstore.go b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstore/cloudhealthcaregetfhirstore.go new file mode 100644 index 0000000000..ebd5d8646c --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstore/cloudhealthcaregetfhirstore.go @@ -0,0 +1,176 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getfhirstore + +import ( + "context" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-get-fhir-store" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedFHIRStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{} + if len(s.AllowedFHIRStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The FHIR store ID to get details for.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedFHIRStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + storeName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/fhirStores/%s", t.Project, t.Region, t.Dataset, storeID) + store, err := svc.Projects.Locations.Datasets.FhirStores.Get(storeName).Do() + if err != nil { + return nil, fmt.Errorf("failed to get FHIR store %q: %w", storeName, err) + } + return store, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstore/cloudhealthcaregetfhirstore_test.go b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstore/cloudhealthcaregetfhirstore_test.go new file mode 100644 index 0000000000..bcf3d54e51 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstore/cloudhealthcaregetfhirstore_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getfhirstore_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + getfhirstore "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstore" +) + +func TestParseFromYamlHealthcareGetFHIRStore(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-get-fhir-store + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": getfhirstore.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-get-fhir-store", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstoremetrics/cloudhealthcaregetfhirstoremetrics.go b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstoremetrics/cloudhealthcaregetfhirstoremetrics.go new file mode 100644 index 0000000000..d8ad394001 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstoremetrics/cloudhealthcaregetfhirstoremetrics.go @@ -0,0 +1,176 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getfhirstoremetrics + +import ( + "context" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-get-fhir-store-metrics" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedFHIRStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{} + if len(s.AllowedFHIRStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The FHIR store ID to get metrics for.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedFHIRStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + storeName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/fhirStores/%s", t.Project, t.Region, t.Dataset, storeID) + store, err := svc.Projects.Locations.Datasets.FhirStores.GetFHIRStoreMetrics(storeName).Do() + if err != nil { + return nil, fmt.Errorf("failed to get metrics for FHIR store %q: %w", storeName, err) + } + return store, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstoremetrics/cloudhealthcaregetfhirstoremetrics_test.go b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstoremetrics/cloudhealthcaregetfhirstoremetrics_test.go new file mode 100644 index 0000000000..bd112bb00d --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstoremetrics/cloudhealthcaregetfhirstoremetrics_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package getfhirstoremetrics_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + getfhirstoremetrics "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaregetfhirstoremetrics" +) + +func TestParseFromYamlHealthcareGetFHIRStoreMetrics(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-get-fhir-store-metrics + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": getfhirstoremetrics.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-get-fhir-store-metrics", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarelistdicomstores/cloudhealthcarelistdicomstores.go b/internal/tools/cloudhealthcare/cloudhealthcarelistdicomstores/cloudhealthcarelistdicomstores.go new file mode 100644 index 0000000000..4048e40b63 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarelistdicomstores/cloudhealthcarelistdicomstores.go @@ -0,0 +1,184 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package listdicomstores + +import ( + "context" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-list-dicom-stores" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedDICOMStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{} + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedDICOMStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + svc := t.Service + var err error + + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + datasetName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s", t.Project, t.Region, t.Dataset) + stores, err := svc.Projects.Locations.Datasets.DicomStores.List(datasetName).Do() + if err != nil { + return nil, fmt.Errorf("failed to get dataset %q: %w", datasetName, err) + } + var filtered []*healthcare.DicomStore + for _, store := range stores.DicomStores { + if len(t.AllowedStores) == 0 { + filtered = append(filtered, store) + continue + } + if len(store.Name) == 0 { + continue + } + parts := strings.Split(store.Name, "/") + if _, ok := t.AllowedStores[parts[len(parts)-1]]; ok { + filtered = append(filtered, store) + } + } + return filtered, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarelistdicomstores/cloudhealthcarelistdicomstores_test.go b/internal/tools/cloudhealthcare/cloudhealthcarelistdicomstores/cloudhealthcarelistdicomstores_test.go new file mode 100644 index 0000000000..f9e874b2d9 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarelistdicomstores/cloudhealthcarelistdicomstores_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package listdicomstores_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + listdicomstores "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarelistdicomstores" +) + +func TestParseFromYamlHealthcareListDICOMStores(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-list-dicom-stores + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": listdicomstores.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-list-dicom-stores", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarelistfhirstores/cloudhealthcarelistfhirstores.go b/internal/tools/cloudhealthcare/cloudhealthcarelistfhirstores/cloudhealthcarelistfhirstores.go new file mode 100644 index 0000000000..ff7955cd6e --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarelistfhirstores/cloudhealthcarelistfhirstores.go @@ -0,0 +1,184 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package listfhirstores + +import ( + "context" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-list-fhir-stores" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedFHIRStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{} + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedFHIRStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + svc := t.Service + var err error + + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + datasetName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s", t.Project, t.Region, t.Dataset) + stores, err := svc.Projects.Locations.Datasets.FhirStores.List(datasetName).Do() + if err != nil { + return nil, fmt.Errorf("failed to get dataset %q: %w", datasetName, err) + } + var filtered []*healthcare.FhirStore + for _, store := range stores.FhirStores { + if len(t.AllowedStores) == 0 { + filtered = append(filtered, store) + continue + } + if len(store.Name) == 0 { + continue + } + parts := strings.Split(store.Name, "/") + if _, ok := t.AllowedStores[parts[len(parts)-1]]; ok { + filtered = append(filtered, store) + } + } + return filtered, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcarelistfhirstores/cloudhealthcarelistfhirstores_test.go b/internal/tools/cloudhealthcare/cloudhealthcarelistfhirstores/cloudhealthcarelistfhirstores_test.go new file mode 100644 index 0000000000..dc7d8d891e --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcarelistfhirstores/cloudhealthcarelistfhirstores_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package listfhirstores_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + listfhirstores "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcarelistfhirstores" +) + +func TestParseFromYamlHealthcareListFHIRStores(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-list-fhir-stores + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": listfhirstores.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-list-fhir-stores", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcareretrieverendereddicominstance/cloudhealthcareretrieverendereddicominstance.go b/internal/tools/cloudhealthcare/cloudhealthcareretrieverendereddicominstance/cloudhealthcareretrieverendereddicominstance.go new file mode 100644 index 0000000000..2861410a1d --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcareretrieverendereddicominstance/cloudhealthcareretrieverendereddicominstance.go @@ -0,0 +1,218 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package retrieverendereddicominstance + +import ( + "context" + "encoding/base64" + "fmt" + "io" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-retrieve-rendered-dicom-instance" +const ( + studyInstanceUIDKey = "StudyInstanceUID" + seriesInstanceUIDKey = "SeriesInstanceUID" + sopInstanceUIDKey = "SOPInstanceUID" + frameNumberKey = "FrameNumber" +) + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedDICOMStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{ + tools.NewStringParameter(studyInstanceUIDKey, "The UID of the DICOM study"), + tools.NewStringParameter(seriesInstanceUIDKey, "The UID of the DICOM series"), + tools.NewStringParameter(sopInstanceUIDKey, "The UID of the SOP instance."), + tools.NewIntParameterWithDefault(frameNumberKey, 1, "The frame number to retrieve (1-based). Only applicable to multi-frame instances."), + } + if len(s.AllowedDICOMStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The DICOM store ID to get details for.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedDICOMStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + study, ok := params.AsMap()[studyInstanceUIDKey].(string) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string", studyInstanceUIDKey) + } + series, ok := params.AsMap()[seriesInstanceUIDKey].(string) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string", seriesInstanceUIDKey) + } + sop, ok := params.AsMap()[sopInstanceUIDKey].(string) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string", sopInstanceUIDKey) + } + frame, ok := params.AsMap()[frameNumberKey].(int) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected an integer", frameNumberKey) + } + name := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/dicomStores/%s", t.Project, t.Region, t.Dataset, storeID) + dicomWebPath := fmt.Sprintf("studies/%s/series/%s/instances/%s/frames/%d/rendered", study, series, sop, frame) + call := svc.Projects.Locations.Datasets.DicomStores.Studies.Series.Instances.Frames.RetrieveRendered(name, dicomWebPath) + call.Header().Set("Accept", "image/jpeg") + resp, err := call.Do() + if err != nil { + return nil, fmt.Errorf("unable to retrieve dicom instance rendered image: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("could not read response: %w", err) + } + if resp.StatusCode > 299 { + return nil, fmt.Errorf("RetrieveRendered: status %d %s: %s", resp.StatusCode, resp.Status, respBytes) + } + base64String := base64.StdEncoding.EncodeToString(respBytes) + return base64String, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcareretrieverendereddicominstance/cloudhealthcareretrieverendereddicominstance_test.go b/internal/tools/cloudhealthcare/cloudhealthcareretrieverendereddicominstance/cloudhealthcareretrieverendereddicominstance_test.go new file mode 100644 index 0000000000..1522c3d8c3 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcareretrieverendereddicominstance/cloudhealthcareretrieverendereddicominstance_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package retrieverendereddicominstance_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + retrieverendereddicominstance "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcareretrieverendereddicominstance" +) + +func TestParseFromYamlHealthcareRetrieveRenderedDICOMInstance(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-retrieve-rendered-dicom-instance + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": retrieverendereddicominstance.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-retrieve-rendered-dicom-instance", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaresearchdicominstances/cloudhealthcaresearchdicominstances.go b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicominstances/cloudhealthcaresearchdicominstances.go new file mode 100644 index 0000000000..f8114c6c80 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicominstances/cloudhealthcaresearchdicominstances.go @@ -0,0 +1,248 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package searchdicominstances + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/googleapi" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-search-dicom-instances" +const ( + studyInstanceUIDKey = "StudyInstanceUID" + patientNameKey = "PatientName" + patientIDKey = "PatientID" + accessionNumberKey = "AccessionNumber" + referringPhysicianNameKey = "ReferringPhysicianName" + studyDateKey = "StudyDate" + seriesInstanceUIDKey = "SeriesInstanceUID" + modalityKey = "Modality" + sopInstanceUIDKey = "SOPInstanceUID" +) + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedDICOMStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{ + tools.NewStringParameterWithDefault(studyInstanceUIDKey, "", "The UID of the DICOM study"), + tools.NewStringParameterWithDefault(patientNameKey, "", "The name of the patient"), + tools.NewStringParameterWithDefault(patientIDKey, "", "The ID of the patient"), + tools.NewStringParameterWithDefault(accessionNumberKey, "", "The accession number of the series"), + tools.NewStringParameterWithDefault(referringPhysicianNameKey, "", "The name of the referring physician"), + tools.NewStringParameterWithDefault(studyDateKey, "", "The date of the study in the format `YYYYMMDD`. You can also specify a date range in the format `YYYYMMDD-YYYYMMDD`"), + tools.NewStringParameterWithDefault(seriesInstanceUIDKey, "", "The UID of the DICOM series"), + tools.NewStringParameterWithDefault(modalityKey, "", "The modality of the series"), + tools.NewStringParameterWithDefault(sopInstanceUIDKey, "", "The UID of the SOP instance."), + tools.NewBooleanParameterWithDefault(common.EnablePatientNameFuzzyMatchingKey, false, `Whether to enable fuzzy matching for patient names. Fuzzy matching will perform tokenization and normalization of both the value of PatientName in the query and the stored value. It will match if any search token is a prefix of any stored token. For example, if PatientName is "John^Doe", then "jo", "Do" and "John Doe" will all match. However "ohn" will not match`), + tools.NewArrayParameterWithDefault(common.IncludeAttributesKey, []any{}, "List of attributeIDs, such as DICOM tag IDs or keywords. Set to [\"all\"] to return all available tags.", tools.NewStringParameter("attributeID", "The attributeID to include. Set to 'all' to return all available tags")), + } + if len(s.AllowedDICOMStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The DICOM store ID to get details for.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedDICOMStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + opts, err := common.ParseDICOMSearchParameters(params, []string{sopInstanceUIDKey, patientNameKey, patientIDKey, accessionNumberKey, referringPhysicianNameKey, studyDateKey, modalityKey}) + if err != nil { + return nil, err + } + paramsMap := params.AsMap() + dicomWebPath := "instances" + if studyInstanceUID, ok := paramsMap[studyInstanceUIDKey]; ok { + id, ok := studyInstanceUID.(string) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string", studyInstanceUIDKey) + } + if id != "" { + dicomWebPath = fmt.Sprintf("studies/%s/instances", id) + } + } + if seriesInstanceUID, ok := paramsMap[seriesInstanceUIDKey]; ok { + id, ok := seriesInstanceUID.(string) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string", seriesInstanceUIDKey) + } + if id != "" { + if dicomWebPath != "instances" { + dicomWebPath = fmt.Sprintf("%s/series/%s/instances", strings.TrimSuffix(dicomWebPath, "/instances"), id) + } else { + opts = append(opts, googleapi.QueryParameter(seriesInstanceUIDKey, id)) + } + } + } + + name := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/dicomStores/%s", t.Project, t.Region, t.Dataset, storeID) + resp, err := svc.Projects.Locations.Datasets.DicomStores.SearchForInstances(name, dicomWebPath).Do(opts...) + if err != nil { + return nil, fmt.Errorf("failed to search dicom instances: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("could not read response: %w", err) + } + if resp.StatusCode > 299 { + return nil, fmt.Errorf("search: status %d %s: %s", resp.StatusCode, resp.Status, respBytes) + } + if len(respBytes) == 0 { + return []interface{}{}, nil + } + var result []interface{} + if err := json.Unmarshal([]byte(string(respBytes)), &result); err != nil { + return nil, fmt.Errorf("could not unmarshal response as list: %w", err) + } + return result, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaresearchdicominstances/cloudhealthcaresearchdicominstances_test.go b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicominstances/cloudhealthcaresearchdicominstances_test.go new file mode 100644 index 0000000000..e28c6c1dd2 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicominstances/cloudhealthcaresearchdicominstances_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package searchdicominstances_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + searchdicominstances "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicominstances" +) + +func TestParseFromYamlHealthcareSearchDICOMInstances(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-search-dicom-instances + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": searchdicominstances.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-search-dicom-instances", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomseries/cloudhealthcaresearchdicomseries.go b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomseries/cloudhealthcaresearchdicomseries.go new file mode 100644 index 0000000000..fb55446471 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomseries/cloudhealthcaresearchdicomseries.go @@ -0,0 +1,231 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package searchdicomseries + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-search-dicom-series" +const ( + studyInstanceUIDKey = "StudyInstanceUID" + patientNameKey = "PatientName" + patientIDKey = "PatientID" + accessionNumberKey = "AccessionNumber" + referringPhysicianNameKey = "ReferringPhysicianName" + studyDateKey = "StudyDate" + seriesInstanceUIDKey = "SeriesInstanceUID" + modalityKey = "Modality" +) + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedDICOMStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{ + tools.NewStringParameterWithDefault(studyInstanceUIDKey, "", "The UID of the DICOM study"), + tools.NewStringParameterWithDefault(patientNameKey, "", "The name of the patient"), + tools.NewStringParameterWithDefault(patientIDKey, "", "The ID of the patient"), + tools.NewStringParameterWithDefault(accessionNumberKey, "", "The accession number of the series"), + tools.NewStringParameterWithDefault(referringPhysicianNameKey, "", "The name of the referring physician"), + tools.NewStringParameterWithDefault(studyDateKey, "", "The date of the study in the format `YYYYMMDD`. You can also specify a date range in the format `YYYYMMDD-YYYYMMDD`"), + tools.NewStringParameterWithDefault(seriesInstanceUIDKey, "", "The UID of the DICOM series"), + tools.NewStringParameterWithDefault(modalityKey, "", "The modality of the series"), + tools.NewBooleanParameterWithDefault(common.EnablePatientNameFuzzyMatchingKey, false, `Whether to enable fuzzy matching for patient names. Fuzzy matching will perform tokenization and normalization of both the value of PatientName in the query and the stored value. It will match if any search token is a prefix of any stored token. For example, if PatientName is "John^Doe", then "jo", "Do" and "John Doe" will all match. However "ohn" will not match`), + tools.NewArrayParameterWithDefault(common.IncludeAttributesKey, []any{}, "List of attributeIDs, such as DICOM tag IDs or keywords. Set to [\"all\"] to return all available tags.", tools.NewStringParameter("attributeID", "The attributeID to include. Set to 'all' to return all available tags")), + } + if len(s.AllowedDICOMStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The DICOM store ID to get details for.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedDICOMStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + opts, err := common.ParseDICOMSearchParameters(params, []string{seriesInstanceUIDKey, patientNameKey, patientIDKey, accessionNumberKey, referringPhysicianNameKey, studyDateKey, modalityKey}) + if err != nil { + return nil, err + } + paramsMap := params.AsMap() + dicomWebPath := "series" + if studyInstanceUID, ok := paramsMap[studyInstanceUIDKey]; ok { + id, ok := studyInstanceUID.(string) + if !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string", studyInstanceUIDKey) + } + if id != "" { + dicomWebPath = fmt.Sprintf("studies/%s/series", id) + } + } + + name := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/dicomStores/%s", t.Project, t.Region, t.Dataset, storeID) + resp, err := svc.Projects.Locations.Datasets.DicomStores.SearchForSeries(name, dicomWebPath).Do(opts...) + if err != nil { + return nil, fmt.Errorf("failed to search dicom series: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("could not read response: %w", err) + } + if resp.StatusCode > 299 { + return nil, fmt.Errorf("search: status %d %s: %s", resp.StatusCode, resp.Status, respBytes) + } + if len(respBytes) == 0 { + return []interface{}{}, nil + } + var result []interface{} + if err := json.Unmarshal([]byte(string(respBytes)), &result); err != nil { + return nil, fmt.Errorf("could not unmarshal response as list: %w", err) + } + return result, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomseries/cloudhealthcaresearchdicomseries_test.go b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomseries/cloudhealthcaresearchdicomseries_test.go new file mode 100644 index 0000000000..7558adcadf --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomseries/cloudhealthcaresearchdicomseries_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package searchdicomseries_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + searchdicomseries "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomseries" +) + +func TestParseFromYamlHealthcareSearchDICOMSeries(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-search-dicom-series + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": searchdicomseries.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-search-dicom-series", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies/cloudhealthcaresearchdicomstudies.go b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies/cloudhealthcaresearchdicomstudies.go new file mode 100644 index 0000000000..310dea2901 --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies/cloudhealthcaresearchdicomstudies.go @@ -0,0 +1,215 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package searchdicomstudies + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + healthcareds "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "cloud-healthcare-search-dicom-studies" +const ( + studyInstanceUIDKey = "StudyInstanceUID" + patientNameKey = "PatientName" + patientIDKey = "PatientID" + accessionNumberKey = "AccessionNumber" + referringPhysicianNameKey = "ReferringPhysicianName" + studyDateKey = "StudyDate" +) + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + Project() string + Region() string + DatasetID() string + AllowedDICOMStores() map[string]struct{} + Service() *healthcare.Service + ServiceCreator() healthcareds.HealthcareServiceCreator + UseClientAuthorization() bool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &healthcareds.Source{} + +var compatibleSources = [...]string{healthcareds.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + parameters := tools.Parameters{ + tools.NewStringParameterWithDefault(studyInstanceUIDKey, "", "The UID of the DICOM study"), + tools.NewStringParameterWithDefault(patientNameKey, "", "The name of the patient"), + tools.NewStringParameterWithDefault(patientIDKey, "", "The ID of the patient"), + tools.NewStringParameterWithDefault(accessionNumberKey, "", "The accession number of the study"), + tools.NewStringParameterWithDefault(referringPhysicianNameKey, "", "The name of the referring physician"), + tools.NewStringParameterWithDefault(studyDateKey, "", "The date of the study in the format `YYYYMMDD`. You can also specify a date range in the format `YYYYMMDD-YYYYMMDD`"), + tools.NewBooleanParameterWithDefault(common.EnablePatientNameFuzzyMatchingKey, false, `Whether to enable fuzzy matching for patient names. Fuzzy matching will perform tokenization and normalization of both the value of PatientName in the query and the stored value. It will match if any search token is a prefix of any stored token. For example, if PatientName is "John^Doe", then "jo", "Do" and "John Doe" will all match. However "ohn" will not match`), + tools.NewArrayParameterWithDefault(common.IncludeAttributesKey, []any{}, "List of attributeIDs, such as DICOM tag IDs or keywords. Set to [\"all\"] to return all available tags.", tools.NewStringParameter("attributeID", "The attributeID to include. Set to 'all' to return all available tags")), + } + if len(s.AllowedDICOMStores()) != 1 { + parameters = append(parameters, tools.NewStringParameter(common.StoreKey, "The DICOM store ID to get details for.")) + } + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Project: s.Project(), + Region: s.Region(), + Dataset: s.DatasetID(), + AllowedStores: s.AllowedDICOMStores(), + UseClientOAuth: s.UseClientAuthorization(), + ServiceCreator: s.ServiceCreator(), + Service: s.Service(), + manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + UseClientOAuth bool `yaml:"useClientOAuth"` + Parameters tools.Parameters `yaml:"parameters"` + + Project, Region, Dataset string + AllowedStores map[string]struct{} + Service *healthcare.Service + ServiceCreator healthcareds.HealthcareServiceCreator + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + storeID, err := common.ValidateAndFetchStoreID(params, t.AllowedStores) + if err != nil { + return nil, err + } + + svc := t.Service + // Initialize new service if using user OAuth token + if t.UseClientOAuth { + tokenStr, err := accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("error parsing access token: %w", err) + } + svc, err = t.ServiceCreator(tokenStr) + if err != nil { + return nil, fmt.Errorf("error creating service from OAuth access token: %w", err) + } + } + + opts, err := common.ParseDICOMSearchParameters(params, []string{studyInstanceUIDKey, patientNameKey, patientIDKey, accessionNumberKey, referringPhysicianNameKey, studyDateKey}) + if err != nil { + return nil, err + } + name := fmt.Sprintf("projects/%s/locations/%s/datasets/%s/dicomStores/%s", t.Project, t.Region, t.Dataset, storeID) + resp, err := svc.Projects.Locations.Datasets.DicomStores.SearchForStudies(name, "studies").Do(opts...) + if err != nil { + return nil, fmt.Errorf("failed to search dicom studies: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("could not read response: %w", err) + } + if resp.StatusCode > 299 { + return nil, fmt.Errorf("search: status %d %s: %s", resp.StatusCode, resp.Status, respBytes) + } + if len(respBytes) == 0 { + return []interface{}{}, nil + } + var result []interface{} + if err := json.Unmarshal([]byte(string(respBytes)), &result); err != nil { + return nil, fmt.Errorf("could not unmarshal response as list: %w", err) + } + return result, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies/cloudhealthcaresearchdicomstudies_test.go b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies/cloudhealthcaresearchdicomstudies_test.go new file mode 100644 index 0000000000..483f37e61b --- /dev/null +++ b/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies/cloudhealthcaresearchdicomstudies_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package searchdicomstudies_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + searchdicomstudies "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies" +) + +func TestParseFromYamlHealthcareSearchDICOMStudies(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: cloud-healthcare-search-dicom-studies + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": searchdicomstudies.Config{ + Name: "example_tool", + Kind: "cloud-healthcare-search-dicom-studies", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/internal/tools/cloudhealthcare/common/util.go b/internal/tools/cloudhealthcare/common/util.go new file mode 100644 index 0000000000..481b5cd2f1 --- /dev/null +++ b/internal/tools/cloudhealthcare/common/util.go @@ -0,0 +1,91 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "fmt" + "slices" + "strings" + + "github.com/googleapis/genai-toolbox/internal/tools" + "google.golang.org/api/googleapi" +) + +// StoreKey is the key used to identify FHIR/DICOM store IDs in tool parameters. +const StoreKey = "storeID" + +// EnablePatientNameFuzzyMatchingKey is the key used for DICOM search to enable +// fuzzy matching. +const EnablePatientNameFuzzyMatchingKey = "fuzzymatching" + +// IncludeAttributesKey is the key used for DICOM search to include additional +// tags in the response. +const IncludeAttributesKey = "includefield" + +// ValidateAndFetchStoreID validates the provided storeID against the allowedStores. +// If only one store is allowed, it returns that storeID. +// If multiple stores are allowed, it checks if the storeID parameter is in the allowed list. +func ValidateAndFetchStoreID(params tools.ParamValues, allowedStores map[string]struct{}) (string, error) { + if len(allowedStores) == 1 { + for k := range allowedStores { + return k, nil + } + } + mapParams := params.AsMap() + storeID, ok := mapParams[StoreKey].(string) + if !ok { + return "", fmt.Errorf("invalid or missing '%s' parameter; expected a string", StoreKey) + } + if len(allowedStores) > 0 { + if _, ok := allowedStores[storeID]; !ok { + return "", fmt.Errorf("store ID '%s' is not in the list of allowed stores", storeID) + } + } + return storeID, nil +} + +// ParseDICOMSearchParameters extracts the search parameters for various DICOM +// search methods. +func ParseDICOMSearchParameters(params tools.ParamValues, paramKeys []string) ([]googleapi.CallOption, error) { + var opts []googleapi.CallOption + for k, v := range params.AsMap() { + if k == IncludeAttributesKey { + if _, ok := v.([]any); !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string array", k) + } + attributeIDsSlice, err := tools.ConvertAnySliceToTyped(v.([]any), "string") + if err != nil { + return nil, fmt.Errorf("can't convert '%s' to array of strings: %s", k, err) + } + attributeIDs := attributeIDsSlice.([]string) + if len(attributeIDs) != 0 { + opts = append(opts, googleapi.QueryParameter(k, strings.Join(attributeIDs, ","))) + } + } else if k == EnablePatientNameFuzzyMatchingKey { + if _, ok := v.(bool); !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a boolean", k) + } + opts = append(opts, googleapi.QueryParameter(k, fmt.Sprintf("%t", v.(bool)))) + } else if slices.Contains(paramKeys, k) { + if _, ok := v.(string); !ok { + return nil, fmt.Errorf("invalid '%s' parameter; expected a string", k) + } + if v.(string) != "" { + opts = append(opts, googleapi.QueryParameter(k, v.(string))) + } + } + } + return opts, nil +} diff --git a/tests/cloudhealthcare/cloud_healthcare_integration_test.go b/tests/cloudhealthcare/cloud_healthcare_integration_test.go new file mode 100644 index 0000000000..8b74b3a10e --- /dev/null +++ b/tests/cloudhealthcare/cloud_healthcare_integration_test.go @@ -0,0 +1,2436 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// To run these tests, set the following environment variables: +// HEALTHCARE_PROJECT: Google Cloud project ID for healthcare resources. +// HEALTHCARE_REGION: Google Cloud region for healthcare resources. + +package cloudhealthcare + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/tests" + "golang.org/x/oauth2/google" + "google.golang.org/api/healthcare/v1" + "google.golang.org/api/option" +) + +var ( + healthcareSourceKind = "cloud-healthcare" + getDatasetToolKind = "cloud-healthcare-get-dataset" + listFHIRStoresToolKind = "cloud-healthcare-list-fhir-stores" + listDICOMStoresToolKind = "cloud-healthcare-list-dicom-stores" + getFHIRStoreToolKind = "cloud-healthcare-get-fhir-store" + getFHIRStoreMetricsToolKind = "cloud-healthcare-get-fhir-store-metrics" + getFHIRResourceToolKind = "cloud-healthcare-get-fhir-resource" + fhirPatientSearchToolKind = "cloud-healthcare-fhir-patient-search" + fhirPatientEverythingToolKind = "cloud-healthcare-fhir-patient-everything" + fhirFetchPageToolKind = "cloud-healthcare-fhir-fetch-page" + getDICOMStoreToolKind = "cloud-healthcare-get-dicom-store" + getDICOMStoreMetricsToolKind = "cloud-healthcare-get-dicom-store-metrics" + searchDICOMStudiesToolKind = "cloud-healthcare-search-dicom-studies" + searchDICOMSeriesToolKind = "cloud-healthcare-search-dicom-series" + searchDICOMInstancesToolKind = "cloud-healthcare-search-dicom-instances" + retrieveRenderedDICOMInstanceToolKind = "cloud-healthcare-retrieve-rendered-dicom-instance" + healthcareProject = os.Getenv("HEALTHCARE_PROJECT") + healthcareRegion = os.Getenv("HEALTHCARE_REGION") + healthcareDataset = os.Getenv("HEALTHCARE_DATASET") + healthcarePrepopulatedDICOMStore = os.Getenv("HEALTHCARE_PREPOPULATED_DICOM_STORE") +) + +type DICOMInstance struct { + study, series, instance string +} + +var ( + singleFrameDICOMInstance = DICOMInstance{ + study: "1.2.840.113619.2.176.3596.3364818.7819.1259708454.105", + series: "1.2.840.113619.2.176.3596.3364818.7819.1259708454.108", + instance: "1.2.840.113619.2.176.3596.3364818.7271.1259708501.876", + } + multiFrameDICOMInstance = DICOMInstance{ + study: "1.2.826.0.1.3680043.9.5704.649259287", + series: "1.2.826.0.1.3680043.9.5704.983743739", + instance: "1.2.826.0.1.3680043.9.5704.983743739.2", + } +) + +func getHealthcareVars(t *testing.T) map[string]any { + switch "" { + case healthcareProject: + t.Fatal("'HEALTHCARE_PROJECT' not set") + case healthcareRegion: + t.Fatal("'HEALTHCARE_REGION' not set") + case healthcareDataset: + t.Fatal("'HEALTHCARE_DATASET' not set") + case healthcarePrepopulatedDICOMStore: + t.Fatal("'HEALTHCARE_PREPOPULATED_DICOM_STORE' not set") + } + return map[string]any{ + "kind": healthcareSourceKind, + "project": healthcareProject, + "region": healthcareRegion, + "dataset": healthcareDataset, + } +} + +func TestHealthcareToolEndpoints(t *testing.T) { + sourceConfig := getHealthcareVars(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + healthcareService, err := newHealthcareService(ctx) + if err != nil { + t.Fatalf("failed to create healthcare service: %v", err) + } + + fhirStoreID := "fhir-store-" + uuid.New().String() + dicomStoreID := "dicom-store-" + uuid.New().String() + + patient1ID, patient2ID, teardown := setupHealthcareResources(t, healthcareService, healthcareDataset, fhirStoreID, dicomStoreID) + defer teardown(t) + + toolsFile := getToolsConfig(sourceConfig) + toolsFile = addClientAuthSourceConfig(t, toolsFile) + + var args []string + cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...) + if err != nil { + t.Fatalf("command initialization returned an error: %s", err) + } + defer cleanup() + + waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out) + if err != nil { + t.Logf("toolbox command logs: %s", out) + t.Fatalf("toolbox didn't start successfully: %s", err) + } + + datasetWant := fmt.Sprintf(`"name":"projects/%s/locations/%s/datasets/%s"`, healthcareProject, healthcareRegion, healthcareDataset) + fhirStoreWant := fmt.Sprintf(`"name":"projects/%s/locations/%s/datasets/%s/fhirStores/%s"`, healthcareProject, healthcareRegion, healthcareDataset, fhirStoreID) + dicomStoreWant := fmt.Sprintf(`"name":"projects/%s/locations/%s/datasets/%s/dicomStores/%s"`, healthcareProject, healthcareRegion, healthcareDataset, dicomStoreID) + + runGetDatasetToolInvokeTest(t, datasetWant) + runListFHIRStoresToolInvokeTest(t, fhirStoreWant) + runListDICOMStoresToolInvokeTest(t, dicomStoreWant) + runGetFHIRStoreToolInvokeTest(t, fhirStoreID, fhirStoreWant) + runGetFHIRStoreMetricsToolInvokeTest(t, fhirStoreID, `"metrics"`) + runGetFHIRResourceToolInvokeTest(t, fhirStoreID, "Patient", patient1ID, `"id":"`+patient1ID+`"`) + runFHIRPatientSearchToolInvokeTest(t, fhirStoreID, patient1ID, patient2ID) + runFHIRPatientEverythingToolInvokeTest(t, fhirStoreID, patient1ID, `"resourceType":"Bundle"`) + + nextURL := getNextPageURLForPatientEverything(t, fhirStoreID, patient2ID) + runFHIRFetchPageToolInvokeTest(t, nextURL, `"total":1`) + + runGetDICOMStoreToolInvokeTest(t, dicomStoreID, dicomStoreWant) + runGetDICOMStoreMetricsToolInvokeTest(t, healthcarePrepopulatedDICOMStore, `"structuredStorageSizeBytes"`) + runSearchDICOMStudiesToolInvokeTest(t, healthcarePrepopulatedDICOMStore) + runSearchDICOMSeriesToolInvokeTest(t, healthcarePrepopulatedDICOMStore) + runSearchDICOMInstancesToolInvokeTest(t, healthcarePrepopulatedDICOMStore) + runRetrieveRenderedDICOMInstanceToolInvokeTest(t, healthcarePrepopulatedDICOMStore) +} + +func TestHealthcareToolWithStoreRestriction(t *testing.T) { + sourceConfig := getHealthcareVars(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + healthcareService, err := newHealthcareService(ctx) + if err != nil { + t.Fatalf("failed to create healthcare service: %v", err) + } + + // Create stores + allowedFHIRStoreID := "fhir-store-allowed-" + uuid.New().String() + allowedDICOMStoreID := "dicom-store-allowed-" + uuid.New().String() + disallowedFHIRStoreID := "fhir-store-disallowed-" + uuid.New().String() + disallowedDICOMStoreID := "dicom-store-disallowed-" + uuid.New().String() + + _, _, teardownAllowedStores := setupHealthcareResources(t, healthcareService, healthcareDataset, allowedFHIRStoreID, allowedDICOMStoreID) + defer teardownAllowedStores(t) + _, _, teardownDisallowedStores := setupHealthcareResources(t, healthcareService, healthcareDataset, disallowedFHIRStoreID, disallowedDICOMStoreID) + defer teardownDisallowedStores(t) + + // Configure source with dataset restriction. + sourceConfig["allowedFhirStores"] = []string{allowedFHIRStoreID} + sourceConfig["allowedDicomStores"] = []string{allowedDICOMStoreID} + + // Configure tool + toolsConfig := map[string]any{ + "list-fhir-stores-restricted": map[string]any{ + "kind": "cloud-healthcare-list-fhir-stores", + "source": "my-instance", + "description": "Tool to list fhir stores", + }, + "list-dicom-stores-restricted": map[string]any{ + "kind": "cloud-healthcare-list-dicom-stores", + "source": "my-instance", + "description": "Tool to list dicom stores", + }, + } + + // Create config file + config := map[string]any{ + "sources": map[string]any{ + "my-instance": sourceConfig, + }, + "tools": toolsConfig, + } + + // Start server + cmd, cleanup, err := tests.StartCmd(ctx, config) + if err != nil { + t.Fatalf("command initialization returned an error: %s", err) + } + defer cleanup() + + waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out) + if err != nil { + t.Logf("toolbox command logs: \n%s", out) + t.Fatalf("toolbox didn't start successfully: %s", err) + } + + // Run tests + runListFHIRStoresWithRestriction(t, allowedFHIRStoreID, disallowedFHIRStoreID) + runListDICOMStoresWithRestriction(t, allowedDICOMStoreID, disallowedDICOMStoreID) +} + +func createFHIRResource(t *testing.T, service *healthcare.Service, fhirStoreName, resourceType string, resourceBody io.Reader) (string, string) { + resp, err := service.Projects.Locations.Datasets.FhirStores.Fhir.Create(fhirStoreName, resourceType, resourceBody).Do() + if err != nil { + t.Fatalf("failed to create FHIR resource: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("failed to create FHIR resource, status: %d, body: %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + id := result["id"].(string) + return id, fmt.Sprintf("%s/%s", resourceType, id) +} + +func newHealthcareService(ctx context.Context) (*healthcare.Service, error) { + creds, err := google.FindDefaultCredentials(ctx, healthcare.CloudHealthcareScope) + if err != nil { + return nil, fmt.Errorf("failed to find default credentials: %w", err) + } + + healthcareService, err := healthcare.NewService(ctx, option.WithCredentials(creds)) + if err != nil { + return nil, fmt.Errorf("failed to create healthcare service: %w", err) + } + return healthcareService, nil +} + +func setupHealthcareResources(t *testing.T, service *healthcare.Service, datasetID, fhirStoreID, dicomStoreID string) (string, string, func(*testing.T)) { + datasetName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s", healthcareProject, healthcareRegion, datasetID) + var err error + + // Create FHIR store + fhirStore := &healthcare.FhirStore{Version: "R4"} + if fhirStore, err = service.Projects.Locations.Datasets.FhirStores.Create(datasetName, fhirStore).FhirStoreId(fhirStoreID).Do(); err != nil { + t.Fatalf("failed to create fhir store: %v", err) + } + + // Create DICOM store + dicomStore := &healthcare.DicomStore{} + if dicomStore, err = service.Projects.Locations.Datasets.DicomStores.Create(datasetName, dicomStore).DicomStoreId(dicomStoreID).Do(); err != nil { + t.Fatalf("failed to create dicom store: %v", err) + } + + // Create Patient 1 + patient1Body := bytes.NewBuffer([]byte(`{ + "resourceType":"Patient", + "name":[{"use":"official","family":"Smith","given":["John"]}], + "birthDate":"1980-01-01", + "gender":"male", + "address":[{"use":"home","line":["123 Main St"],"city":"san fransisco","state":"CA","postalCode":"12345","country":"USA"}], + "active":true, + "deceasedBoolean":false, + "telecom":[{"system":"phone","value":"555-1234","use":"home"},{"system":"email","value":"john@foo.com","use":"work"}], + "gender":"male", + "identifier":[{"system":"http://hospital.org","value":"1234567"}], + "communication":[{"language":{"coding":[{"system":"urn:ietf:bcp:47","code":"en","display":"English"}]},"preferred":true}] + }`)) + patient1ID, patient1Name := createFHIRResource(t, service, fhirStore.Name, "Patient", patient1Body) + + // Create Observation for Patient 1 + observation1Body := bytes.NewBuffer([]byte(fmt.Sprintf(` + { + "resourceType": "Observation", + "status": "final", + "code": { "coding": [{ "system": "http://loinc.org", "code": "29463-7", "display": "Body Weight" }] }, + "subject": { "reference": "%s" }, + "valueQuantity": { "value": 185, "unit": "lbs", "system": "http://unitsofmeasure.org", "code": "[lb_av]" } + }`, patient1Name))) + createFHIRResource(t, service, fhirStore.Name, "Observation", observation1Body) + + // Create Patient 2 + patient2Body := bytes.NewBuffer([]byte(`{"resourceType":"Patient","name":[{"use":"official","family":"Doe","given":["Jane"]}]}`)) + patient2ID, patient2Name := createFHIRResource(t, service, fhirStore.Name, "Patient", patient2Body) + + // Create 101 Observations for Patient 2 + for i := 0; i < 101; i++ { + observation2Body := bytes.NewBuffer([]byte(fmt.Sprintf(` + { + "resourceType": "Observation", + "status": "final", + "code": { "coding": [{ "system": "http://loinc.org", "code": "8302-2", "display": "Body Height" }] }, + "subject": { "reference": "%s" }, + "valueQuantity": { "value": 68, "unit": "in", "system": "http://unitsofmeasure.org", "code": "[in_i]" } + }`, patient2Name))) + createFHIRResource(t, service, fhirStore.Name, "Observation", observation2Body) + } + + teardown := func(t *testing.T) { + if _, err := service.Projects.Locations.Datasets.FhirStores.Delete(fhirStore.Name).Do(); err != nil { + t.Logf("failed to delete fhir store: %v", err) + } + if _, err := service.Projects.Locations.Datasets.DicomStores.Delete(dicomStore.Name).Do(); err != nil { + t.Logf("failed to delete dicom store: %v", err) + } + } + return patient1ID, patient2ID, teardown +} + +func getToolsConfig(sourceConfig map[string]any) map[string]any { + config := map[string]any{ + "sources": map[string]any{ + "my-instance": sourceConfig, + }, + "tools": map[string]any{ + "my-get-dataset-tool": map[string]any{ + "kind": getDatasetToolKind, + "source": "my-instance", + "description": "Tool to get a healthcare dataset", + }, + "my-list-fhir-stores-tool": map[string]any{ + "kind": listFHIRStoresToolKind, + "source": "my-instance", + "description": "Tool to list FHIR stores", + }, + "my-list-dicom-stores-tool": map[string]any{ + "kind": listDICOMStoresToolKind, + "source": "my-instance", + "description": "Tool to list DICOM stores", + }, + "my-get-fhir-store-tool": map[string]any{ + "kind": getFHIRStoreToolKind, + "source": "my-instance", + "description": "Tool to get a FHIR store", + }, + "my-get-fhir-store-metrics-tool": map[string]any{ + "kind": getFHIRStoreMetricsToolKind, + "source": "my-instance", + "description": "Tool to get FHIR store metrics", + }, + "my-get-fhir-resource-tool": map[string]any{ + "kind": getFHIRResourceToolKind, + "source": "my-instance", + "description": "Tool to get FHIR resource", + }, + "my-fhir-patient-search-tool": map[string]any{ + "kind": fhirPatientSearchToolKind, + "source": "my-instance", + "description": "Tool to search for patients", + }, + "my-fhir-patient-everything-tool": map[string]any{ + "kind": fhirPatientEverythingToolKind, + "source": "my-instance", + "description": "Tool for patient everything", + }, + "my-fhir-fetch-page-tool": map[string]any{ + "kind": fhirFetchPageToolKind, + "source": "my-instance", + "description": "Tool to fetch a page of FHIR resources", + }, + "my-get-dicom-store-tool": map[string]any{ + "kind": getDICOMStoreToolKind, + "source": "my-instance", + "description": "Tool to get a DICOM store", + }, + "my-get-dicom-store-metrics-tool": map[string]any{ + "kind": getDICOMStoreMetricsToolKind, + "source": "my-instance", + "description": "Tool to get DICOM store metrics", + }, + "my-search-dicom-studies-tool": map[string]any{ + "kind": searchDICOMStudiesToolKind, + "source": "my-instance", + "description": "Tool to search DICOM studies", + }, + "my-search-dicom-series-tool": map[string]any{ + "kind": searchDICOMSeriesToolKind, + "source": "my-instance", + "description": "Tool to search DICOM series", + }, + "my-search-dicom-instances-tool": map[string]any{ + "kind": searchDICOMInstancesToolKind, + "source": "my-instance", + "description": "Tool to search DICOM instances", + }, + "my-retrieve-rendered-dicom-instance-tool": map[string]any{ + "kind": retrieveRenderedDICOMInstanceToolKind, + "source": "my-instance", + "description": "Tool to retrieve rendered DICOM instance", + }, + "my-client-auth-get-dataset-tool": map[string]any{ + "kind": getDatasetToolKind, + "source": "my-client-auth-source", + "description": "Tool to get a healthcare dataset", + }, + "my-client-auth-list-fhir-stores-tool": map[string]any{ + "kind": listFHIRStoresToolKind, + "source": "my-client-auth-source", + "description": "Tool to list FHIR stores", + }, + "my-client-auth-list-dicom-stores-tool": map[string]any{ + "kind": listDICOMStoresToolKind, + "source": "my-client-auth-source", + "description": "Tool to list DICOM stores", + }, + "my-client-auth-get-fhir-store-tool": map[string]any{ + "kind": getFHIRStoreToolKind, + "source": "my-client-auth-source", + "description": "Tool to get a FHIR store", + }, + "my-client-auth-get-fhir-store-metrics-tool": map[string]any{ + "kind": getFHIRStoreMetricsToolKind, + "source": "my-client-auth-source", + "description": "Tool to get FHIR store metrics", + }, + "my-client-auth-get-fhir-resource-tool": map[string]any{ + "kind": getFHIRResourceToolKind, + "source": "my-client-auth-source", + "description": "Tool to get FHIR resource", + }, + "my-client-auth-fhir-patient-search-tool": map[string]any{ + "kind": fhirPatientSearchToolKind, + "source": "my-client-auth-source", + "description": "Tool to search for patients", + }, + "my-client-auth-fhir-patient-everything-tool": map[string]any{ + "kind": fhirPatientEverythingToolKind, + "source": "my-client-auth-source", + "description": "Tool for patient everything", + }, + "my-client-auth-fhir-fetch-page-tool": map[string]any{ + "kind": fhirFetchPageToolKind, + "source": "my-client-auth-source", + "description": "Tool to fetch a page of FHIR resources", + }, + "my-client-auth-get-dicom-store-tool": map[string]any{ + "kind": getDICOMStoreToolKind, + "source": "my-client-auth-source", + "description": "Tool to get a DICOM store", + }, + "my-client-auth-get-dicom-store-metrics-tool": map[string]any{ + "kind": getDICOMStoreMetricsToolKind, + "source": "my-client-auth-source", + "description": "Tool to get DICOM store metrics", + }, + "my-client-auth-search-dicom-studies-tool": map[string]any{ + "kind": searchDICOMStudiesToolKind, + "source": "my-client-auth-source", + "description": "Tool to search DICOM studies", + }, + "my-client-auth-search-dicom-series-tool": map[string]any{ + "kind": searchDICOMSeriesToolKind, + "source": "my-client-auth-source", + "description": "Tool to search DICOM series", + }, + "my-client-auth-search-dicom-instances-tool": map[string]any{ + "kind": searchDICOMInstancesToolKind, + "source": "my-client-auth-source", + "description": "Tool to search DICOM instances", + }, + "my-client-auth-retrieve-rendered-dicom-instance-tool": map[string]any{ + "kind": retrieveRenderedDICOMInstanceToolKind, + "source": "my-client-auth-source", + "description": "Tool to retrieve rendered DICOM instance", + }, + "my-auth-get-dataset-tool": map[string]any{ + "kind": getDatasetToolKind, + "source": "my-instance", + "description": "Tool to get a healthcare dataset with auth", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-list-fhir-stores-tool": map[string]any{ + "kind": listFHIRStoresToolKind, + "source": "my-instance", + "description": "Tool to list FHIR stores with auth", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-list-dicom-stores-tool": map[string]any{ + "kind": listDICOMStoresToolKind, + "source": "my-instance", + "description": "Tool to list DICOM stores with auth", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-get-fhir-store-tool": map[string]any{ + "kind": getFHIRStoreToolKind, + "source": "my-instance", + "description": "Tool to get a FHIR store", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-get-fhir-store-metrics-tool": map[string]any{ + "kind": getFHIRStoreMetricsToolKind, + "source": "my-instance", + "description": "Tool to get FHIR store metrics", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-get-fhir-resource-tool": map[string]any{ + "kind": getFHIRResourceToolKind, + "source": "my-instance", + "description": "Tool to get FHIR resource", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-fhir-patient-search-tool": map[string]any{ + "kind": fhirPatientSearchToolKind, + "source": "my-instance", + "description": "Tool to search for patients", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-fhir-patient-everything-tool": map[string]any{ + "kind": fhirPatientEverythingToolKind, + "source": "my-instance", + "description": "Tool for patient everything", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-fhir-fetch-page-tool": map[string]any{ + "kind": fhirFetchPageToolKind, + "source": "my-instance", + "description": "Tool to fetch a page of FHIR resources", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-get-dicom-store-tool": map[string]any{ + "kind": getDICOMStoreToolKind, + "source": "my-instance", + "description": "Tool to get a DICOM store", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-get-dicom-store-metrics-tool": map[string]any{ + "kind": getDICOMStoreMetricsToolKind, + "source": "my-instance", + "description": "Tool to get DICOM store metrics", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-search-dicom-studies-tool": map[string]any{ + "kind": searchDICOMStudiesToolKind, + "source": "my-instance", + "description": "Tool to search DICOM studies", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-search-dicom-series-tool": map[string]any{ + "kind": searchDICOMSeriesToolKind, + "source": "my-instance", + "description": "Tool to search DICOM series", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-search-dicom-instances-tool": map[string]any{ + "kind": searchDICOMInstancesToolKind, + "source": "my-instance", + "description": "Tool to search DICOM instances", + "authRequired": []string{ + "my-google-auth", + }, + }, + "my-auth-retrieve-rendered-dicom-instance-tool": map[string]any{ + "kind": retrieveRenderedDICOMInstanceToolKind, + "source": "my-instance", + "description": "Tool to retrieve rendered DICOM instance", + "authRequired": []string{ + "my-google-auth", + }, + }, + }, + "authServices": map[string]any{ + "my-google-auth": map[string]any{ + "kind": "google", + "clientId": tests.ClientId, + }, + }, + } + return config +} + +func addClientAuthSourceConfig(t *testing.T, config map[string]any) map[string]any { + sources, ok := config["sources"].(map[string]any) + if !ok { + t.Fatalf("unable to get sources from config") + } + sources["my-client-auth-source"] = map[string]any{ + "kind": healthcareSourceKind, + "project": healthcareProject, + "region": healthcareRegion, + "dataset": healthcareDataset, + "useClientOAuth": true, + } + config["sources"] = sources + return config +} + +func runGetDatasetToolInvokeTest(t *testing.T, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-get-dataset-tool", + api: "http://127.0.0.1:5000/api/tool/my-get-dataset-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-dataset-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-dataset-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-dataset-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-get-dataset-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-dataset-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-dataset-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + { + name: "invoke my-auth-get-dataset-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-dataset-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-dataset-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-dataset-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-get-dataset-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-dataset-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-dataset-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-dataset-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runListFHIRStoresToolInvokeTest(t *testing.T, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-list-fhir-stores-tool", + api: "http://127.0.0.1:5000/api/tool/my-list-fhir-stores-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-list-fhir-stores-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-list-fhir-stores-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-list-fhir-stores-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-list-fhir-stores-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-list-fhir-stores-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-list-fhir-stores-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + { + name: "invoke my-auth-list-fhir-stores-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-list-fhir-stores-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-list-fhir-stores-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-list-fhir-stores-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-list-fhir-stores-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-list-fhir-stores-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-list-fhir-stores-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-list-fhir-stores-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runListDICOMStoresToolInvokeTest(t *testing.T, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-list-dicom-stores-tool", + api: "http://127.0.0.1:5000/api/tool/my-list-dicom-stores-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-list-dicom-stores-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-list-dicom-stores-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-list-dicom-stores-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-list-dicom-stores-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-list-dicom-stores-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-list-dicom-stores-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + { + name: "invoke my-auth-list-dicom-stores-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-list-dicom-stores-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-list-dicom-stores-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-list-dicom-stores-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-list-dicom-stores-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-list-dicom-stores-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-list-dicom-stores-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-list-dicom-stores-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runGetFHIRStoreToolInvokeTest(t *testing.T, fhirStoreID, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-get-fhir-store-tool", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-store-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-fhir-store-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-fhir-store-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-fhir-store-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-store-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-fhir-store-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-fhir-store-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-get-fhir-store-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-fhir-store-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-get-fhir-store-tool with invalid storeID", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-store-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"invalid-store"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-fhir-store-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-fhir-store-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-get-fhir-store-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-fhir-store-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-fhir-store-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-fhir-store-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runGetFHIRStoreMetricsToolInvokeTest(t *testing.T, fhirStoreID, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-get-fhir-store-metrics-tool", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-store-metrics-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-fhir-store-metrics-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-fhir-store-metrics-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-fhir-store-metrics-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-store-metrics-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-fhir-store-metrics-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-fhir-store-metrics-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-get-fhir-store-metrics-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-fhir-store-metrics-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-get-fhir-store-metrics-tool with invalid storeID", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-store-metrics-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"invalid-store"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-fhir-store-metrics-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-fhir-store-metrics-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-get-fhir-store-metrics-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-fhir-store-metrics-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-fhir-store-metrics-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-fhir-store-metrics-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runGetFHIRResourceToolInvokeTest(t *testing.T, storeID, resType, resID, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-get-fhir-resource-tool", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `", "resourceID":"` + resID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-fhir-resource-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `", "resourceID":"` + resID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-fhir-resource-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `", "resourceID":"` + resID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-fhir-resource-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `", "resourceID":"` + resID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-get-fhir-resource-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `", "resourceID":"` + resID + `"}`)), + isErr: true, + }, + { + name: "invoke my-get-fhir-resource-tool with non-existent resource", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `", "resourceID":"foo"}`)), + isErr: true, + }, + { + name: "invoke my-get-fhir-resource-tool with missing required id parameter", + api: "http://127.0.0.1:5000/api/tool/my-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-fhir-resource-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `", "resourceID":"` + resID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-get-fhir-resource-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `", "resourceID":"` + resID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-fhir-resource-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-fhir-resource-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + storeID + `", "resourceType":"` + resType + `", "resourceID":"` + resID + `"}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runFHIRPatientSearchToolInvokeTest(t *testing.T, fhirStoreID string, patientIDs ...string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + want := `"total":` + fmt.Sprintf(`%d`, len(patientIDs)) + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-fhir-patient-search-tool", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-fhir-patient-search-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-fhir-patient-search-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-fhir-patient-search-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-fhir-patient-search-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-fhir-patient-search-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-fhir-patient-search-tool wrong parameter type", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "name":true}`)), + isErr: true, + }, + { + name: "invoke my-fhir-patient-search-tool filters", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "name":"john","gender":"male","state":"CA","active":"true","birthDateRange":"1970-01-01/2000-12-31"}`)), + want: patientIDs[0], + isErr: false, + }, + { + name: "invoke my-fhir-patient-search-tool filters 2", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "givenName":"john","addressSubstring":"main st","email":"john@foo.com","phone":"555-1234","language":"en","deceased":"false"}`)), + want: patientIDs[0], + isErr: false, + }, + { + name: "invoke my-fhir-patient-search-tool filters 3", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "familyName":"smith","country":"USA","addressUse":"home","postalCode":"12345","identifier":"1234567"}`)), + want: patientIDs[0], + isErr: false, + }, + { + name: "invoke my-fhir-patient-search-tool zero matches", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "gender":"unknown"}`)), + want: `"total":0`, + isErr: false, + }, + { + name: "invoke my-fhir-patient-search-tool match second patient only", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `","familyName":"Doe"}`)), + want: patientIDs[1], + isErr: false, + }, + { + name: "invoke my-client-auth-fhir-patient-search-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-fhir-patient-search-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-fhir-patient-search-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-fhir-patient-search-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `"}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runFHIRPatientEverythingToolInvokeTest(t *testing.T, fhirStoreID, patientID, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-fhir-patient-everything-tool", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-fhir-patient-everything-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-fhir-patient-everything-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-fhir-patient-everything-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-fhir-patient-everything-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `"}`)), + isErr: true, + }, + { + name: "invoke my-fhir-patient-everything-tool with non-existent patient", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"foo"`)), + isErr: true, + }, + { + name: "invoke my-fhir-patient-everything-tool with invalid since filter format", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `","sinceFilter":"October 10th, 2023"}`)), + isErr: true, + }, + { + name: "invoke my-fhir-patient-everything-tool with type and since filters", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `","sinceFilter":"2023-01-01T00:00:00Z","resourceTypesFilter":["Observation"]}`)), + want: `"total":2`, + isErr: false, + }, + { + name: "invoke my-fhir-patient-everything-tool with type and since keeps only patient", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `","sinceFilter":"` + time.Now().Format(time.RFC3339) + `","resourceTypesFilter":["Observation","Encounter"]}`)), + want: `"total":1`, + isErr: false, + }, + { + name: "invoke my-fhir-patient-everything-tool with type keeps only patient", + api: "http://127.0.0.1:5000/api/tool/my-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `","resourceTypesFilter":["Encounter","Claim"]}`)), + want: `"total":1`, + isErr: false, + }, + { + name: "invoke my-client-auth-fhir-patient-everything-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-fhir-patient-everything-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-fhir-patient-everything-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-fhir-patient-everything-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + fhirStoreID + `", "patientID":"` + patientID + `"}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runFHIRFetchPageToolInvokeTest(t *testing.T, pageURL, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-fhir-fetch-page-tool", + api: "http://127.0.0.1:5000/api/tool/my-fhir-fetch-page-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"pageURL":"` + pageURL + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-fhir-fetch-page-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-fetch-page-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"pageURL":"` + pageURL + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-fhir-fetch-page-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-fhir-fetch-page-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"pageURL":"` + pageURL + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-fhir-fetch-page-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-fetch-page-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"pageURL":"` + pageURL + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-fhir-fetch-page-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-fhir-fetch-page-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"pageURL":"` + pageURL + `"}`)), + isErr: true, + }, + { + name: "invoke my-fhir-fetch-page-tool with invalid url", + api: "http://127.0.0.1:5000/api/tool/my-fhir-fetch-page-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"pageURL":"google.com"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-fhir-fetch-page-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-fhir-fetch-page-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"pageURL":"` + pageURL + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-fhir-fetch-page-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-fhir-fetch-page-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"pageURL":"` + pageURL + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-fhir-fetch-page-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-fhir-fetch-page-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"pageURL":"` + pageURL + `"}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func getNextPageURLForPatientEverything(t *testing.T, fhirStoreID, patientID string) string { + api := "http://127.0.0.1:5000/api/tool/my-fhir-patient-everything-tool/invoke" + reqBody := fmt.Sprintf(`{"storeID": "%s", "patientID": "%s"}`, fhirStoreID, patientID) + resp, bodyBytes := tests.RunRequest(t, http.MethodPost, api, bytes.NewBuffer([]byte(reqBody)), map[string]string{"Content-type": "application/json"}) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var body map[string]interface{} + err := json.Unmarshal(bodyBytes, &body) + if err != nil { + t.Fatalf("error parsing response body") + } + + resultStr, ok := body["result"].(string) + if !ok { + t.Fatalf("unable to find result in response body") + } + + var resultJSON map[string]interface{} + if err := json.Unmarshal([]byte(resultStr), &resultJSON); err != nil { + t.Fatalf("failed to unmarshal result string: %v", err) + } + + links, ok := resultJSON["link"].([]interface{}) + if !ok { + t.Fatalf("no link field in result") + } + + for _, l := range links { + link := l.(map[string]interface{}) + if relation, ok := link["relation"].(string); ok && relation == "next" { + if url, ok := link["url"].(string); ok { + return url + } + } + } + t.Fatalf("next link not found in patient everything response") + return "" +} + +func runTest(t *testing.T, api string, requestHeader map[string]string, requestBody io.Reader) (string, int) { + resp, bodyBytes := tests.RunRequest(t, http.MethodPost, api, requestBody, requestHeader) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", resp.StatusCode + } + + var body map[string]interface{} + err := json.Unmarshal(bodyBytes, &body) + if err != nil { + t.Fatalf("error parsing response body") + } + + got, ok := body["result"].(string) + if !ok { + t.Fatalf("unable to find result in response body") + } + return got, http.StatusOK +} + +func runListFHIRStoresWithRestriction(t *testing.T, allowedFHIRStore, disallowedFHIRStore string) { + api := "http://127.0.0.1:5000/api/tool/list-fhir-stores-restricted/invoke" + got, status := runTest(t, api, map[string]string{"Content-type": "application/json"}, bytes.NewBuffer([]byte(`{}`))) + if status != http.StatusOK { + t.Fatalf("expected status OK but got %d", status) + } + + if !strings.Contains(got, allowedFHIRStore) { + t.Fatalf("expected %q to contain %q, but it did not", got, allowedFHIRStore) + } + if strings.Contains(got, disallowedFHIRStore) { + t.Fatalf("expected %q to NOT contain %q, but it did", got, disallowedFHIRStore) + } +} + +func runListDICOMStoresWithRestriction(t *testing.T, allowedDICOMStore, disallowedDICOMStore string) { + api := "http://127.0.0.1:5000/api/tool/list-dicom-stores-restricted/invoke" + got, status := runTest(t, api, map[string]string{"Content-type": "application/json"}, bytes.NewBuffer([]byte(`{}`))) + if status != http.StatusOK { + t.Fatalf("expected status OK but got %d", status) + } + + if !strings.Contains(got, allowedDICOMStore) { + t.Fatalf("expected %q to contain %q, but it did not", got, allowedDICOMStore) + } + if strings.Contains(got, disallowedDICOMStore) { + t.Fatalf("expected %q to NOT contain %q, but it did", got, disallowedDICOMStore) + } +} + +func runGetDICOMStoreToolInvokeTest(t *testing.T, dicomStoreID, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-get-dicom-store-tool", + api: "http://127.0.0.1:5000/api/tool/my-get-dicom-store-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-dicom-store-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-dicom-store-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-dicom-store-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-get-dicom-store-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-dicom-store-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-dicom-store-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-get-dicom-store-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-dicom-store-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-get-dicom-store-tool with invalid storeID", + api: "http://127.0.0.1:5000/api/tool/my-get-dicom-store-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"invalid-store"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-dicom-store-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-dicom-store-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-get-dicom-store-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-dicom-store-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-dicom-store-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-dicom-store-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runGetDICOMStoreMetricsToolInvokeTest(t *testing.T, dicomStoreID, want string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-get-dicom-store-metrics-tool", + api: "http://127.0.0.1:5000/api/tool/my-get-dicom-store-metrics-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-dicom-store-metrics-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-dicom-store-metrics-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-dicom-store-metrics-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-get-dicom-store-metrics-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-auth-get-dicom-store-metrics-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-dicom-store-metrics-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-get-dicom-store-metrics-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-get-dicom-store-metrics-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-get-dicom-store-metrics-tool with invalid storeID", + api: "http://127.0.0.1:5000/api/tool/my-get-dicom-store-metrics-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"invalid-store"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-dicom-store-metrics-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-dicom-store-metrics-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: want, + isErr: false, + }, + { + name: "invoke my-client-auth-get-dicom-store-metrics-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-dicom-store-metrics-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-get-dicom-store-metrics-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-get-dicom-store-metrics-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runSearchDICOMStudiesToolInvokeTest(t *testing.T, dicomStoreID string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-search-dicom-studies-tool", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: singleFrameDICOMInstance.study, + isErr: false, + }, + { + name: "invoke my-auth-search-dicom-studies-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: multiFrameDICOMInstance.study, + isErr: false, + }, + { + name: "invoke my-auth-search-dicom-studies-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: multiFrameDICOMInstance.study, + isErr: false, + }, + { + name: "invoke my-auth-search-dicom-studies-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-search-dicom-studies-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-search-dicom-studies-tool with invalid storeID", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"invalid-store"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-search-dicom-studies-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: singleFrameDICOMInstance.study, + isErr: false, + }, + { + name: "invoke my-client-auth-search-dicom-studies-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-search-dicom-studies-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-search-dicom-studies-tool with patient name and fuzzy matching", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "PatientName":"Andrew", "fuzzymatching":true}`)), + want: multiFrameDICOMInstance.study, + isErr: false, + }, + { + name: "invoke my-search-dicom-studies-tool with patient id filter", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-studies-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "PatientID":"Joelle-del"}`)), + want: singleFrameDICOMInstance.study, + isErr: false, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runSearchDICOMSeriesToolInvokeTest(t *testing.T, dicomStoreID string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-search-dicom-series-tool", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-series-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: singleFrameDICOMInstance.series, + isErr: false, + }, + { + name: "invoke my-auth-search-dicom-series-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-search-dicom-series-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: multiFrameDICOMInstance.series, + isErr: false, + }, + { + name: "invoke my-auth-search-dicom-series-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-series-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: multiFrameDICOMInstance.series, + isErr: false, + }, + { + name: "invoke my-auth-search-dicom-series-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-search-dicom-series-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-search-dicom-series-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-search-dicom-series-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-search-dicom-series-tool with invalid storeID", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-series-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"invalid-store"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-search-dicom-series-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-search-dicom-series-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: singleFrameDICOMInstance.series, + isErr: false, + }, + { + name: "invoke my-client-auth-search-dicom-series-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-search-dicom-series-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-search-dicom-series-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-search-dicom-series-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-search-dicom-series-tool with study date and referring physician name filters", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-series-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "StudyDate":"20170101-20171231", "ReferringPhysicianName":"Frederick^Bryant^^Ph.D."}`)), + want: multiFrameDICOMInstance.series, + isErr: false, + }, + { + name: "invoke my-search-dicom-series-tool with series instance uid", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-series-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "SeriesInstanceUID":"1.2.840.113619.2.176.3596.3364818.7819.1259708454.108"}`)), + want: singleFrameDICOMInstance.series, + isErr: false, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runSearchDICOMInstancesToolInvokeTest(t *testing.T, dicomStoreID string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + want string + isErr bool + }{ + { + name: "invoke my-search-dicom-instances-tool", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: singleFrameDICOMInstance.instance, + isErr: false, + }, + { + name: "invoke my-auth-search-dicom-instances-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: multiFrameDICOMInstance.instance, + isErr: false, + }, + { + name: "invoke my-auth-search-dicom-instances-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: multiFrameDICOMInstance.instance, + isErr: false, + }, + { + name: "invoke my-auth-search-dicom-instances-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-search-dicom-instances-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-search-dicom-instances-tool with invalid storeID", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"invalid-store"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-search-dicom-instances-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + want: singleFrameDICOMInstance.instance, + isErr: false, + }, + { + name: "invoke my-client-auth-search-dicom-instances-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-search-dicom-instances-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `"}`)), + isErr: true, + }, + { + name: "invoke my-search-dicom-instances-tool with modality filter", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "modality":"SM"}`)), + want: multiFrameDICOMInstance.instance, + isErr: false, + }, + { + name: "invoke my-search-dicom-instances-tool with include attribute", + api: "http://127.0.0.1:5000/api/tool/my-search-dicom-instances-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "includefield":["52009230"]}`)), + want: `"52009230"`, + isErr: false, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + got, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } else if !strings.Contains(got, tc.want) { + t.Errorf("expected result to contain %q but got %q", tc.want, got) + } + }) + } +} + +func runRetrieveRenderedDICOMInstanceToolInvokeTest(t *testing.T, dicomStoreID string) { + idToken, err := tests.GetGoogleIdToken(tests.ClientId) + if err != nil { + t.Fatalf("error getting Google ID token: %s", err) + } + + accessToken, err := sources.GetIAMAccessToken(t.Context()) + if err != nil { + t.Fatalf("error getting access token from ADC: %s", err) + } + accessToken = "Bearer " + accessToken + + invokeTcs := []struct { + name string + api string + requestHeader map[string]string + requestBody io.Reader + isErr bool + }{ + { + name: "invoke my-retrieve-rendered-dicom-instance-tool", + api: "http://127.0.0.1:5000/api/tool/my-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: false, + }, + { + name: "invoke my-auth-retrieve-rendered-dicom-instance-tool with auth", + api: "http://127.0.0.1:5000/api/tool/my-auth-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: false, + }, + { + name: "invoke my-auth-retrieve-rendered-dicom-instance-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: false, + }, + { + name: "invoke my-auth-retrieve-rendered-dicom-instance-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: true, + }, + { + name: "invoke my-auth-retrieve-rendered-dicom-instance-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-auth-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{"Authorization": "invalid-token"}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: true, + }, + { + name: "invoke my-retrieve-rendered-dicom-instance-tool with invalid storeID", + api: "http://127.0.0.1:5000/api/tool/my-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"invalid-store", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-retrieve-rendered-dicom-instance-tool with client auth", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{"Authorization": accessToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: false, + }, + { + name: "invoke my-client-auth-retrieve-rendered-dicom-instance-tool without auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: true, + }, + { + name: "invoke my-client-auth-retrieve-rendered-dicom-instance-tool with invalid auth token", + api: "http://127.0.0.1:5000/api/tool/my-client-auth-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{"my-google-auth_token": idToken}, + requestBody: bytes.NewBuffer([]byte(`{"storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: true, + }, + { + name: "invoke my-retrieve-rendered-dicom-instance-tool second frame on single-frame instance", + api: "http://127.0.0.1:5000/api/tool/my-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"FrameNumber": 2, "storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + singleFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + singleFrameDICOMInstance.series + `", "SOPInstanceUID":"` + singleFrameDICOMInstance.instance + `"}`)), + isErr: true, + }, + { + name: "invoke my-retrieve-rendered-dicom-instance-tool second frame on multi-frame instance", + api: "http://127.0.0.1:5000/api/tool/my-retrieve-rendered-dicom-instance-tool/invoke", + requestHeader: map[string]string{}, + requestBody: bytes.NewBuffer([]byte(`{"FrameNumber": 2, "storeID":"` + dicomStoreID + `", "StudyInstanceUID":"` + multiFrameDICOMInstance.study + `", "SeriesInstanceUID":"` + multiFrameDICOMInstance.series + `", "SOPInstanceUID":"` + multiFrameDICOMInstance.instance + `"}`)), + isErr: false, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + _, status := runTest(t, tc.api, tc.requestHeader, tc.requestBody) + if tc.isErr { + if status == http.StatusOK { + t.Errorf("expected error but got success") + } + return + } + if status != http.StatusOK { + t.Errorf("expected status OK but got %d", status) + } + }) + } +}