mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-01-09 15:38:08 -05:00
feat(tools/looker): add looker-make-look tool to create Looks (#1099)
This commit is contained in:
@@ -68,6 +68,7 @@ import (
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetmeasures"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetmodels"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetparameters"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookermakelook"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquery"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquerysql"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerqueryurl"
|
||||
|
||||
@@ -1290,7 +1290,7 @@ func TestPrebuiltTools(t *testing.T) {
|
||||
wantToolset: server.ToolsetConfigs{
|
||||
"looker-tools": tools.ToolsetConfig{
|
||||
Name: "looker-tools",
|
||||
ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look"},
|
||||
ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
65
docs/en/resources/tools/looker/looker_make_look.md
Normal file
65
docs/en/resources/tools/looker/looker_make_look.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: "looker-make-look"
|
||||
type: docs
|
||||
weight: 1
|
||||
description: >
|
||||
"looker-make-look" generates a Looker look in the users personal folder in
|
||||
Looker
|
||||
aliases:
|
||||
- /resources/tools/looker-make-look
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
The `looker-make-look` creates a saved Look in the user's
|
||||
Looker personal folder.
|
||||
|
||||
It's compatible with the following sources:
|
||||
|
||||
- [looker](../../sources/looker.md)
|
||||
|
||||
`looker-make-look` takes eight parameters:
|
||||
|
||||
1. the `model`
|
||||
2. the `explore`
|
||||
3. the `fields` list
|
||||
4. an optional set of `filters`
|
||||
5. an optional set of `pivots`
|
||||
6. an optional set of `sorts`
|
||||
7. an optional `limit`
|
||||
8. an optional `tz`
|
||||
9. an optional `vis_config`
|
||||
10. the `title`
|
||||
11. an optional `description`
|
||||
|
||||
## Example
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
make_look:
|
||||
kind: looker-make-look
|
||||
source: looker-source
|
||||
description: |
|
||||
make_look Tool
|
||||
|
||||
This tool creates a new look in Looker, using the query
|
||||
parameters and the vis_config specified.
|
||||
|
||||
Most of the parameters are the same as the query_url
|
||||
tool. In addition, there is a title and a description
|
||||
that must be provided.
|
||||
|
||||
The newly created look will be created in the user's
|
||||
personal folder in looker. The look name must be unique.
|
||||
|
||||
The result is a json document with a link to the newly
|
||||
created look.
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
| **field** | **type** | **required** | **description** |
|
||||
|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------|
|
||||
| kind | string | true | Must be "looker-make-look" |
|
||||
| source | string | true | Name of the source the SQL should execute on. |
|
||||
| description | string | true | Description of the tool that is passed to the LLM. |
|
||||
@@ -464,6 +464,25 @@ tools:
|
||||
the data in a JSON structure. It accepts the look_id as the
|
||||
parameter.
|
||||
|
||||
make_look:
|
||||
kind: looker-make-look
|
||||
source: looker-source
|
||||
description: |
|
||||
make_look Tool
|
||||
|
||||
This tool creates a new look in Looker, using the query
|
||||
parameters and the vis_config specified.
|
||||
|
||||
Most of the parameters are the same as the query_url
|
||||
tool. In addition, there is a title and a description
|
||||
that must be provided.
|
||||
|
||||
The newly created look will be created in the user's
|
||||
personal folder in looker. The look name must be unique.
|
||||
|
||||
The result is a json document with a link to the newly
|
||||
created look.
|
||||
|
||||
toolsets:
|
||||
looker-tools:
|
||||
- get_models
|
||||
@@ -477,3 +496,4 @@ toolsets:
|
||||
- query_url
|
||||
- get_looks
|
||||
- run_look
|
||||
- make_look
|
||||
|
||||
219
internal/tools/looker/lookermakelook/lookermakelook.go
Normal file
219
internal/tools/looker/lookermakelook/lookermakelook.go
Normal file
@@ -0,0 +1,219 @@
|
||||
// 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 lookermakelook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/googleapis/genai-toolbox/internal/sources"
|
||||
lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon"
|
||||
"github.com/googleapis/genai-toolbox/internal/util"
|
||||
|
||||
"github.com/looker-open-source/sdk-codegen/go/rtl"
|
||||
v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4"
|
||||
)
|
||||
|
||||
const kind string = "looker-make-look"
|
||||
|
||||
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 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.(*lookersrc.Source)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind)
|
||||
}
|
||||
|
||||
parameters := lookercommon.GetQueryParameters()
|
||||
|
||||
titleParameter := tools.NewStringParameter("title", "The title of the Look")
|
||||
parameters = append(parameters, titleParameter)
|
||||
descParameter := tools.NewStringParameterWithDefault("description", "", "The description of the Look")
|
||||
parameters = append(parameters, descParameter)
|
||||
vizParameter := tools.NewMapParameterWithDefault("vis_config",
|
||||
map[string]any{},
|
||||
"The visualization config for the query",
|
||||
"",
|
||||
)
|
||||
parameters = append(parameters, vizParameter)
|
||||
|
||||
mcpManifest := tools.McpManifest{
|
||||
Name: cfg.Name,
|
||||
Description: cfg.Description,
|
||||
InputSchema: parameters.McpManifest(),
|
||||
}
|
||||
|
||||
// finish tool setup
|
||||
return Tool{
|
||||
Name: cfg.Name,
|
||||
Kind: kind,
|
||||
Parameters: parameters,
|
||||
AuthRequired: cfg.AuthRequired,
|
||||
Client: s.Client,
|
||||
ApiSettings: s.ApiSettings,
|
||||
manifest: tools.Manifest{
|
||||
Description: cfg.Description,
|
||||
Parameters: parameters.Manifest(),
|
||||
AuthRequired: cfg.AuthRequired,
|
||||
},
|
||||
mcpManifest: mcpManifest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validate interface
|
||||
var _ tools.Tool = Tool{}
|
||||
|
||||
type Tool struct {
|
||||
Name string `yaml:"name"`
|
||||
Kind string `yaml:"kind"`
|
||||
Client *v4.LookerSDK
|
||||
ApiSettings *rtl.ApiSettings
|
||||
AuthRequired []string `yaml:"authRequired"`
|
||||
Parameters tools.Parameters `yaml:"parameters"`
|
||||
manifest tools.Manifest
|
||||
mcpManifest tools.McpManifest
|
||||
}
|
||||
|
||||
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error) {
|
||||
logger, err := util.LoggerFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get logger from ctx: %s", err)
|
||||
}
|
||||
logger.DebugContext(ctx, "params = ", params)
|
||||
wq, err := lookercommon.ProcessQueryArgs(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error building query request: %w", err)
|
||||
}
|
||||
|
||||
mrespFields := "id,personal_folder_id"
|
||||
mresp, err := t.Client.Me(mrespFields, t.ApiSettings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making me request: %s", err)
|
||||
}
|
||||
|
||||
paramsMap := params.AsMap()
|
||||
title := paramsMap["title"].(string)
|
||||
description := paramsMap["description"].(string)
|
||||
|
||||
looks, err := t.Client.FolderLooks(*mresp.PersonalFolderId, "title", t.ApiSettings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting existing looks in folder: %s", err)
|
||||
}
|
||||
|
||||
lookTitles := []string{}
|
||||
for _, look := range looks {
|
||||
lookTitles = append(lookTitles, *look.Title)
|
||||
}
|
||||
if slices.Contains(lookTitles, title) {
|
||||
lt, _ := json.Marshal(lookTitles)
|
||||
return nil, fmt.Errorf("title %s already used in user's folder. Currently used titles are %v. Make the call again with a unique title", title, string(lt))
|
||||
}
|
||||
|
||||
visConfig := paramsMap["vis_config"].(map[string]any)
|
||||
wq.VisConfig = &visConfig
|
||||
|
||||
qrespFields := "id"
|
||||
qresp, err := t.Client.CreateQuery(*wq, qrespFields, t.ApiSettings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making create query request: %s", err)
|
||||
}
|
||||
|
||||
wlwq := v4.WriteLookWithQuery{
|
||||
Title: &title,
|
||||
UserId: mresp.Id,
|
||||
Description: &description,
|
||||
QueryId: qresp.Id,
|
||||
FolderId: mresp.PersonalFolderId,
|
||||
}
|
||||
resp, err := t.Client.CreateLook(wlwq, "", t.ApiSettings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making create look request: %s", err)
|
||||
}
|
||||
logger.DebugContext(ctx, "resp = %v", resp)
|
||||
|
||||
setting, err := t.Client.GetSetting("host_url", t.ApiSettings)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "error getting settings: %s", err)
|
||||
}
|
||||
|
||||
data := make(map[string]any)
|
||||
if resp.Id != nil {
|
||||
data["id"] = *resp.Id
|
||||
}
|
||||
if resp.ShortUrl != nil {
|
||||
if setting.HostUrl != nil {
|
||||
data["short_url"] = *setting.HostUrl + *resp.ShortUrl
|
||||
} else {
|
||||
data["short_url"] = *resp.ShortUrl
|
||||
}
|
||||
}
|
||||
logger.DebugContext(ctx, "data = %v", data)
|
||||
|
||||
return data, 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)
|
||||
}
|
||||
116
internal/tools/looker/lookermakelook/lookermakelook_test.go
Normal file
116
internal/tools/looker/lookermakelook/lookermakelook_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// 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 lookermakelook_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"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"
|
||||
lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookermakelook"
|
||||
)
|
||||
|
||||
func TestParseFromYamlLookerQuery(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: looker-make-look
|
||||
source: my-instance
|
||||
description: some description
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"example_tool": lkr.Config{
|
||||
Name: "example_tool",
|
||||
Kind: "looker-make-look",
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestFailParseFromYamlLookerQuery(t *testing.T) {
|
||||
ctx, err := testutils.ContextWithNewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
tcs := []struct {
|
||||
desc string
|
||||
in string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
desc: "Invalid method",
|
||||
in: `
|
||||
tools:
|
||||
example_tool:
|
||||
kind: looker-make-look
|
||||
source: my-instance
|
||||
method: GOT
|
||||
description: some description
|
||||
`,
|
||||
err: "unable to parse tool \"example_tool\" as kind \"looker-make-look\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-make-look\n> 4 | method: GOT\n ^\n 5 | source: my-instance",
|
||||
},
|
||||
}
|
||||
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("expect parsing to fail")
|
||||
}
|
||||
errStr := err.Error()
|
||||
if !strings.Contains(errStr, tc.err) {
|
||||
t.Fatalf("unexpected error string: got %q, want substring %q", errStr, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user