diff --git a/.ci/integration.cloudbuild.yaml b/.ci/integration.cloudbuild.yaml index e99b147c8130..06b72bf2e921 100644 --- a/.ci/integration.cloudbuild.yaml +++ b/.ci/integration.cloudbuild.yaml @@ -224,6 +224,7 @@ steps: - "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" @@ -881,6 +882,7 @@ substitutions: _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 5c6822111559..8ffe1ad8eb47 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -99,12 +99,18 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/fhirfetchpage" _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/fhirpatienteverything" _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/fhirpatientsearch" + _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/getdicomstore" + _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/getdicomstoremetrics" _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/getfhirresource" _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/getfhirstore" _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/getfhirstoremetrics" _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/gethealthcaredataset" _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/listdicomstores" _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/listfhirstores" + _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/retrieverendereddicominstance" + _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/searchdicominstances" + _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/searchdicomseries" + _ "github.com/googleapis/genai-toolbox/internal/tools/healthcare/searchdicomstudies" _ "github.com/googleapis/genai-toolbox/internal/tools/http" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookeradddashboardelement" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerconversationalanalytics" diff --git a/docs/en/resources/sources/healthcare.md b/docs/en/resources/sources/healthcare.md index 2c4dedc4e03a..3460a53d47d5 100644 --- a/docs/en/resources/sources/healthcare.md +++ b/docs/en/resources/sources/healthcare.md @@ -63,6 +63,24 @@ If you are new to the Healthcare API, you can try to - [`fhir-fetch-page`](../tools/healthcare/fhir-fetch-page.md) Fetches a page of FHIR resources from a given URL. +- [`get-dicom-store`](../tools/healthcare/get-dicom-store.md) + Retrieves information about a DICOM store. + +- [`get-dicom-store-metrics`](../tools/healthcare/get-dicom-store-metrics.md) + Retrieves metrics for a DICOM store. + +- [`search-dicom-studies`](../tools/healthcare/search-dicom-studies.md) + Searches for DICOM studies in a DICOM store. + +- [`search-dicom-series`](../tools/healthcare/search-dicom-series.md) + Searches for DICOM series in a DICOM store. + +- [`search-dicom-instances`](../tools/healthcare/search-dicom-instances.md) + Searches for DICOM instances in a DICOM store. + +- [`retrieve-rendered-dicom-instance`](../tools/healthcare/retrieve-rendered-dicom-instance.md) + Retrieves a rendered DICOM instance from a DICOM store. + ## Requirements ### IAM Permissions diff --git a/docs/en/resources/tools/healthcare/get-dicom-store-metrics.md b/docs/en/resources/tools/healthcare/get-dicom-store-metrics.md new file mode 100644 index 000000000000..d73b8dae29c4 --- /dev/null +++ b/docs/en/resources/tools/healthcare/get-dicom-store-metrics.md @@ -0,0 +1,45 @@ +--- +title: "get-dicom-store-metrics" +linkTitle: "get-dicom-store-metrics" +type: docs +weight: 1 +description: > + A "get-dicom-store-metrics" tool retrieves metrics for a DICOM store. +aliases: +- /resources/tools/healthcare-get-dicom-store-metrics +--- + +## About + +A `get-dicom-store-metrics` tool retrieves metrics for a DICOM store. It's +compatible with the following sources: + +- [healthcare](../../sources/healthcare.md) + +`get-dicom-store-metrics` returns the metrics of a DICOM store. + +## Example + +```yaml +tools: + get_dicom_store_metrics: + kind: 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 "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/healthcare/get-dicom-store.md b/docs/en/resources/tools/healthcare/get-dicom-store.md new file mode 100644 index 000000000000..eaa3c26f2584 --- /dev/null +++ b/docs/en/resources/tools/healthcare/get-dicom-store.md @@ -0,0 +1,45 @@ +--- +title: "get-dicom-store" +linkTitle: "get-dicom-store" +type: docs +weight: 1 +description: > + A "get-dicom-store" tool retrieves information about a DICOM store. +aliases: +- /resources/tools/healthcare-get-dicom-store +--- + +## About + +A `get-dicom-store` tool retrieves information about a DICOM store. It's +compatible with the following sources: + +- [healthcare](../../sources/healthcare.md) + +`get-dicom-store` returns the details of a DICOM store. + +## Example + +```yaml +tools: + get_dicom_store: + kind: 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 "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/healthcare/retrieve-rendered-dicom-instance.md b/docs/en/resources/tools/healthcare/retrieve-rendered-dicom-instance.md new file mode 100644 index 000000000000..7109ec5dcfec --- /dev/null +++ b/docs/en/resources/tools/healthcare/retrieve-rendered-dicom-instance.md @@ -0,0 +1,49 @@ +--- +title: "retrieve-rendered-dicom-instance" +linkTitle: "retrieve-rendered-dicom-instance" +type: docs +weight: 1 +description: > + A "retrieve-rendered-dicom-instance" tool retrieves a rendered DICOM instance from a DICOM store. +aliases: +- /resources/tools/healthcare-retrieve-rendered-dicom-instance +--- + +## About + +A `retrieve-rendered-dicom-instance` tool retrieves a rendered DICOM instance from a DICOM store. +It's compatible with the following sources: + +- [healthcare](../../sources/healthcare.md) + +`retrieve-rendered-dicom-instance` returns a base64 encoded string of the image in JPEG format. + +## Example + +```yaml +tools: + retrieve_rendered_dicom_instance: + kind: 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 "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/healthcare/search-dicom-instances.md b/docs/en/resources/tools/healthcare/search-dicom-instances.md new file mode 100644 index 000000000000..56bd9dbeaa2b --- /dev/null +++ b/docs/en/resources/tools/healthcare/search-dicom-instances.md @@ -0,0 +1,56 @@ +--- +title: "search-dicom-instances" +linkTitle: "search-dicom-instances" +type: docs +weight: 1 +description: > + A "search-dicom-instances" tool searches for DICOM instances in a DICOM store. +aliases: +- /resources/tools/healthcare-search-dicom-instances +--- + +## About + +A `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: + +- [healthcare](../../sources/healthcare.md) + +`search-dicom-instances` returns a list of DICOM instances that match the given criteria. + +## Example + +```yaml +tools: + search_dicom_instances: + kind: 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 "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/healthcare/search-dicom-series.md b/docs/en/resources/tools/healthcare/search-dicom-series.md new file mode 100644 index 000000000000..5ad47dd53649 --- /dev/null +++ b/docs/en/resources/tools/healthcare/search-dicom-series.md @@ -0,0 +1,55 @@ +--- +title: "search-dicom-series" +linkTitle: "search-dicom-series" +type: docs +weight: 1 +description: > + A "search-dicom-series" tool searches for DICOM series in a DICOM store. +aliases: +- /resources/tools/healthcare-search-dicom-series +--- + +## About + +A `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: + +- [healthcare](../../sources/healthcare.md) + +`search-dicom-series` returns a list of DICOM series that match the given criteria. + +## Example + +```yaml +tools: + search_dicom_series: + kind: 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 "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/healthcare/search-dicom-studies.md b/docs/en/resources/tools/healthcare/search-dicom-studies.md new file mode 100644 index 000000000000..874bcb31ecb3 --- /dev/null +++ b/docs/en/resources/tools/healthcare/search-dicom-studies.md @@ -0,0 +1,53 @@ +--- +title: "search-dicom-studies" +linkTitle: "search-dicom-studies" +type: docs +weight: 1 +description: > + A "search-dicom-studies" tool searches for DICOM studies in a DICOM store. +aliases: +- /resources/tools/healthcare-search-dicom-studies +--- + +## About + +A `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: + +- [healthcare](../../sources/healthcare.md) + +`search-dicom-studies` returns a list of DICOM studies that match the given criteria. + +## Example + +```yaml +tools: + search_dicom_studies: + kind: 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 "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/tools/healthcare/common/util.go b/internal/tools/healthcare/common/util.go index 1c1818a50ebc..481b5cd2f110 100644 --- a/internal/tools/healthcare/common/util.go +++ b/internal/tools/healthcare/common/util.go @@ -16,13 +16,24 @@ 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. @@ -44,3 +55,37 @@ func ValidateAndFetchStoreID(params tools.ParamValues, allowedStores map[string] } 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/internal/tools/healthcare/getdicomstore/getdicomstore.go b/internal/tools/healthcare/getdicomstore/getdicomstore.go new file mode 100644 index 000000000000..b0679fe29421 --- /dev/null +++ b/internal/tools/healthcare/getdicomstore/getdicomstore.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/healthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "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/healthcare/getdicomstore/getdicomstore_test.go b/internal/tools/healthcare/getdicomstore/getdicomstore_test.go new file mode 100644 index 000000000000..f4386e637aec --- /dev/null +++ b/internal/tools/healthcare/getdicomstore/getdicomstore_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" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/getdicomstore" +) + +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: get-dicom-store + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": getdicomstore.Config{ + Name: "example_tool", + Kind: "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/healthcare/getdicomstoremetrics/getdicomstoremetrics.go b/internal/tools/healthcare/getdicomstoremetrics/getdicomstoremetrics.go new file mode 100644 index 000000000000..83811d131049 --- /dev/null +++ b/internal/tools/healthcare/getdicomstoremetrics/getdicomstoremetrics.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/healthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "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/healthcare/getdicomstoremetrics/getdicomstoremetrics_test.go b/internal/tools/healthcare/getdicomstoremetrics/getdicomstoremetrics_test.go new file mode 100644 index 000000000000..87cbfc2ec45f --- /dev/null +++ b/internal/tools/healthcare/getdicomstoremetrics/getdicomstoremetrics_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" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/getdicomstoremetrics" +) + +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: get-dicom-store-metrics + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": getdicomstoremetrics.Config{ + Name: "example_tool", + Kind: "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/healthcare/retrieverendereddicominstance/retrieverendereddicominstance.go b/internal/tools/healthcare/retrieverendereddicominstance/retrieverendereddicominstance.go new file mode 100644 index 000000000000..411d2304dafd --- /dev/null +++ b/internal/tools/healthcare/retrieverendereddicominstance/retrieverendereddicominstance.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/healthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "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/healthcare/retrieverendereddicominstance/retrieverendereddicominstance_test.go b/internal/tools/healthcare/retrieverendereddicominstance/retrieverendereddicominstance_test.go new file mode 100644 index 000000000000..19c72f989340 --- /dev/null +++ b/internal/tools/healthcare/retrieverendereddicominstance/retrieverendereddicominstance_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" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/retrieverendereddicominstance" +) + +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: retrieve-rendered-dicom-instance + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": retrieverendereddicominstance.Config{ + Name: "example_tool", + Kind: "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/healthcare/searchdicominstances/searchdicominstances.go b/internal/tools/healthcare/searchdicominstances/searchdicominstances.go new file mode 100644 index 000000000000..b31b1a4a93eb --- /dev/null +++ b/internal/tools/healthcare/searchdicominstances/searchdicominstances.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/healthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/common" + "google.golang.org/api/googleapi" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "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/healthcare/searchdicominstances/searchdicominstances_test.go b/internal/tools/healthcare/searchdicominstances/searchdicominstances_test.go new file mode 100644 index 000000000000..1923a9b9d4d4 --- /dev/null +++ b/internal/tools/healthcare/searchdicominstances/searchdicominstances_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" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/searchdicominstances" +) + +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: search-dicom-instances + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": searchdicominstances.Config{ + Name: "example_tool", + Kind: "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/healthcare/searchdicomseries/searchdicomseries.go b/internal/tools/healthcare/searchdicomseries/searchdicomseries.go new file mode 100644 index 000000000000..b7b3a1a24f38 --- /dev/null +++ b/internal/tools/healthcare/searchdicomseries/searchdicomseries.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/healthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "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/healthcare/searchdicomseries/searchdicomseries_test.go b/internal/tools/healthcare/searchdicomseries/searchdicomseries_test.go new file mode 100644 index 000000000000..43ca0a5ca44f --- /dev/null +++ b/internal/tools/healthcare/searchdicomseries/searchdicomseries_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" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/searchdicomseries" +) + +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: search-dicom-series + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": searchdicomseries.Config{ + Name: "example_tool", + Kind: "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/healthcare/searchdicomstudies/searchdicomstudies.go b/internal/tools/healthcare/searchdicomstudies/searchdicomstudies.go new file mode 100644 index 000000000000..c2c9a3a65544 --- /dev/null +++ b/internal/tools/healthcare/searchdicomstudies/searchdicomstudies.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/healthcare" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/common" + "google.golang.org/api/healthcare/v1" +) + +const kind string = "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/healthcare/searchdicomstudies/searchdicomstudies_test.go b/internal/tools/healthcare/searchdicomstudies/searchdicomstudies_test.go new file mode 100644 index 000000000000..bdeef9c39634 --- /dev/null +++ b/internal/tools/healthcare/searchdicomstudies/searchdicomstudies_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" + "github.com/googleapis/genai-toolbox/internal/tools/healthcare/searchdicomstudies" +) + +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: search-dicom-studies + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": searchdicomstudies.Config{ + Name: "example_tool", + Kind: "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/http/http.go b/internal/tools/http/http.go index 0f4551688c00..9b9f1e868560 100644 --- a/internal/tools/http/http.go +++ b/internal/tools/http/http.go @@ -212,15 +212,15 @@ func getURL(baseURL, path string, pathParams, queryParams tools.Parameters, defa for _, p := range queryParams { v, ok := paramsMap[p.GetName()] if !ok || v == nil { - if !p.GetRequired(){ + if !p.GetRequired() { // If the param is not required AND - // Not provodid OR provided with a nil value + // Not provodid OR provided with a nil value // Omitted from the URL continue } v = "" - } - query.Add(p.GetName(), fmt.Sprintf("%v", v)) + } + query.Add(p.GetName(), fmt.Sprintf("%v", v)) } parsedURL.RawQuery = query.Encode() return parsedURL.String(), nil @@ -284,7 +284,7 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken return nil, err } if resp.StatusCode < 200 || resp.StatusCode > 299 { - return nil, fmt.Errorf("unexpected status code: %d, response body: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("unexpected status code: %d, response body: %s", resp.StatusCode, string(body)) } var data any diff --git a/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile.go b/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile.go index b182ff48f3da..bfd671b333d4 100644 --- a/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile.go +++ b/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile.go @@ -166,4 +166,4 @@ func (t Tool) Authorized(verifiedAuthServices []string) bool { func (t Tool) RequiresClientAuthorization() bool { return t.UseClientOAuth -} \ No newline at end of file +} diff --git a/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile.go b/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile.go index 4d20f0430862..58c3aeced718 100644 --- a/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile.go +++ b/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile.go @@ -156,4 +156,4 @@ func (t Tool) Authorized(verifiedAuthServices []string) bool { func (t Tool) RequiresClientAuthorization() bool { return t.UseClientOAuth -} \ No newline at end of file +} diff --git a/internal/tools/looker/lookerdevmode/lookerdevmode.go b/internal/tools/looker/lookerdevmode/lookerdevmode.go index 973f71967f4b..8fd7a0e04c9e 100644 --- a/internal/tools/looker/lookerdevmode/lookerdevmode.go +++ b/internal/tools/looker/lookerdevmode/lookerdevmode.go @@ -163,4 +163,4 @@ func (t Tool) Authorized(verifiedAuthServices []string) bool { func (t Tool) RequiresClientAuthorization() bool { return t.UseClientOAuth -} \ No newline at end of file +} diff --git a/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile.go b/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile.go index 32deff567c33..9b63e70980c8 100644 --- a/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile.go +++ b/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile.go @@ -166,4 +166,4 @@ func (t Tool) Authorized(verifiedAuthServices []string) bool { func (t Tool) RequiresClientAuthorization() bool { return t.UseClientOAuth -} \ No newline at end of file +} diff --git a/tests/bigquery/bigquery_integration_test.go b/tests/bigquery/bigquery_integration_test.go index 9fffda9d1e88..fd75a3b4473b 100644 --- a/tests/bigquery/bigquery_integration_test.go +++ b/tests/bigquery/bigquery_integration_test.go @@ -2474,7 +2474,7 @@ func runListDatasetIdsWithRestriction(t *testing.T, allowedDatasetName1, allowed testCases := []struct { name string wantStatusCode int - wantElements []string + wantElements []string }{ { name: "invoke list-dataset-ids with restriction", @@ -2499,7 +2499,7 @@ func runListDatasetIdsWithRestriction(t *testing.T, allowedDatasetName1, allowed if err := json.Unmarshal(bodyBytes, &respBody); err != nil { t.Fatalf("error parsing response body: %v", err) } - + gotJSON, ok := respBody["result"].(string) if !ok { t.Fatalf("unable to find 'result' as a string in response body: %s", string(bodyBytes)) diff --git a/tests/healthcare/healthcare_integration_test.go b/tests/healthcare/healthcare_integration_test.go index eaee92b533b9..553f5704e039 100644 --- a/tests/healthcare/healthcare_integration_test.go +++ b/tests/healthcare/healthcare_integration_test.go @@ -41,19 +41,43 @@ import ( ) var ( - healthcareSourceKind = "healthcare" - getDatasetToolKind = "get-healthcare-dataset" - listFHIRStoresToolKind = "list-fhir-stores" - listDICOMStoresToolKind = "list-dicom-stores" - getFHIRStoreToolKind = "get-fhir-store" - getFHIRStoreMetricsToolKind = "get-fhir-store-metrics" - getFHIRResourceToolKind = "get-fhir-resource" - fhirPatientSearchToolKind = "fhir-patient-search" - fhirPatientEverythingToolKind = "fhir-patient-everything" - fhirFetchPageToolKind = "fhir-fetch-page" - healthcareProject = os.Getenv("HEALTHCARE_PROJECT") - healthcareRegion = os.Getenv("HEALTHCARE_REGION") - healthcareDataset = os.Getenv("HEALTHCARE_DATASET") + healthcareSourceKind = "healthcare" + getDatasetToolKind = "get-healthcare-dataset" + listFHIRStoresToolKind = "list-fhir-stores" + listDICOMStoresToolKind = "list-dicom-stores" + getFHIRStoreToolKind = "get-fhir-store" + getFHIRStoreMetricsToolKind = "get-fhir-store-metrics" + getFHIRResourceToolKind = "get-fhir-resource" + fhirPatientSearchToolKind = "fhir-patient-search" + fhirPatientEverythingToolKind = "fhir-patient-everything" + fhirFetchPageToolKind = "fhir-fetch-page" + getDICOMStoreToolKind = "get-dicom-store" + getDICOMStoreMetricsToolKind = "get-dicom-store-metrics" + searchDICOMStudiesToolKind = "search-dicom-studies" + searchDICOMSeriesToolKind = "search-dicom-series" + searchDICOMInstancesToolKind = "search-dicom-instances" + retrieveRenderedDICOMInstanceToolKind = "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 { @@ -64,6 +88,8 @@ func getHealthcareVars(t *testing.T) map[string]any { 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, @@ -122,6 +148,13 @@ func TestHealthcareToolEndpoints(t *testing.T) { 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) { @@ -346,6 +379,36 @@ func getToolsConfig(sourceConfig map[string]any) map[string]any { "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", @@ -391,6 +454,36 @@ func getToolsConfig(sourceConfig map[string]any) map[string]any { "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", @@ -463,6 +556,54 @@ func getToolsConfig(sourceConfig map[string]any) map[string]any { "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{ @@ -1602,3 +1743,694 @@ func runListDICOMStoresWithRestriction(t *testing.T, allowedDICOMStore, disallow 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) + } + }) + } +} diff --git a/tests/http/http_integration_test.go b/tests/http/http_integration_test.go index 12cf7ad76414..faa239d2e686 100644 --- a/tests/http/http_integration_test.go +++ b/tests/http/http_integration_test.go @@ -79,22 +79,22 @@ func multiTool(w http.ResponseWriter, r *http.Request) { // handleQueryTest simply returns the raw query string it received so the test // can verify it's formatted correctly. func handleQueryTest(w http.ResponseWriter, r *http.Request) { - // expect GET method - if r.Method != http.MethodGet { - errorMessage := fmt.Sprintf("expected GET method but got: %s", string(r.Method)) - http.Error(w, errorMessage, http.StatusBadRequest) - return - } - - w.WriteHeader(http.StatusOK) - enc := json.NewEncoder(w) - enc.SetEscapeHTML(false) - - err := enc.Encode(r.URL.RawQuery) - if err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - return - } + // expect GET method + if r.Method != http.MethodGet { + errorMessage := fmt.Sprintf("expected GET method but got: %s", string(r.Method)) + http.Error(w, errorMessage, http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + + err := enc.Encode(r.URL.RawQuery) + if err != nil { + http.Error(w, "Failed to write response", http.StatusInternalServerError) + return + } } // handler function for the test server @@ -579,31 +579,26 @@ func getHTTPToolsConfig(sourceConfig map[string]any, toolKind string) map[string "method": "get", "path": "/{{.path}}?id=2", "description": "some description", - "headers": - map[string]string{ - "X-Custom-Header": "example", - }, - "pathParams": - []tools.Parameter{ - &tools.StringParameter{ - CommonParameter: tools.CommonParameter{Name: "path", Type: "string", Desc: "path param"}, - }, - }, - "queryParams": - []tools.Parameter{ - tools.NewIntParameter("id", "user ID"), tools.NewStringParameter("country", "country"), + "headers": map[string]string{ + "X-Custom-Header": "example", + }, + "pathParams": []tools.Parameter{ + &tools.StringParameter{ + CommonParameter: tools.CommonParameter{Name: "path", Type: "string", Desc: "path param"}, }, + }, + "queryParams": []tools.Parameter{ + tools.NewIntParameter("id", "user ID"), tools.NewStringParameter("country", "country"), + }, "requestBody": `{ "place": "zoo", "animals": {{json .animalArray }} } `, - "bodyParams": - []tools.Parameter{tools.NewArrayParameter("animalArray", "animals in the zoo", tools.NewStringParameter("animals", "desc"))}, - "headerParams": - []tools.Parameter{tools.NewStringParameter("X-Other-Header", "custom header")}, + "bodyParams": []tools.Parameter{tools.NewArrayParameter("animalArray", "animals in the zoo", tools.NewStringParameter("animals", "desc"))}, + "headerParams": []tools.Parameter{tools.NewStringParameter("X-Other-Header", "custom header")}, }, }, } return toolsFile -} \ No newline at end of file +}