Skip to content
Open
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
13 changes: 7 additions & 6 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@
},
}

cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
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 @@ -112,6 +112,7 @@
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)

// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
Expand All @@ -123,7 +124,7 @@
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}

params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
Expand All @@ -145,7 +146,7 @@
// Parse --data as JSON map for form fields (not as body).
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
Expand All @@ -161,7 +162,7 @@
}

fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,

Check warning on line 165 in cmd/api/api.go

View check run for this annotation

Codecov / codecov/patch

cmd/api/api.go#L165

Added line #L165 was not covered by tests
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
Expand All @@ -171,7 +172,7 @@
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
// Normal path: JSON body.
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
Expand Down
13 changes: 7 additions & 6 deletions cmd/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,10 @@
},
}

cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
Expand Down Expand Up @@ -354,6 +354,7 @@
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)

// Validate --file mutual exclusions.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
Expand All @@ -362,7 +363,7 @@
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
Expand Down Expand Up @@ -431,7 +432,7 @@
// Parse --data as form fields.
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
Expand All @@ -447,7 +448,7 @@
}

fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,

Check warning on line 451 in cmd/service/service.go

View check run for this annotation

Codecov / codecov/patch

cmd/service/service.go#L451

Added line #L451 was not covered by tests
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
Expand All @@ -456,7 +457,7 @@
request.Data = fd
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
Expand Down
13 changes: 7 additions & 6 deletions internal/cmdutil/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,20 @@ import (
"encoding/json"
"io"

"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)

// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-) and single-quote stripping via ResolveInput.
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
resolved, err := ResolveInput(data, stdin)
resolved, err := ResolveInput(data, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
Expand All @@ -34,9 +35,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, e
}

// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
// Supports stdin (-) and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin)
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("%s: %s", label, err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cmdutil/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,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, nil)
got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
Expand Down Expand Up @@ -53,7 +53,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, nil)
got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return
Expand Down
67 changes: 61 additions & 6 deletions internal/cmdutil/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,27 @@
package cmdutil

import (
"errors"
"fmt"
"io"
"strings"

"github.com/larksuite/cli/extension/fileio"
)

// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
// - "-" → read all bytes from stdin
// - "@<path>" → read all bytes from the file at <path> via fileIO
// - "@@..." → strip leading @ (escape for a literal @-prefixed value)
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// fileIO is required for "@<path>" inputs and goes through path validation
// (SafeInputPath); pass nil only when callers know "@" inputs are not possible.
//
// This allows callers to bypass shell quoting issues (especially on Windows
// PowerShell) by piping JSON via stdin instead of command-line arguments.
func ResolveInput(raw string, stdin io.Reader) (string, error) {
// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5)
// by reading JSON from a file (@path) or piping via stdin (-).
func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, error) {
if raw == "" {
return "", nil
}
Expand All @@ -37,10 +45,57 @@
return s, nil
}

// escape: @@... → literal @... (no file read)
if strings.HasPrefix(raw, "@@") {
return raw[1:], nil
}

// file: @path
if strings.HasPrefix(raw, "@") {
path := strings.TrimSpace(raw[1:])
if path == "" {
return "", fmt.Errorf("file path cannot be empty after @")
}
data, err := ReadInputFile(fileIO, path)
if err != nil {
return "", err
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("file %q is empty", path)
}
return s, nil
}

// strip surrounding single quotes (Windows cmd.exe passes them literally)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
raw = raw[1 : len(raw)-1]
}

return raw, nil
}

// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")
}
f, err := fileIO.Open(path)
if err != nil {
return nil, wrapInputFileError(path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, wrapInputFileError(path, err)

Check warning on line 91 in internal/cmdutil/resolve.go

View check run for this annotation

Codecov / codecov/patch

internal/cmdutil/resolve.go#L91

Added line #L91 was not covered by tests
}
return data, nil
}

func wrapInputFileError(path string, err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return fmt.Errorf("invalid file path %q: %w", path, err)
}
return fmt.Errorf("cannot read file %q: %w", path, err)
}
Loading
Loading