From 7baba213bc9e3e4863b804d24ca38089b810b694 Mon Sep 17 00:00:00 2001 From: MaxHuang22 Date: Thu, 2 Apr 2026 18:36:59 +0800 Subject: [PATCH] feat: add --jq flag for filtering JSON output (#211) * feat: add --jq flag for filtering JSON output across all command types Add jq expression filtering (--jq / -q) to api, service, and shortcut commands using gojq. Includes early expression validation, mutual exclusion checks with --output and non-json --format, pagination+jq aggregation path, and comprehensive test coverage. * fix: correct gofmt alignment in jq_test.go struct literal * fix: downgrade gojq to v0.12.17 to keep Go 1.23 compatibility gojq v0.12.18 requires Go 1.24, which unnecessarily bumped the project minimum version. v0.12.17 requires only Go 1.21 and provides the same jq functionality needed. * refactor: consolidate jq validation and pagination logic Extract ValidateJqFlags() and PaginateWithJq() shared functions to eliminate duplicated jq logic across api, service, and shortcut commands. * fix: reject --jq for non-JSON responses and propagate shortcut jq errors - HandleResponse now returns a validation error when --jq is used with a non-JSON Content-Type instead of silently falling through to binary save. - Shortcut runtime jq errors are captured in RuntimeContext.outputErr and propagated as the command exit code, matching api/service behavior. --- .gitignore | 4 + cmd/api/api.go | 18 ++- cmd/api/api_test.go | 173 +++++++++++++++++++++++ cmd/root.go | 2 + cmd/service/service.go | 15 +- cmd/service/service_test.go | 167 ++++++++++++++++++++++ go.mod | 2 + go.sum | 4 + internal/client/pagination.go | 17 +++ internal/client/response.go | 7 + internal/client/response_test.go | 17 +++ internal/output/jq.go | 132 ++++++++++++++++++ internal/output/jq_test.go | 215 +++++++++++++++++++++++++++++ shortcuts/common/runner.go | 26 +++- shortcuts/common/runner_jq_test.go | 201 +++++++++++++++++++++++++++ 15 files changed, 995 insertions(+), 5 deletions(-) create mode 100644 internal/output/jq.go create mode 100644 internal/output/jq_test.go create mode 100644 shortcuts/common/runner_jq_test.go diff --git a/.gitignore b/.gitignore index ec525ba80..9df212e40 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ test_scripts/ tests/mail/reports/ /log/ + +# Generated / test artifacts +internal/registry/meta_data.json +cmd/api/download.bin diff --git a/cmd/api/api.go b/cmd/api/api.go index 587d45271..89661b365 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -40,6 +40,7 @@ type APIOptions struct { PageLimit int PageDelay int Format string + JqExpr string DryRun bool } @@ -96,6 +97,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") + cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { @@ -155,6 +157,9 @@ func apiRun(opts *APIOptions) error { if opts.PageAll && opts.Output != "" { return output.ErrValidation("--output and --page-all are mutually exclusive") } + if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { + return err + } request, err := buildAPIRequest(opts) if err != nil { @@ -184,7 +189,7 @@ func apiRun(opts *APIOptions) error { } if opts.PageAll { - return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut, + return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}) } @@ -195,6 +200,7 @@ func apiRun(opts *APIOptions) error { err = client.HandleResponse(resp, client.ResponseOptions{ OutputPath: opts.Output, Format: format, + JqExpr: opts.JqExpr, Out: out, ErrOut: f.IOStreams.ErrOut, }) @@ -210,7 +216,15 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format) } -func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error { +func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error { + // When jq is set, always aggregate all pages then filter. + if jqExpr != "" { + if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil { + return output.MarkRaw(err) + } + return nil + } + switch format { case output.FormatNDJSON, output.FormatTable, output.FormatCSV: pf := output.NewPaginatedFormatter(out, format) diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 730aae0bc..0b01d250e 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -536,6 +536,179 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) { } } +func TestApiCmd_JqFlag_Parsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--jq", ".data"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.JqExpr != ".data" { + t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr) + } +} + +func TestApiCmd_JqFlag_ShortForm(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "-q", ".data"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.JqExpr != ".data" { + t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr) + } +} + +func TestApiCmd_JqAndOutputConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, func(opts *APIOptions) error { + return apiRun(opts) + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --jq + --output conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-jq", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/jq", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + map[string]interface{}{"name": "Bob"}, + }, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") { + t.Errorf("expected jq-filtered names, got: %s", out) + } + // Should NOT contain the full envelope structure + if strings.Contains(out, `"code"`) { + t.Errorf("expected jq to filter out envelope, got: %s", out) + } +} + +func TestApiCmd_JqAndFormatConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, func(opts *APIOptions) error { + return apiRun(opts) + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --jq + --format ndjson conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestApiCmd_JqInvalidExpression(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, func(opts *APIOptions) error { + return apiRun(opts) + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid jq expression") + } + if !strings.Contains(err.Error(), "invalid jq expression") { + t.Errorf("expected 'invalid jq expression' error, got: %v", err) + } +} + +func TestApiCmd_PageAll_WithJq(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-pjq", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/contact/v3/users", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "u1"}, map[string]interface{}{"id": "u2"}}, + "has_more": false, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") { + t.Errorf("expected jq-filtered ids, got: %s", out) + } + if strings.Contains(out, `"code"`) { + t.Errorf("expected jq to filter out envelope, got: %s", out) + } +} + func TestApiCmd_MethodUppercase(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, diff --git a/cmd/root.go b/cmd/root.go index 6168dbdeb..3440edb16 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,6 +61,8 @@ FLAGS: --page-limit max pages to fetch with --page-all (default: 10, 0 for unlimited) --page-delay delay in ms between pages (default: 200, only with --page-all) -o, --output output file path for binary responses + --jq jq expression to filter JSON output + -q shorthand for --jq --dry-run print request without executing AI AGENT SKILLS: diff --git a/cmd/service/service.go b/cmd/service/service.go index e3fceded5..1a392a7ee 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -109,6 +109,7 @@ type ServiceMethodOptions struct { PageLimit int PageDelay int Format string + JqExpr string DryRun bool } @@ -157,6 +158,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{} cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") + cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { @@ -185,6 +187,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { if opts.PageAll && opts.Output != "" { return output.ErrValidation("--output and --page-all are mutually exclusive") } + if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { + return err + } config, err := f.ResolveConfig(opts.As) if err != nil { @@ -223,7 +228,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { checkErr := scopeAwareChecker(scopes, opts.As.IsBot()) if opts.PageAll { - return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut, + return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr) } @@ -234,6 +239,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { return client.HandleResponse(resp, client.ResponseOptions{ OutputPath: opts.Output, Format: format, + JqExpr: opts.JqExpr, Out: out, ErrOut: f.IOStreams.ErrOut, CheckError: checkErr, @@ -400,7 +406,12 @@ func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) e } } -func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error { +func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error { + // When jq is set, always aggregate all pages then filter. + if jqExpr != "" { + return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr) + } + switch format { case output.FormatNDJSON, output.FormatTable, output.FormatCSV: pf := output.NewPaginatedFormatter(out, format) diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 55c0986df..d52687dbb 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -474,6 +474,173 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) { } } +// ── jq flag ── + +func TestNewCmdServiceMethod_JqFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + + var captured *ServiceMethodOptions + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + func(opts *ServiceMethodOptions) error { + captured = opts + return nil + }) + cmd.SetArgs([]string{"--jq", ".data"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured == nil { + t.Fatal("runF was not called") + } + if captured.JqExpr != ".data" { + t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr) + } +} + +func TestNewCmdServiceMethod_JqShortForm(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + + var captured *ServiceMethodOptions + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + func(opts *ServiceMethodOptions) error { + captured = opts + return nil + }) + cmd.SetArgs([]string{"-q", ".data"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured.JqExpr != ".data" { + t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr) + } +} + +func TestServiceMethod_JqAndOutputConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --jq + --output conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + map[string]interface{}{"name": "Bob"}, + }, + }, + }, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") { + t.Errorf("expected jq-filtered names, got: %s", out) + } + if strings.Contains(out, `"code"`) { + t.Errorf("expected jq to filter out envelope, got: %s", out) + } +} + +func TestServiceMethod_JqAndFormatConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --jq + --format ndjson conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestServiceMethod_JqInvalidExpression(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid jq expression") + } + if !strings.Contains(err.Error(), "invalid jq expression") { + t.Errorf("expected 'invalid jq expression' error, got: %v", err) + } +} + +func TestServiceMethod_PageAll_WithJq(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "s1"}, map[string]interface{}{"id": "s2"}}, + "has_more": false, + }, + }, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "s1") || !strings.Contains(out, "s2") { + t.Errorf("expected jq-filtered ids, got: %s", out) + } + if strings.Contains(out, `"code"`) { + t.Errorf("expected jq to filter out envelope, got: %s", out) + } +} + // ── scopeAwareChecker ── func TestScopeAwareChecker_Success(t *testing.T) { diff --git a/go.mod b/go.mod index ed41d1f0c..5a3fa4c0e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/gofrs/flock v0.8.1 github.com/google/uuid v1.6.0 + github.com/itchyny/gojq v0.12.17 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartystreets/goconvey v1.8.1 @@ -37,6 +38,7 @@ require ( github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index b6a807a3e..91133de37 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,10 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= diff --git a/internal/client/pagination.go b/internal/client/pagination.go index 4c12979d4..ecddcc1c9 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -4,6 +4,7 @@ package client import ( + "context" "fmt" "io" @@ -16,6 +17,22 @@ type PaginationOptions struct { PageDelay int // ms, default 200 } +// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter. +// If checkErr detects an error, the raw result is printed as JSON before returning the error. +func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest, + jqExpr string, out io.Writer, pagOpts PaginationOptions, + checkErr func(interface{}) error) error { + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return output.ErrNetwork("API call failed: %v", err) + } + if apiErr := checkErr(result); apiErr != nil { + output.FormatValue(out, result, output.FormatJSON) + return apiErr + } + return output.JqFilter(out, result, jqExpr) +} + func mergePagedResults(w io.Writer, results []interface{}) interface{} { if len(results) == 0 { return map[string]interface{}{} diff --git a/internal/client/response.go b/internal/client/response.go index 0a76d57cb..db34400b1 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -26,6 +26,7 @@ import ( type ResponseOptions struct { OutputPath string // --output flag; "" = auto-detect Format output.Format // output format for JSON responses + JqExpr string // if set, apply jq filter instead of Format Out io.Writer // stdout ErrOut io.Writer // stderr // CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse. @@ -62,11 +63,17 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { if opts.OutputPath != "" { return saveAndPrint(resp, opts.OutputPath, opts.Out) } + if opts.JqExpr != "" { + return output.JqFilter(opts.Out, result, opts.JqExpr) + } output.FormatValue(opts.Out, result, opts.Format) return nil } // Non-JSON (binary) responses. + if opts.JqExpr != "" { + return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct) + } if opts.OutputPath != "" { return saveAndPrint(resp, opts.OutputPath, opts.Out) } diff --git a/internal/client/response_test.go b/internal/client/response_test.go index 8bfc6f16b..0de09f977 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -319,6 +319,23 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) { } } +func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) { + resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + JqExpr: ".data", + Out: &out, + ErrOut: &errOut, + }) + if err == nil { + t.Fatal("expected error when --jq is used with non-JSON response") + } + if !strings.Contains(err.Error(), "--jq requires a JSON response") { + t.Errorf("expected '--jq requires a JSON response' error, got: %v", err) + } +} + func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) { body := []byte(`{"code":99991400,"msg":"invalid token"}`) resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"}) diff --git a/internal/output/jq.go b/internal/output/jq.go new file mode 100644 index 000000000..5a84b5609 --- /dev/null +++ b/internal/output/jq.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "encoding/json" + "fmt" + "io" + "math/big" + + "github.com/itchyny/gojq" +) + +// JqFilter applies a jq expression to data and writes the results to w. +// Scalar values are printed raw (no quotes for strings), matching jq -r behavior. +// Complex values (maps, arrays) are printed as indented JSON. +func JqFilter(w io.Writer, data interface{}, expr string) error { + query, err := gojq.Parse(expr) + if err != nil { + return ErrValidation("invalid jq expression: %s", err) + } + code, err := gojq.Compile(query) + if err != nil { + return ErrValidation("invalid jq expression: %s", err) + } + + // Normalize data through toGeneric so typed structs become map[string]any. + normalized := toGeneric(data) + // Convert json.Number values to gojq-compatible types. + normalized = convertNumbers(normalized) + + iter := code.Run(normalized) + for { + v, ok := iter.Next() + if !ok { + break + } + if err, isErr := v.(error); isErr { + return Errorf(ExitAPI, "jq_error", "jq error: %s", err) + } + if err := writeJqValue(w, v); err != nil { + return err + } + } + return nil +} + +// ValidateJqFlags checks --jq flag compatibility with --output and --format flags, +// and validates the jq expression syntax. Returns nil if jqExpr is empty. +func ValidateJqFlags(jqExpr, outputFlag, format string) error { + if jqExpr == "" { + return nil + } + if outputFlag != "" { + return ErrValidation("--jq and --output are mutually exclusive") + } + if format != "" && format != "json" { + return ErrValidation("--jq and --format %s are mutually exclusive", format) + } + return ValidateJqExpression(jqExpr) +} + +// ValidateJqExpression checks whether a jq expression is syntactically valid. +func ValidateJqExpression(expr string) error { + query, err := gojq.Parse(expr) + if err != nil { + return ErrValidation("invalid jq expression: %s", err) + } + _, err = gojq.Compile(query) + if err != nil { + return ErrValidation("invalid jq expression: %s", err) + } + return nil +} + +// writeJqValue writes a single jq result value to w. +// Scalars are printed raw; complex values as indented JSON. +func writeJqValue(w io.Writer, v interface{}) error { + switch val := v.(type) { + case nil: + fmt.Fprintln(w, "null") + case bool: + fmt.Fprintln(w, val) + case int: + fmt.Fprintln(w, val) + case float64: + // Use %g to avoid trailing zeros, matching jq behavior. + fmt.Fprintf(w, "%g\n", val) + case *big.Int: + fmt.Fprintln(w, val.String()) + case string: + // Raw output for strings (no quotes), matching jq -r. + fmt.Fprintln(w, val) + default: + // Complex value (map, array): indented JSON. + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return Errorf(ExitInternal, "jq_error", "failed to marshal jq result: %s", err) + } + fmt.Fprintln(w, string(b)) + } + return nil +} + +// convertNumbers recursively converts json.Number values to int or float64 +// so that gojq can process them correctly. +func convertNumbers(v interface{}) interface{} { + switch val := v.(type) { + case json.Number: + if i, err := val.Int64(); err == nil { + return int(i) + } + if f, err := val.Float64(); err == nil { + return f + } + // Fallback: return as string (shouldn't happen for valid JSON numbers). + return val.String() + case map[string]interface{}: + for k, elem := range val { + val[k] = convertNumbers(elem) + } + return val + case []interface{}: + for i, elem := range val { + val[i] = convertNumbers(elem) + } + return val + default: + return v + } +} diff --git a/internal/output/jq_test.go b/internal/output/jq_test.go new file mode 100644 index 000000000..80300dc48 --- /dev/null +++ b/internal/output/jq_test.go @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestJqFilter(t *testing.T) { + data := map[string]interface{}{ + "ok": true, + "identity": "user", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice", "age": 30}, + map[string]interface{}{"name": "Bob", "age": 25}, + map[string]interface{}{"name": "Charlie", "age": 35}, + }, + "total": 3, + }, + "meta": map[string]interface{}{ + "count": 3, + }, + } + + tests := []struct { + name string + expr string + want string + wantErr bool + }{ + { + name: "identity expression", + expr: ".", + want: `"ok"`, + }, + { + name: "field access .ok", + expr: ".ok", + want: "true\n", + }, + { + name: "string field raw output", + expr: ".identity", + want: "user\n", + }, + { + name: "nested field access", + expr: ".data.total", + want: "3\n", + }, + { + name: "meta count", + expr: ".meta.count", + want: "3\n", + }, + { + name: "array iteration", + expr: ".data.items[].name", + want: "Alice\nBob\nCharlie\n", + }, + { + name: "pipe and select", + expr: `.data.items[] | select(.age > 28) | .name`, + want: "Alice\nCharlie\n", + }, + { + name: "length builtin", + expr: ".data.items | length", + want: "3\n", + }, + { + name: "keys builtin", + expr: ".data | keys", + want: "[\n \"items\",\n \"total\"\n]\n", + }, + { + name: "null for missing field", + expr: ".nonexistent", + want: "null\n", + }, + { + name: "complex value output", + expr: ".data.items[0]", + want: "{\n \"age\": 30,\n \"name\": \"Alice\"\n}\n", + }, + { + name: "invalid expression", + expr: "invalid[", + wantErr: true, + }, + { + name: "multiple outputs", + expr: ".ok, .identity", + want: "true\nuser\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := JqFilter(&buf, data, tt.expr) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.name == "identity expression" { + // For identity, just verify it contains the key fields + if !strings.Contains(buf.String(), `"ok"`) { + t.Errorf("identity output missing 'ok' key") + } + return + } + if buf.String() != tt.want { + t.Errorf("got %q, want %q", buf.String(), tt.want) + } + }) + } +} + +func TestJqFilter_WithStruct(t *testing.T) { + // Test that toGeneric normalizes structs properly + type inner struct { + Name string `json:"name"` + } + data := struct { + OK bool `json:"ok"` + Item *inner `json:"item"` + }{ + OK: true, + Item: &inner{Name: "test"}, + } + + var buf bytes.Buffer + err := JqFilter(&buf, data, ".item.name") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.TrimSpace(buf.String()); got != "test" { + t.Errorf("got %q, want %q", got, "test") + } +} + +func TestValidateJqFlags(t *testing.T) { + tests := []struct { + name string + jqExpr string + outputFlag string + format string + wantErr string + }{ + {name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""}, + {name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""}, + {name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""}, + {name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"}, + {name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"}, + {name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"}, + {name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateJqFlags(tt.jqExpr, tt.outputFlag, tt.format) + if tt.wantErr == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErr) + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestValidateJqExpression(t *testing.T) { + tests := []struct { + expr string + wantErr bool + }{ + {".", false}, + {".data", false}, + {".data.items[].name", false}, + {`.data.items[] | select(.name == "Alice")`, false}, + {"length", false}, + {"keys", false}, + {"invalid[", true}, + {".foo | invalid_func", true}, + } + + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + err := ValidateJqExpression(tt.expr) + if tt.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 51ec9baa1..7623c8d47 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -33,6 +33,8 @@ type RuntimeContext struct { Config *core.CliConfig Cmd *cobra.Command Format string + JqExpr string // --jq expression; empty = no filter + outputErr error // deferred error from Out()/OutFormat() jq filtering botOnly bool // set by framework for bot-only shortcuts resolvedAs core.Identity // effective identity resolved by framework Factory *cmdutil.Factory // injected by framework @@ -433,13 +435,27 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { // Out prints a success JSON envelope to stdout. func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()} + if ctx.JqExpr != "" { + if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil { + fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err) + if ctx.outputErr == nil { + ctx.outputErr = err + } + } + return + } b, _ := json.MarshalIndent(env, "", " ") fmt.Fprintln(ctx.IO().Out, string(b)) } // OutFormat prints output based on --format flag. // "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue. +// When JqExpr is set, routes through Out() regardless of format. func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) { + if ctx.JqExpr != "" { + ctx.Out(data, meta) + return + } switch ctx.Format { case "pretty": if prettyFn != nil { @@ -560,6 +576,9 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo if err := validateEnumFlags(rctx, s.Flags); err != nil { return err } + if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil { + return err + } if s.Validate != nil { if err := s.Validate(rctx.ctx, rctx); err != nil { return err @@ -576,7 +595,10 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo } } - return s.Execute(rctx.ctx, rctx) + if err := s.Execute(rctx.ctx, rctx); err != nil { + return err + } + return rctx.outputErr } func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) { @@ -618,6 +640,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf if s.HasFormat { rctx.Format = rctx.Str("format") } + rctx.JqExpr, _ = cmd.Flags().GetString("jq") return rctx, nil } @@ -698,6 +721,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) { if s.Risk == "high-risk-write" { cmd.Flags().Bool("yes", false, "confirm high-risk operation") } + cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output") cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot") _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go new file mode 100644 index 000000000..cce144f41 --- /dev/null +++ b/shortcuts/common/runner_jq_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + lark "github.com/larksuite/oapi-sdk-go/v3" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// newJqTestContext creates a RuntimeContext wired for jq testing. +func newJqTestContext(jqExpr, format string) (*RuntimeContext, *bytes.Buffer, *bytes.Buffer) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("jq", "", "") + cmd.Flags().String("format", "json", "") + cmd.Flags().String("as", "bot", "") + cmd.ParseFlags(nil) + if jqExpr != "" { + cmd.Flags().Set("jq", jqExpr) + } + if format != "" { + cmd.Flags().Set("format", format) + } + + rctx := &RuntimeContext{ + ctx: context.Background(), + Config: &core.CliConfig{Brand: core.BrandFeishu}, + Cmd: cmd, + Format: format, + JqExpr: jqExpr, + resolvedAs: core.AsBot, + Factory: &cmdutil.Factory{ + IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}, + }, + } + return rctx, stdout, stderr +} + +func TestRuntimeContext_Out_WithJq(t *testing.T) { + rctx, stdout, _ := newJqTestContext(".data.name", "") + + rctx.Out(map[string]interface{}{ + "name": "Alice", + "age": 30, + }, nil) + + out := stdout.String() + if !strings.Contains(out, "Alice") { + t.Errorf("expected jq-filtered 'Alice', got: %s", out) + } + if strings.Contains(out, "age") { + t.Errorf("expected jq to filter out 'age', got: %s", out) + } +} + +func TestRuntimeContext_Out_WithJq_Identity(t *testing.T) { + rctx, stdout, _ := newJqTestContext(".ok", "") + + rctx.Out(map[string]interface{}{"key": "value"}, nil) + + out := strings.TrimSpace(stdout.String()) + if out != "true" { + t.Errorf("expected 'true' for .ok, got: %s", out) + } +} + +func TestRuntimeContext_OutFormat_WithJq_OverridesFormat(t *testing.T) { + rctx, stdout, _ := newJqTestContext(".data.items", "pretty") + + items := []interface{}{"a", "b", "c"} + rctx.OutFormat(map[string]interface{}{ + "items": items, + }, nil, func(w io.Writer) { + t.Error("prettyFn should not be called when jq is set") + }) + + out := stdout.String() + if !strings.Contains(out, "a") || !strings.Contains(out, "b") { + t.Errorf("expected jq-filtered items, got: %s", out) + } +} + +func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) { + rctx, _, stderr := newJqTestContext(".foo | invalid_func_xyz", "") + + rctx.Out(map[string]interface{}{"foo": "bar"}, nil) + + if !strings.Contains(stderr.String(), "error") { + t.Errorf("expected error on stderr for runtime jq error, got: %s", stderr.String()) + } +} + +func newTestShortcutCmd(s *Shortcut) *cobra.Command { + cmd := &cobra.Command{Use: "test-shortcut"} + cmd.SetContext(context.Background()) + registerShortcutFlags(cmd, s) + return cmd +} + +func newTestFactory() *cmdutil.Factory { + return &cmdutil.Factory{ + Config: func() (*core.CliConfig, error) { + return &core.CliConfig{ + AppID: "test", AppSecret: "test", Brand: core.BrandFeishu, + }, nil + }, + LarkClient: func() (*lark.Client, error) { + return lark.NewClient("test", "test"), nil + }, + IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, + } +} + +func TestRunShortcut_JqAndFormatConflict(t *testing.T) { + s := &Shortcut{ + Service: "test", + Command: "test-shortcut", + AuthTypes: []string{"bot"}, + HasFormat: true, + Execute: func(ctx context.Context, rctx *RuntimeContext) error { + return nil + }, + } + cmd := newTestShortcutCmd(s) + cmd.Flags().Set("jq", ".data") + cmd.Flags().Set("format", "table") + cmd.Flags().Set("as", "bot") + + err := runShortcut(cmd, newTestFactory(), s, true) + if err == nil { + t.Fatal("expected error for --jq + --format table conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestRunShortcut_JqInvalidExpression(t *testing.T) { + s := &Shortcut{ + Service: "test", + Command: "test-shortcut", + AuthTypes: []string{"bot"}, + Execute: func(ctx context.Context, rctx *RuntimeContext) error { + return nil + }, + } + cmd := newTestShortcutCmd(s) + cmd.Flags().Set("jq", "invalid[") + cmd.Flags().Set("as", "bot") + + err := runShortcut(cmd, newTestFactory(), s, true) + if err == nil { + t.Fatal("expected error for invalid jq expression") + } + if !strings.Contains(err.Error(), "invalid jq expression") { + t.Errorf("expected 'invalid jq expression' error, got: %v", err) + } +} + +func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) { + s := &Shortcut{ + Service: "test", + Command: "test-shortcut", + AuthTypes: []string{"bot"}, + Execute: func(ctx context.Context, rctx *RuntimeContext) error { + rctx.Out(map[string]interface{}{"foo": "bar"}, nil) + return nil + }, + } + cmd := newTestShortcutCmd(s) + cmd.Flags().Set("jq", ".foo | invalid_func_xyz") + cmd.Flags().Set("as", "bot") + + err := runShortcut(cmd, newTestFactory(), s, true) + if err == nil { + t.Fatal("expected error from jq runtime failure to propagate") + } +} + +func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) { + rctx, stdout, _ := newJqTestContext("", "") + + rctx.Out(map[string]interface{}{"key": "value"}, &output.Meta{Count: 1}) + + out := stdout.String() + if !strings.Contains(out, `"ok"`) || !strings.Contains(out, `"key"`) { + t.Errorf("expected normal JSON envelope, got: %s", out) + } +}