From 8f6218a7d2b098d0b856ce04b92486313762c400 Mon Sep 17 00:00:00 2001 From: gannonsun Date: Wed, 8 Apr 2026 21:20:34 +0800 Subject: [PATCH] fix: support file and stdin JSON inputs --- cmd/api/api.go | 23 +++---------- cmd/api/api_test.go | 37 +++++++++++++++++++++ cmd/root.go | 4 +-- cmd/service/service.go | 13 ++++---- cmd/service/service_test.go | 39 +++++++++++++++++++++- internal/cmdutil/json.go | 57 +++++++++++++++++++++++++++++--- internal/cmdutil/json_test.go | 61 +++++++++++++++++++++++++++++++++-- 7 files changed, 199 insertions(+), 35 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 3467ef2e..33bc4325 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -5,7 +5,6 @@ package api import ( "context" - "encoding/json" "fmt" "io" "regexp" @@ -44,17 +43,6 @@ type APIOptions struct { DryRun bool } -func parseJsonOpt(input, label string) (map[string]interface{}, error) { - if input == "" { - return nil, nil - } - var result map[string]interface{} - if err := json.Unmarshal([]byte(input), &result); err != nil { - return nil, output.ErrValidation("%s invalid format, expected JSON object", label) - } - return result, nil -} - var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`) func normalisePath(raw string) string { @@ -88,8 +76,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command }, } - cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON") - cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON") + cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON, @file, or - for stdin") + cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON, @file, or - for stdin") cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)") cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") @@ -118,16 +106,13 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command // buildAPIRequest validates flags and builds a RawApiRequest. func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) { - params, err := parseJsonOpt(opts.Params, "--params") + params, err := cmdutil.ParseJSONMap(opts.Params, "--params", opts.Factory.IOStreams.In) if err != nil { return client.RawApiRequest{}, err } - if params == nil { - params = map[string]interface{}{} - } var data interface{} if opts.Data != "" { - data, err = parseJsonOpt(opts.Data, "--data") + data, err = cmdutil.ParseJSONMap(opts.Data, "--data", opts.Factory.IOStreams.In) if err != nil { return client.RawApiRequest{}, err } diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 589cc21e..b7d70a74 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -13,6 +13,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/vfs" "github.com/spf13/cobra" ) @@ -113,6 +114,42 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) { } } +func TestApiCmd_ParamsFromFile(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + if err := vfs.WriteFile("params.json", []byte(`{"foo":"bar"}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "@params.json", "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"foo": "bar"`) { + t.Fatalf("expected params from file in dry-run output, got: %s", stdout.String()) + } +} + +func TestApiCmd_ParamsFromStdin(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + f.IOStreams.In = strings.NewReader(`{"foo":"bar"}`) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "-", "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"foo": "bar"`) { + t.Fatalf("expected params from stdin in dry-run output, got: %s", stdout.String()) + } +} + func TestApiValidArgsFunction(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 8996dd8a..380b3e06 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -54,8 +54,8 @@ EXAMPLES: lark-cli api GET /open-apis/calendar/v4/calendars FLAGS: - --params URL/query parameters JSON - --data request body JSON (POST/PATCH/PUT/DELETE) + --params URL/query parameters JSON, @file, or - for stdin + --data request body JSON, @file, or - for stdin --as identity type: user | bot | auto (default: auto) --format output format: json (default) | ndjson | table | csv | pretty --page-all automatically paginate through all pages diff --git a/cmd/service/service.go b/cmd/service/service.go index 61759fa6..ef734ed3 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -5,7 +5,6 @@ package service import ( "context" - "encoding/json" "fmt" "io" "strings" @@ -148,10 +147,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{} }, } - cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON") + cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON, @file, or - for stdin") switch httpMethod { case "POST", "PUT", "PATCH", "DELETE": - cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON") + cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON, @file, or - for stdin") } cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)") cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") @@ -309,11 +308,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro method := opts.Method schemaPath := opts.SchemaPath httpMethod := registry.GetStrFromMap(method, "httpMethod") + var err error var params map[string]interface{} if opts.Params != "" { - if err := json.Unmarshal([]byte(opts.Params), ¶ms); err != nil { - return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format") + params, err = cmdutil.ParseJSONMap(opts.Params, "--params", opts.Factory.IOStreams.In) + if err != nil { + return client.RawApiRequest{}, err } } else { params = map[string]interface{}{} @@ -365,7 +366,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro } } - data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data) + data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, opts.Factory.IOStreams.In) if err != nil { return client.RawApiRequest{}, err } diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 7cd09f39..34ac5bc9 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -11,6 +11,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/vfs" "github.com/spf13/cobra" ) @@ -308,7 +309,7 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) { if err == nil { t.Fatal("expected error for invalid JSON") } - if !strings.Contains(err.Error(), "--params invalid JSON format") { + if !strings.Contains(err.Error(), "--params invalid format, expected JSON object") { t.Errorf("unexpected error: %v", err) } } @@ -331,6 +332,42 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) { } } +func TestServiceMethod_ParamsFromFile(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + if err := vfs.WriteFile("params.json", []byte(`{"file_token":"boxcn123abc"}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := vfs.WriteFile("data.json", []byte(`{"name":"test.txt"}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil) + cmd.SetArgs([]string{"--params", "@params.json", "--data", "@data.json", "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/drive/v1/files/boxcn123abc/copy") || !strings.Contains(out, `"name": "test.txt"`) { + t.Fatalf("expected params/data from file in dry-run output, got:\n%s", out) + } +} + +func TestServiceMethod_ParamsFromStdin(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + f.IOStreams.In = strings.NewReader(`{"file_token":"boxcn123abc"}`) + + cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil) + cmd.SetArgs([]string{"--params", "-", "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/boxcn123abc/copy") { + t.Fatalf("expected params from stdin in dry-run output, got:\n%s", stdout.String()) + } +} + func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, testConfig) spec := map[string]interface{}{ diff --git a/internal/cmdutil/json.go b/internal/cmdutil/json.go index 6a162c4e..9c8cde74 100644 --- a/internal/cmdutil/json.go +++ b/internal/cmdutil/json.go @@ -5,13 +5,17 @@ package cmdutil import ( "encoding/json" + "io" + "strings" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" ) // ParseOptionalBody parses --data JSON for methods that accept a request body. // Returns (nil, nil) if the method has no body or data is empty. -func ParseOptionalBody(httpMethod, data string) (interface{}, error) { +func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) { switch httpMethod { case "POST", "PUT", "PATCH", "DELETE": default: @@ -20,21 +24,66 @@ func ParseOptionalBody(httpMethod, data string) (interface{}, error) { if data == "" { return nil, nil } + resolved, err := ResolveStructuredInput(data, "--data", stdin) + if err != nil { + return nil, err + } var body interface{} - if err := json.Unmarshal([]byte(data), &body); err != nil { + if err := json.Unmarshal([]byte(resolved), &body); err != nil { return nil, output.ErrValidation("--data invalid JSON format") } return body, nil } // ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty. -func ParseJSONMap(input, label string) (map[string]any, error) { +func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) { if input == "" { return map[string]any{}, nil } + resolved, err := ResolveStructuredInput(input, label, stdin) + if err != nil { + return nil, err + } var result map[string]any - if err := json.Unmarshal([]byte(input), &result); err != nil { + if err := json.Unmarshal([]byte(resolved), &result); err != nil { return nil, output.ErrValidation("%s invalid format, expected JSON object", label) } return result, nil } + +// ResolveStructuredInput expands raw string input for JSON-capable flags. +// Supported forms: +// - literal JSON string +// - @path: read file content +// - -: read from stdin +func ResolveStructuredInput(input, label string, stdin io.Reader) (string, error) { + switch { + case input == "": + return "", nil + case input == "-": + if stdin == nil { + return "", output.ErrValidation("%s stdin is not available", label) + } + data, err := io.ReadAll(stdin) + if err != nil { + return "", output.ErrValidation("%s failed to read from stdin: %v", label, err) + } + return string(data), nil + case strings.HasPrefix(input, "@"): + path := strings.TrimSpace(strings.TrimPrefix(input, "@")) + if path == "" { + return "", output.ErrValidation("%s file path cannot be empty after @", label) + } + safePath, err := validate.SafeInputPath(path) + if err != nil { + return "", output.ErrValidation("%s invalid file path %q: %v", label, path, err) + } + data, err := vfs.ReadFile(safePath) + if err != nil { + return "", output.ErrValidation("%s cannot read file %q: %v", label, path, err) + } + return string(data), nil + default: + return input, nil + } +} diff --git a/internal/cmdutil/json_test.go b/internal/cmdutil/json_test.go index e88218a1..d0f5b1c9 100644 --- a/internal/cmdutil/json_test.go +++ b/internal/cmdutil/json_test.go @@ -3,7 +3,12 @@ package cmdutil -import "testing" +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/vfs" +) func TestParseOptionalBody(t *testing.T) { tests := []struct { @@ -23,7 +28,7 @@ func TestParseOptionalBody(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ParseOptionalBody(tt.method, tt.data) + got, err := ParseOptionalBody(tt.method, tt.data, nil) if (err != nil) != tt.wantErr { t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr) return @@ -53,7 +58,7 @@ func TestParseJSONMap(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ParseJSONMap(tt.input, tt.label) + got, err := ParseJSONMap(tt.input, tt.label, nil) if (err != nil) != tt.wantErr { t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr) return @@ -64,3 +69,53 @@ func TestParseJSONMap(t *testing.T) { }) } } + +func TestParseJSONMap_FromFile(t *testing.T) { + tmpDir := t.TempDir() + TestChdir(t, tmpDir) + if err := vfs.WriteFile("params.json", []byte(`{"a":"1","b":"2"}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got, err := ParseJSONMap("@params.json", "--params", nil) + if err != nil { + t.Fatalf("ParseJSONMap(@file) error = %v", err) + } + if len(got) != 2 || got["a"] != "1" || got["b"] != "2" { + t.Fatalf("ParseJSONMap(@file) = %#v, want parsed map", got) + } +} + +func TestParseJSONMap_FromStdin(t *testing.T) { + got, err := ParseJSONMap("-", "--params", strings.NewReader(`{"a":"1"}`)) + if err != nil { + t.Fatalf("ParseJSONMap(-) error = %v", err) + } + if got["a"] != "1" { + t.Fatalf("ParseJSONMap(-) = %#v, want a=1", got) + } +} + +func TestParseOptionalBody_FromFile(t *testing.T) { + tmpDir := t.TempDir() + TestChdir(t, tmpDir) + if err := vfs.WriteFile("data.json", []byte(`{"id":"1"}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got, err := ParseOptionalBody("POST", "@data.json", nil) + if err != nil { + t.Fatalf("ParseOptionalBody(@file) error = %v", err) + } + body, ok := got.(map[string]any) + if !ok || body["id"] != "1" { + t.Fatalf("ParseOptionalBody(@file) = %#v, want parsed object", got) + } +} + +func TestResolveStructuredInput_EmptyFilePath(t *testing.T) { + _, err := ResolveStructuredInput("@", "--params", nil) + if err == nil { + t.Fatal("expected error for empty @file path") + } +}