diff --git a/docs/en/resources/sources/healthcare.md b/docs/en/resources/sources/healthcare.md new file mode 100644 index 000000000000..feb8491595e9 --- /dev/null +++ b/docs/en/resources/sources/healthcare.md @@ -0,0 +1,117 @@ +--- +title: "Cloud Healthcare API" +linkTitle: "Healthcare" +type: docs +weight: 1 +description: > + The Cloud Healthcare API provides a managed solution for storing and + accessing healthcare data in Google Cloud, providing a critical bridge + between existing care systems and applications hosted on Google Cloud. +--- + +## About + +The [Cloud Healthcare API][healthcare-docs] provides a managed solution +for storing and accessing healthcare data in Google Cloud, providing a +critical bridge between existing care systems and applications hosted on +Google Cloud. It supports healthcare data standards such as HL7® FHIR®, +HL7® v2, and DICOM®. It provides a fully managed, highly scalable, +enterprise-grade development environment for building clinical and analytics +solutions securely on Google Cloud. + +A dataset is a container in your Google Cloud project that holds modality-specific +healthcare data. Datasets contain other data stores, such as FHIR stores and DICOM +stores, which in turn hold their own types of healthcare data. + +A single dataset can contain one or many data stores, and those stores can all service +the same modality or different modalities as application needs dictate. Using multiple +stores in the same dataset might be appropriate in various situations. + +If you are new to the Healthcare API, you can try to +[create and view datasets and stores using curl][healthcare-quickstart-curl]. + +[healthcare-docs]: https://cloud.google.com/healthcare/docs +[healthcare-quickstart-curl]: + https://cloud.google.com/healthcare-api/docs/store-healthcare-data-rest + +## Requirements + +### IAM Permissions + +The Healthcare API uses [Identity and Access Management (IAM)][iam-overview] to control +user and group access to Healthcare resources like projects, datasets, and stores. + +### Authentication via Application Default Credentials (ADC) + +By **default**, Toolbox will use your [Application Default Credentials +(ADC)][adc] to authorize and authenticate when interacting with the +[Healthcare API][healthcare-docs]. + +When using this method, you need to ensure the IAM identity associated with your +ADC (such as a service account) has the correct permissions for the queries you +intend to run. Common roles include `roles/healthcare.fhirResourceReader` (which includes +permissions to read and search for FHIR resources) or `roles/healthcare.dicomViewer` (for +retrieving DICOM images). +Follow this [guide][set-adc] to set up your ADC. + +### Authentication via User's OAuth Access Token + +If the `useClientOAuth` parameter is set to `true`, Toolbox will instead use the +OAuth access token for authentication. This token is parsed from the +`Authorization` header passed in with the tool invocation request. This method +allows Toolbox to make queries to the [Healthcare API][healthcare-docs] on behalf of the +client or the end-user. + +When using this on-behalf-of authentication, you must ensure that the +identity used has been granted the correct IAM permissions. + +[iam-overview]: +[adc]: +[set-adc]: + +## Example + +Initialize a Healthcare source that uses ADC: + +```yaml +sources: + my-healthcare-source: + kind: "healthcare" + project: "my-project-id" + region: "us-central1" + dataset: "my-healthcare-dataset-id" + # allowedFhirStores: # Optional: Restricts tool access to a specific list of FHIR store IDs. + # - "my_fhir_store_1" + # allowedDicomStores: # Optional: Restricts tool access to a specific list of DICOM store IDs. + # - "my_dicom_store_1" + # - "my_dicom_store_2" +``` + +Initialize a Healthcare source that uses the client's access token: + +```yaml +sources: + my-healthcare-client-auth-source: + kind: "healthcare" + project: "my-project-id" + region: "us-central1" + dataset: "my-healthcare-dataset-id" + useClientOAuth: true + # allowedFhirStores: # Optional: Restricts tool access to a specific list of FHIR store IDs. + # - "my_fhir_store_1" + # allowedDicomStores: # Optional: Restricts tool access to a specific list of DICOM store IDs. + # - "my_dicom_store_1" + # - "my_dicom_store_2" +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|--------------------|:--------:|:------------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| kind | string | true | Must be "healthcare". | +| project | string | true | ID of the GCP project that the dataset lives in. | +| region | string | true | Specifies the region (e.g., 'us', 'asia-northeast1') of the healthcare dataset. [Learn More](https://cloud.google.com/healthcare-api/docs/regions) | +| dataset | string | true | ID of the healthcare dataset. | +| allowedFhirStores | []string | false | An optional list of FHIR store IDs that tools using this source are allowed to access. If provided, any tool operation attempting to access a store not in this list will be rejected. If a single store is provided, it will be treated as the default for prebuilt tools. | +| allowedDicomStores | []string | false | An optional list of DICOM store IDs that tools using this source are allowed to access. If provided, any tool operation attempting to access a store not in this list will be rejected. If a single store is provided, it will be treated as the default for prebuilt tools. | +| useClientOAuth | bool | false | If true, forwards the client's OAuth access token from the "Authorization" header to downstream queries. | diff --git a/internal/sources/healthcare/healthcare.go b/internal/sources/healthcare/healthcare.go new file mode 100644 index 000000000000..8684a60ea951 --- /dev/null +++ b/internal/sources/healthcare/healthcare.go @@ -0,0 +1,261 @@ +// 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 healthcare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/util" + "go.opentelemetry.io/otel/trace" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/googleapi" + "google.golang.org/api/healthcare/v1" + "google.golang.org/api/option" +) + +const SourceKind string = "healthcare" + +// validate interface +var _ sources.SourceConfig = Config{} + +type HealthcareServiceCreator func(tokenString string) (*healthcare.Service, error) + +func init() { + if !sources.Register(SourceKind, newConfig) { + panic(fmt.Sprintf("source kind %q already registered", SourceKind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (sources.SourceConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + // Healthcare configs + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Project string `yaml:"project" validate:"required"` + Region string `yaml:"region" validate:"required"` + Dataset string `yaml:"dataset" validate:"required"` + AllowedFHIRStores []string `yaml:"allowedFhirStores"` + AllowedDICOMStores []string `yaml:"allowedDicomStores"` + UseClientOAuth bool `yaml:"useClientOAuth"` +} + +func (c Config) SourceConfigKind() string { + return SourceKind +} + +func (c Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.Source, error) { + var service *healthcare.Service + var serviceCreator HealthcareServiceCreator + var tokenSource oauth2.TokenSource + + svc, tok, err := initHealthcareConnection(ctx, tracer, c.Name) + if err != nil { + return nil, fmt.Errorf("error creating service from ADC: %w", err) + } + if c.UseClientOAuth { + serviceCreator, err = newHealthcareServiceCreator(ctx, tracer, c.Name) + if err != nil { + return nil, fmt.Errorf("error constructing service creator: %w", err) + } + } else { + service = svc + tokenSource = tok + } + + dsName := fmt.Sprintf("projects/%s/locations/%s/datasets/%s", c.Project, c.Region, c.Dataset) + if _, err = svc.Projects.Locations.Datasets.FhirStores.Get(dsName).Do(); err != nil { + if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusNotFound { + return nil, fmt.Errorf("dataset '%s' not found", dsName) + } + return nil, fmt.Errorf("failed to verify existence of dataset '%s': %w", dsName, err) + } + + allowedFHIRStores := make(map[string]struct{}) + for _, store := range c.AllowedFHIRStores { + name := fmt.Sprintf("%s/fhirStores/%s", dsName, store) + _, err := svc.Projects.Locations.Datasets.FhirStores.Get(name).Do() + if err != nil { + if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusNotFound { + return nil, fmt.Errorf("allowedFhirStore '%s' not found in dataset '%s'", store, dsName) + } + return nil, fmt.Errorf("failed to verify allowedFhirStore '%s' in datasest '%s': %w", store, dsName, err) + } + allowedFHIRStores[store] = struct{}{} + } + allowedDICOMStores := make(map[string]struct{}) + for _, store := range c.AllowedDICOMStores { + name := fmt.Sprintf("%s/dicomStores/%s", dsName, store) + _, err := svc.Projects.Locations.Datasets.DicomStores.Get(name).Do() + if err != nil { + if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusNotFound { + return nil, fmt.Errorf("allowedDicomStore '%s' not found in dataset '%s'", store, dsName) + } + return nil, fmt.Errorf("failed to verify allowedDicomFhirStore '%s' in datasest '%s': %w", store, dsName, err) + } + allowedDICOMStores[store] = struct{}{} + } + s := &Source{ + name: c.Name, + kind: SourceKind, + project: c.Project, + region: c.Region, + dataset: c.Dataset, + service: service, + serviceCreator: serviceCreator, + tokenSource: tokenSource, + allowedFHIRStores: allowedFHIRStores, + allowedDICOMStores: allowedDICOMStores, + useClientOAuth: c.UseClientOAuth, + } + return s, nil +} + +func newHealthcareServiceCreator(ctx context.Context, tracer trace.Tracer, name string) (func(string) (*healthcare.Service, error), error) { + userAgent, err := util.UserAgentFromContext(ctx) + if err != nil { + return nil, err + } + return func(tokenString string) (*healthcare.Service, error) { + return initHealthcareConnectionWithOAuthToken(ctx, tracer, name, userAgent, tokenString) + }, nil +} + +func initHealthcareConnectionWithOAuthToken(ctx context.Context, tracer trace.Tracer, name string, userAgent string, tokenString string) (*healthcare.Service, error) { + ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceKind, name) + defer span.End() + // Construct token source + token := &oauth2.Token{ + AccessToken: string(tokenString), + } + ts := oauth2.StaticTokenSource(token) + + // Initialize the Healthcare service with tokenSource + service, err := healthcare.NewService(ctx, option.WithUserAgent(userAgent), option.WithTokenSource(ts)) + if err != nil { + return nil, fmt.Errorf("failed to create Healthcare service: %w", err) + } + return service, nil +} + +func initHealthcareConnection(ctx context.Context, tracer trace.Tracer, name string) (*healthcare.Service, oauth2.TokenSource, error) { + ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceKind, name) + defer span.End() + + cred, err := google.FindDefaultCredentials(ctx, healthcare.CloudHealthcareScope) + if err != nil { + return nil, nil, fmt.Errorf("failed to find default Google Cloud credentials with scope %q: %w", healthcare.CloudHealthcareScope, err) + } + + userAgent, err := util.UserAgentFromContext(ctx) + if err != nil { + return nil, nil, err + } + + service, err := healthcare.NewService(ctx, option.WithUserAgent(userAgent), option.WithCredentials(cred)) + if err != nil { + return nil, nil, fmt.Errorf("failed to create Healthcare service: %w", err) + } + return service, cred.TokenSource, nil +} + +var _ sources.Source = &Source{} + +type Source struct { + name string `yaml:"name"` + kind string `yaml:"kind"` + project string + region string + dataset string + service *healthcare.Service + serviceCreator HealthcareServiceCreator + tokenSource oauth2.TokenSource + allowedFHIRStores map[string]struct{} + allowedDICOMStores map[string]struct{} + useClientOAuth bool +} + +func (s *Source) SourceKind() string { + return SourceKind +} + +func (s *Source) Project() string { + return s.project +} + +func (s *Source) Region() string { + return s.region +} + +func (s *Source) DatasetID() string { + return s.dataset +} + +func (s *Source) Service() *healthcare.Service { + return s.service +} + +func (s *Source) ServiceCreator() HealthcareServiceCreator { + return s.serviceCreator +} + +func (s *Source) TokenSource() oauth2.TokenSource { + return s.tokenSource +} + +func (s *Source) AllowedFHIRStores() map[string]struct{} { + if len(s.allowedFHIRStores) == 0 { + return nil + } + return s.allowedFHIRStores +} + +func (s *Source) AllowedDICOMStores() map[string]struct{} { + if len(s.allowedDICOMStores) == 0 { + return nil + } + return s.allowedDICOMStores +} + +func (s *Source) IsFHIRStoreAllowed(storeID string) bool { + if len(s.allowedFHIRStores) == 0 { + return true + } + _, ok := s.allowedFHIRStores[storeID] + return ok +} + +func (s *Source) IsDICOMStoreAllowed(storeID string) bool { + if len(s.allowedDICOMStores) == 0 { + return true + } + _, ok := s.allowedDICOMStores[storeID] + return ok +} + +func (s *Source) UseClientAuthorization() bool { + return s.useClientOAuth +} diff --git a/internal/sources/healthcare/healthcare_test.go b/internal/sources/healthcare/healthcare_test.go new file mode 100644 index 000000000000..8e0e84b8fe05 --- /dev/null +++ b/internal/sources/healthcare/healthcare_test.go @@ -0,0 +1,168 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package healthcare_test + +import ( + "testing" + + "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources/healthcare" + "github.com/googleapis/genai-toolbox/internal/testutils" +) + +func TestParseFromYamlHealthcare(t *testing.T) { + tcs := []struct { + desc string + in string + want server.SourceConfigs + }{ + { + desc: "basic example", + in: ` + sources: + my-instance: + kind: healthcare + project: my-project + region: us-central1 + dataset: my-dataset + `, + want: server.SourceConfigs{ + "my-instance": healthcare.Config{ + Name: "my-instance", + Kind: healthcare.SourceKind, + Project: "my-project", + Region: "us-central1", + Dataset: "my-dataset", + UseClientOAuth: false, + }, + }, + }, + { + desc: "use client auth example", + in: ` + sources: + my-instance: + kind: healthcare + project: my-project + region: us + dataset: my-dataset + useClientOAuth: true + `, + want: server.SourceConfigs{ + "my-instance": healthcare.Config{ + Name: "my-instance", + Kind: healthcare.SourceKind, + Project: "my-project", + Region: "us", + Dataset: "my-dataset", + UseClientOAuth: true, + }, + }, + }, + { + desc: "with allowed stores example", + in: ` + sources: + my-instance: + kind: healthcare + project: my-project + region: us + dataset: my-dataset + allowedFhirStores: + - my-fhir-store + allowedDicomStores: + - my-dicom-store1 + - my-dicom-store2 + `, + want: server.SourceConfigs{ + "my-instance": healthcare.Config{ + Name: "my-instance", + Kind: healthcare.SourceKind, + Project: "my-project", + Region: "us", + Dataset: "my-dataset", + AllowedFHIRStores: []string{"my-fhir-store"}, + AllowedDICOMStores: []string{"my-dicom-store1", "my-dicom-store2"}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Sources server.SourceConfigs `yaml:"sources"` + }{} + // Parse contents + err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if !cmp.Equal(tc.want, got.Sources) { + t.Fatalf("incorrect parse: want %v, got %v", tc.want, got.Sources) + } + }) + } +} + +func TestFailParseFromYaml(t *testing.T) { + tcs := []struct { + desc string + in string + err string + }{ + { + desc: "extra field", + in: ` + sources: + my-instance: + kind: healthcare + project: my-project + region: us-central1 + dataset: my-dataset + foo: bar + `, + err: "unable to parse source \"my-instance\" as \"healthcare\": [2:1] unknown field \"foo\"\n 1 | dataset: my-dataset\n> 2 | foo: bar\n ^\n 3 | kind: healthcare\n 4 | project: my-project\n 5 | region: us-central1", + }, + { + desc: "missing required field", + in: ` + sources: + my-instance: + kind: healthcare + project: my-project + region: us-central1 + `, + err: `unable to parse source "my-instance" as "healthcare": Key: 'Config.Dataset' Error:Field validation for 'Dataset' failed on the 'required' tag`, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Sources server.SourceConfigs `yaml:"sources"` + }{} + // Parse contents + err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got) + if err == nil { + t.Fatalf("expect parsing to fail") + } + errStr := err.Error() + if errStr != tc.err { + t.Fatalf("unexpected error: got %q, want %q", errStr, tc.err) + } + }) + } +}