From ba3b450dd2e5045f3cb4ec34303529d4123b268a Mon Sep 17 00:00:00 2001 From: Divyansh Vijayvergia Date: Fri, 5 Jun 2026 09:16:27 +0000 Subject: [PATCH 1/2] Add cmdio.RenderFiltered to strip fields from rendered output Adds new cmdio.RenderFiltered and cmdio.RenderIteratorFiltered entry points that mirror Render / RenderIterator but accept a list of dotted JSON paths to strip from the value before it is marshaled. The list is consulted only on the JSON render path; text/template rendering is unchanged. Motivation: the Databricks SDK uses a single transport struct per resource for both request and response. Some fields are required on the request side (so the SDK marshals them unconditionally) but input-only on the response side per the OpenAPI spec. The CLI today hands the SDK response struct directly to json.MarshalIndent, so those fields leak into user-visible output even though the server doesn't populate them. RenderFiltered gives generated CLI commands a way to strip such fields without modifying the SDK or introducing a separate view layer. Nothing in the generated CLI surface calls these new entry points yet; that switch will land alongside the corresponding codegen change. Co-authored-by: Isaac --- libs/cmdio/filter.go | 58 +++++++++++++++ libs/cmdio/filter_test.go | 115 ++++++++++++++++++++++++++++++ libs/cmdio/paged_template_test.go | 2 +- libs/cmdio/render.go | 55 ++++++++++---- 4 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 libs/cmdio/filter.go create mode 100644 libs/cmdio/filter_test.go diff --git a/libs/cmdio/filter.go b/libs/cmdio/filter.go new file mode 100644 index 00000000000..7122e73855e --- /dev/null +++ b/libs/cmdio/filter.go @@ -0,0 +1,58 @@ +package cmdio + +import ( + "encoding/json" + "fmt" + "strings" +) + +// applyInputOnlyMask returns v with the listed dotted paths removed. If +// paths is empty, v is returned unchanged. Otherwise v is round-tripped +// through JSON into a generic representation, the paths are deleted, and +// the masked value is returned for the caller to marshal in its preferred +// format. +// +// Paths use dotted notation (e.g. "stable_url.initial_workspace_id"). +// Arrays are traversed transparently: a single path applies to every +// element of any array encountered along the way, so list responses share +// the same path expression as singletons. +func applyInputOnlyMask(v any, paths []string) (any, error) { + if len(paths) == 0 { + return v, nil + } + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("input-only mask: marshal: %w", err) + } + var out any + if err := json.Unmarshal(b, &out); err != nil { + return nil, fmt.Errorf("input-only mask: unmarshal: %w", err) + } + for _, p := range paths { + deletePath(out, strings.Split(p, ".")) + } + return out, nil +} + +// deletePath walks v according to keys and removes the leaf key from any +// object it lands on. Missing intermediate keys are a no-op; arrays are +// traversed transparently with the same remaining key list. +func deletePath(v any, keys []string) { + if len(keys) == 0 { + return + } + switch t := v.(type) { + case map[string]any: + if len(keys) == 1 { + delete(t, keys[0]) + return + } + if child, ok := t[keys[0]]; ok { + deletePath(child, keys[1:]) + } + case []any: + for _, el := range t { + deletePath(el, keys) + } + } +} diff --git a/libs/cmdio/filter_test.go b/libs/cmdio/filter_test.go new file mode 100644 index 00000000000..b6b9bdb4f55 --- /dev/null +++ b/libs/cmdio/filter_test.go @@ -0,0 +1,115 @@ +package cmdio + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/databricks/cli/libs/flags" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stableURL struct { + Name string `json:"name"` + InitialWorkspaceID string `json:"initial_workspace_id"` + URL string `json:"url,omitempty"` +} + +type stableURLList struct { + StableURLs []stableURL `json:"stable_urls"` +} + +type wrapper struct { + StableURL stableURL `json:"stable_url"` +} + +func TestApplyInputOnlyMaskEmptyPathsReturnsValueUnchanged(t *testing.T) { + in := stableURL{Name: "n", InitialWorkspaceID: "w"} + out, err := applyInputOnlyMask(in, nil) + require.NoError(t, err) + assert.Equal(t, in, out) +} + +func TestApplyInputOnlyMaskFlatField(t *testing.T) { + in := stableURL{Name: "n", InitialWorkspaceID: "w", URL: "u"} + out, err := applyInputOnlyMask(in, []string{"initial_workspace_id"}) + require.NoError(t, err) + b, err := json.Marshal(out) + require.NoError(t, err) + assert.JSONEq(t, `{"name":"n","url":"u"}`, string(b)) +} + +func TestApplyInputOnlyMaskFieldAbsentIsNoop(t *testing.T) { + in := stableURL{Name: "n"} + out, err := applyInputOnlyMask(in, []string{"missing_field"}) + require.NoError(t, err) + b, err := json.Marshal(out) + require.NoError(t, err) + // Name retained; missing path silently ignored. InitialWorkspaceID + // stays present at "" because the struct tag has no omitempty. + assert.JSONEq(t, `{"name":"n","initial_workspace_id":""}`, string(b)) +} + +func TestApplyInputOnlyMaskNested(t *testing.T) { + in := wrapper{StableURL: stableURL{Name: "n", InitialWorkspaceID: "w"}} + out, err := applyInputOnlyMask(in, []string{"stable_url.initial_workspace_id"}) + require.NoError(t, err) + b, err := json.Marshal(out) + require.NoError(t, err) + assert.JSONEq(t, `{"stable_url":{"name":"n"}}`, string(b)) +} + +func TestApplyInputOnlyMaskSliceElements(t *testing.T) { + in := stableURLList{StableURLs: []stableURL{ + {Name: "a", InitialWorkspaceID: "1"}, + {Name: "b", InitialWorkspaceID: "2"}, + }} + out, err := applyInputOnlyMask(in, []string{"stable_urls.initial_workspace_id"}) + require.NoError(t, err) + b, err := json.Marshal(out) + require.NoError(t, err) + assert.JSONEq(t, `{"stable_urls":[{"name":"a"},{"name":"b"}]}`, string(b)) +} + +func TestApplyInputOnlyMaskMultiplePaths(t *testing.T) { + in := stableURL{Name: "n", InitialWorkspaceID: "w", URL: "u"} + out, err := applyInputOnlyMask(in, []string{"initial_workspace_id", "url"}) + require.NoError(t, err) + b, err := json.Marshal(out) + require.NoError(t, err) + assert.JSONEq(t, `{"name":"n"}`, string(b)) +} + +// TestRenderFilteredStripsInputOnlyField is the integration check: pass +// the same StableUrl-like value the CLI would receive from the SDK, and +// confirm the rendered JSON does not contain initial_workspace_id. +func TestRenderFilteredStripsInputOnlyField(t *testing.T) { + v := stableURL{Name: "accounts/x/stable-urls/y", InitialWorkspaceID: "ws-1", URL: "https://example.test"} + + out := &bytes.Buffer{} + c := &cmdIO{ + capabilities: Capabilities{}, + outputFormat: flags.OutputJSON, + out: out, + err: out, + } + ctx := InContext(t.Context(), c) + require.NoError(t, RenderFiltered(ctx, v, []string{"initial_workspace_id"})) + + assert.JSONEq(t, `{"name":"accounts/x/stable-urls/y","url":"https://example.test"}`, out.String()) + assert.NotContains(t, out.String(), "initial_workspace_id") +} + +func TestRenderFilteredNoPathsMatchesRender(t *testing.T) { + v := stableURL{Name: "n", InitialWorkspaceID: "w"} + + want := &bytes.Buffer{} + got := &bytes.Buffer{} + mk := func(buf *bytes.Buffer) *cmdIO { + return &cmdIO{capabilities: Capabilities{}, outputFormat: flags.OutputJSON, out: buf, err: buf} + } + require.NoError(t, Render(InContext(t.Context(), mk(want)), v)) + require.NoError(t, RenderFiltered(InContext(t.Context(), mk(got)), v, nil)) + assert.Equal(t, want.String(), got.String()) +} diff --git a/libs/cmdio/paged_template_test.go b/libs/cmdio/paged_template_test.go index 03a5fc1c9d5..bf8ba8a3417 100644 --- a/libs/cmdio/paged_template_test.go +++ b/libs/cmdio/paged_template_test.go @@ -167,7 +167,7 @@ func TestPagedTemplateMatchesNonPagedForSmallList(t *testing.T) { var expected bytes.Buffer refIter := listing.Iterator[int](&numberIterator{n: rows}) - require.NoError(t, renderWithTemplate(MockDiscard(t.Context()), newIteratorRenderer(refIter), flags.OutputText, &expected, "", tmpl)) + require.NoError(t, renderWithTemplate(MockDiscard(t.Context()), newIteratorRenderer(refIter, nil), flags.OutputText, &expected, "", tmpl)) pagedIter := listing.Iterator[int](&numberIterator{n: rows}) var actual bytes.Buffer diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 874db015c8b..4bcd3dab87e 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -85,8 +85,9 @@ func (r readerRenderer) renderText(_ context.Context, w io.Writer) error { } type iteratorRenderer[T any] struct { - t listing.Iterator[T] - bufferSize int + t listing.Iterator[T] + bufferSize int + inputOnlyPaths []string } func (ir iteratorRenderer[T]) getBufferSize() int { @@ -117,7 +118,11 @@ func (ir iteratorRenderer[T]) renderJson(ctx context.Context, w writeFlusher) er if err != nil { return err } - res, err := json.MarshalIndent(n, " ", " ") + masked, err := applyInputOnlyMask(n, ir.inputOnlyPaths) + if err != nil { + return err + } + res, err := json.MarshalIndent(masked, " ", " ") if err != nil { return err } @@ -173,12 +178,17 @@ func (ir iteratorRenderer[T]) renderTemplate(ctx context.Context, t *template.Te } type defaultRenderer struct { - t any + t any + inputOnlyPaths []string } func (d defaultRenderer) renderJson(ctx context.Context, w writeFlusher) error { c := fromContext(ctx) - pretty, err := marshalJSON(d.t, c.capabilities.SupportsStdoutColor()) + v, err := applyInputOnlyMask(d.t, d.inputOnlyPaths) + if err != nil { + return err + } + pretty, err := marshalJSON(v, c.capabilities.SupportsStdoutColor()) if err != nil { return err } @@ -201,15 +211,20 @@ func (d defaultRenderer) renderTemplate(_ context.Context, t *template.Template, // - jsonRenderer // - textRenderer // - templateRenderer -func newRenderer(t any) any { +// +// inputOnlyPaths, when non-empty, lists dotted JSON paths that should be +// stripped from the rendered value before it is written to stdout. The +// paths are consulted only by the JSON render path; text/template +// rendering operates on the raw value. +func newRenderer(t any, inputOnlyPaths []string) any { if r, ok := t.(io.Reader); ok { return readerRenderer{reader: r} } - return defaultRenderer{t: t} + return defaultRenderer{t: t, inputOnlyPaths: inputOnlyPaths} } -func newIteratorRenderer[T any](i listing.Iterator[T]) iteratorRenderer[T] { - return iteratorRenderer[T]{t: i} +func newIteratorRenderer[T any](i listing.Iterator[T], inputOnlyPaths []string) iteratorRenderer[T] { + return iteratorRenderer[T]{t: i, inputOnlyPaths: inputOnlyPaths} } type bufferedFlusher struct { @@ -266,11 +281,20 @@ type listingInterface interface { } func Render(ctx context.Context, v any) error { + return RenderFiltered(ctx, v, nil) +} + +// RenderFiltered behaves like Render but strips the given dotted JSON +// paths from the value before it is marshaled. Used by generated CLI +// commands for response types containing INPUT_ONLY fields (which the +// SDK transport struct carries unconditionally) so those fields don't +// leak into user-facing JSON output. +func RenderFiltered(ctx context.Context, v any, inputOnlyPaths []string) error { c := fromContext(ctx) if _, ok := v.(listingInterface); ok { panic("use RenderIterator instead") } - return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, c.headerTemplate, c.template) + return renderWithTemplate(ctx, newRenderer(v, inputOnlyPaths), c.outputFormat, c.out, c.headerTemplate, c.template) } // RenderIterator renders the items produced by i. When the terminal is @@ -280,11 +304,18 @@ func Render(ctx context.Context, v any) error { // locked from the first batch so columns stay aligned across pages). // Piped output and JSON output keep the existing non-paged behavior. func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error { + return RenderIteratorFiltered(ctx, i, nil) +} + +// RenderIteratorFiltered behaves like RenderIterator but strips the given +// dotted JSON paths from each element before it is marshaled. See +// RenderFiltered for the motivation. +func RenderIteratorFiltered[T any](ctx context.Context, i listing.Iterator[T], inputOnlyPaths []string) error { c := fromContext(ctx) if c.capabilities.SupportsPager() && c.outputFormat == flags.OutputText && c.template != "" { return renderIteratorPagedTemplate(ctx, i, c.in, c.out, c.headerTemplate, c.template) } - return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template) + return renderWithTemplate(ctx, newIteratorRenderer(i, inputOnlyPaths), c.outputFormat, c.out, c.headerTemplate, c.template) } func RenderWithTemplate(ctx context.Context, v any, headerTemplate, template string) error { @@ -292,7 +323,7 @@ func RenderWithTemplate(ctx context.Context, v any, headerTemplate, template str if _, ok := v.(listingInterface); ok { panic("listings must use RenderIterator, not RenderWithTemplate") } - return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, headerTemplate, template) + return renderWithTemplate(ctx, newRenderer(v, nil), c.outputFormat, c.out, headerTemplate, template) } // staticTemplateFuncs are the ctx-independent helpers shared across every From e3d5421a78d29f8c900db3184a76453aefdf90c7 Mon Sep 17 00:00:00 2001 From: Divyansh Vijayvergia Date: Fri, 5 Jun 2026 13:05:32 +0000 Subject: [PATCH 2/2] Strip input-only fields from map values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deletePath was traversing arrays transparently but not maps. When the generator emits a path like "tags.initial_workspace_id" for a map[string]V field whose value type has an INPUT_ONLY field, the literal "initial_workspace_id" key doesn't exist directly under "tags" — it lives inside each map value — so the path was silently a no-op at render time. Mirror the array behavior: when a path component doesn't match a literal key on the current object, descend into every value with the same key list. The literal-match path is still preferred, so struct field paths keep working unchanged. Adds two tests: a top-level map field and a map nested inside another struct, both checking that the inner field is stripped from every value. Co-authored-by: Isaac --- libs/cmdio/filter.go | 38 +++++++++++++++++++------- libs/cmdio/filter_test.go | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/libs/cmdio/filter.go b/libs/cmdio/filter.go index 7122e73855e..9fa878d70fc 100644 --- a/libs/cmdio/filter.go +++ b/libs/cmdio/filter.go @@ -13,9 +13,11 @@ import ( // format. // // Paths use dotted notation (e.g. "stable_url.initial_workspace_id"). -// Arrays are traversed transparently: a single path applies to every -// element of any array encountered along the way, so list responses share -// the same path expression as singletons. +// Arrays and dynamically-keyed maps (e.g. proto map) are +// traversed transparently: a single path applies to every element of an +// array, and to every value of a map when no literal key matches the +// next path component. List responses and map-valued fields therefore +// share the same path expression as singletons. func applyInputOnlyMask(v any, paths []string) (any, error) { if len(paths) == 0 { return v, nil @@ -35,20 +37,38 @@ func applyInputOnlyMask(v any, paths []string) (any, error) { } // deletePath walks v according to keys and removes the leaf key from any -// object it lands on. Missing intermediate keys are a no-op; arrays are -// traversed transparently with the same remaining key list. +// object it lands on. +// +// Both arrays and dynamically-keyed maps are traversed transparently: +// +// - When v is a []any, every element is visited with the same key list. +// - When v is a map[string]any but the next key is not a literal match, +// every value is visited with the same key list — this handles proto +// map fields, whose JSON keys are user-provided strings and +// whose values carry the field name from the path. +// +// Both struct fields and proto map surface as map[string]any +// after json.Unmarshal, so a single corner case remains: if a map's +// user-provided key happens to equal an inner field name, the literal +// match wins and that entry is removed instead of the field inside each +// value. Genkit emits paths from the schema, and this matches the +// expected behavior for any path the schema actually targets. func deletePath(v any, keys []string) { if len(keys) == 0 { return } switch t := v.(type) { case map[string]any: - if len(keys) == 1 { - delete(t, keys[0]) + if child, ok := t[keys[0]]; ok { + if len(keys) == 1 { + delete(t, keys[0]) + } else { + deletePath(child, keys[1:]) + } return } - if child, ok := t[keys[0]]; ok { - deletePath(child, keys[1:]) + for _, child := range t { + deletePath(child, keys) } case []any: for _, el := range t { diff --git a/libs/cmdio/filter_test.go b/libs/cmdio/filter_test.go index b6b9bdb4f55..af4294cf7f7 100644 --- a/libs/cmdio/filter_test.go +++ b/libs/cmdio/filter_test.go @@ -24,6 +24,16 @@ type wrapper struct { StableURL stableURL `json:"stable_url"` } +type taggedStableURLs struct { + Tags map[string]stableURL `json:"tags"` +} + +type nestedMapWrapper struct { + Spec struct { + Tags map[string]stableURL `json:"tags"` + } `json:"spec"` +} + func TestApplyInputOnlyMaskEmptyPathsReturnsValueUnchanged(t *testing.T) { in := stableURL{Name: "n", InitialWorkspaceID: "w"} out, err := applyInputOnlyMask(in, nil) @@ -72,6 +82,53 @@ func TestApplyInputOnlyMaskSliceElements(t *testing.T) { assert.JSONEq(t, `{"stable_urls":[{"name":"a"},{"name":"b"}]}`, string(b)) } +func TestApplyInputOnlyMaskMapValues(t *testing.T) { + in := taggedStableURLs{Tags: map[string]stableURL{ + "env": {Name: "a", InitialWorkspaceID: "1"}, + "prod": {Name: "b", InitialWorkspaceID: "2"}, + }} + out, err := applyInputOnlyMask(in, []string{"tags.initial_workspace_id"}) + require.NoError(t, err) + b, err := json.Marshal(out) + require.NoError(t, err) + + var got struct { + Tags map[string]map[string]any `json:"tags"` + } + require.NoError(t, json.Unmarshal(b, &got)) + require.Len(t, got.Tags, 2) + for k, v := range got.Tags { + assert.NotContains(t, v, "initial_workspace_id", "tag %q should have initial_workspace_id stripped", k) + assert.Contains(t, v, "name", "tag %q should retain name", k) + } +} + +func TestApplyInputOnlyMaskNestedInMapValue(t *testing.T) { + // Path lands inside a map at the second segment, then descends two + // more levels into the map's value. Confirms map transparency + // composes with regular literal-key descent. + var in nestedMapWrapper + in.Spec.Tags = map[string]stableURL{ + "a": {Name: "x", InitialWorkspaceID: "1"}, + "b": {Name: "y", InitialWorkspaceID: "2"}, + } + out, err := applyInputOnlyMask(in, []string{"spec.tags.initial_workspace_id"}) + require.NoError(t, err) + b, err := json.Marshal(out) + require.NoError(t, err) + + var got struct { + Spec struct { + Tags map[string]map[string]any `json:"tags"` + } `json:"spec"` + } + require.NoError(t, json.Unmarshal(b, &got)) + require.Len(t, got.Spec.Tags, 2) + for k, v := range got.Spec.Tags { + assert.NotContains(t, v, "initial_workspace_id", "spec.tags[%q] should have initial_workspace_id stripped", k) + } +} + func TestApplyInputOnlyMaskMultiplePaths(t *testing.T) { in := stableURL{Name: "n", InitialWorkspaceID: "w", URL: "u"} out, err := applyInputOnlyMask(in, []string{"initial_workspace_id", "url"})