Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 4 additions & 19 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package api

import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down
37 changes: 37 additions & 0 deletions cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ EXAMPLES:
lark-cli api GET /open-apis/calendar/v4/calendars

FLAGS:
--params <json> URL/query parameters JSON
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
--params <json> URL/query parameters JSON, @file, or - for stdin
--data <json> request body JSON, @file, or - for stdin
--as <type> identity type: user | bot | auto (default: auto)
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
--page-all automatically paginate through all pages
Expand Down
13 changes: 7 additions & 6 deletions cmd/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package service

import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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), &params); 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{}{}
Expand Down Expand Up @@ -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
}
Expand Down
39 changes: 38 additions & 1 deletion cmd/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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{}{
Expand Down
57 changes: 53 additions & 4 deletions internal/cmdutil/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
}
}
61 changes: 58 additions & 3 deletions internal/cmdutil/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@

package cmdutil

import "testing"
import (
"strings"
"testing"

"github.com/larksuite/cli/internal/vfs"
)

func TestParseOptionalBody(t *testing.T) {
tests := []struct {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")
}
}
Loading