mirror of
https://github.com/larksuite/cli.git
synced 2026-07-11 03:43:42 +08:00
* chore: bump oapi-sdk-go/v3 to v3.7.2 for filename-aware multipart upload * fix: preserve original filename in multipart file upload BuildFormdata read local files into a bytes.Reader before handing them to the SDK, so the SDK's part-filename detection (which only reads *os.File) fell back to "unknown-file" for every local --file upload. Use AddFileWithName with the file's basename instead.
157 lines
5.5 KiB
Go
157 lines
5.5 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmdutil
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
)
|
|
|
|
// ParseFileFlag parses a --file flag value into its components.
|
|
// The format is either "path" or "field=path". When no explicit "field="
|
|
// prefix is present, defaultField is used as the field name.
|
|
// A path of "-" indicates stdin; in that case filePath is empty and isStdin is true.
|
|
func ParseFileFlag(raw, defaultField string) (fieldName, filePath string, isStdin bool) {
|
|
if idx := strings.IndexByte(raw, '='); idx > 0 {
|
|
fieldName = raw[:idx]
|
|
filePath = raw[idx+1:]
|
|
} else {
|
|
fieldName = defaultField
|
|
filePath = raw
|
|
}
|
|
if filePath == "-" {
|
|
return fieldName, "", true
|
|
}
|
|
return fieldName, filePath, false
|
|
}
|
|
|
|
// ValidateFileFlag checks mutual exclusion rules for the --file flag.
|
|
// Returns nil if file is empty (flag not provided).
|
|
func ValidateFileFlag(file, params, data, outputPath string, pageAll bool, httpMethod string) error {
|
|
if file == "" {
|
|
return nil
|
|
}
|
|
|
|
_, filePath, isStdin := ParseFileFlag(file, "file")
|
|
if !isStdin && filePath == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: empty file path").
|
|
WithParam("--file")
|
|
}
|
|
|
|
if outputPath != "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --output are mutually exclusive").WithParams(
|
|
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --output"},
|
|
errs.InvalidParam{Name: "--output", Reason: "mutually exclusive with --file"},
|
|
)
|
|
}
|
|
if pageAll {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --page-all are mutually exclusive").WithParams(
|
|
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --page-all"},
|
|
errs.InvalidParam{Name: "--page-all", Reason: "mutually exclusive with --file"},
|
|
)
|
|
}
|
|
if isStdin && data == "-" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --data cannot both read from stdin").WithParams(
|
|
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
|
|
errs.InvalidParam{Name: "--data", Reason: "only one flag may read from stdin"},
|
|
)
|
|
}
|
|
if isStdin && params == "-" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --params cannot both read from stdin").WithParams(
|
|
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
|
|
errs.InvalidParam{Name: "--params", Reason: "only one flag may read from stdin"},
|
|
)
|
|
}
|
|
|
|
switch httpMethod {
|
|
case "POST", "PUT", "PATCH", "DELETE":
|
|
default:
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file requires POST, PUT, PATCH, or DELETE method").
|
|
WithParam("--file").
|
|
WithHint("file upload only applies to write methods; remove --file for read methods")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FileUploadMeta holds file upload metadata for dry-run display.
|
|
// Returned by request builders when dry-run mode skips actual file reading.
|
|
type FileUploadMeta struct {
|
|
FieldName string
|
|
FilePath string
|
|
FormFields any
|
|
}
|
|
|
|
// BuildFormdata constructs a multipart form data payload for file upload.
|
|
// If isStdin is true, the file content is read from stdin.
|
|
// Top-level keys from dataJSON are added as text form fields.
|
|
func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin bool, stdin io.Reader, dataJSON any) (*larkcore.Formdata, error) {
|
|
fd := larkcore.NewFormdata()
|
|
|
|
if isStdin {
|
|
if stdin == nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "--file: stdin is not available").
|
|
WithParam("--file").
|
|
WithHint("pipe the file content to stdin, or pass a file path instead of \"-\"")
|
|
}
|
|
data, err := io.ReadAll(stdin)
|
|
if err != nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read stdin: %v", err).
|
|
WithParam("--file").
|
|
WithCause(err)
|
|
}
|
|
if len(data) == 0 {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: stdin is empty").
|
|
WithParam("--file").
|
|
WithHint("pipe non-empty file content to stdin")
|
|
}
|
|
fd.AddFile(fieldName, bytes.NewReader(data))
|
|
} else {
|
|
f, err := fileIO.Open(filePath)
|
|
if err != nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot open file: %s", filePath).
|
|
WithParam("--file").
|
|
WithCause(err)
|
|
}
|
|
defer f.Close()
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read %s: %v", filePath, err).
|
|
WithParam("--file").
|
|
WithCause(err)
|
|
}
|
|
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
|
|
}
|
|
|
|
// Add top-level JSON keys as text form fields.
|
|
if m, ok := dataJSON.(map[string]any); ok {
|
|
for k, v := range m {
|
|
fd.AddField(k, formatFormFieldValue(v))
|
|
}
|
|
}
|
|
|
|
return fd, nil
|
|
}
|
|
|
|
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
|
|
// field string. float64 is handled specially: fmt's default %v/%g switches to
|
|
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
|
|
// backends reject when parsing the field as an integer. Use decimal notation
|
|
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
|
|
// All other types fall through to %v.
|
|
func formatFormFieldValue(v any) string {
|
|
if n, ok := v.(float64); ok {
|
|
return strconv.FormatFloat(n, 'f', -1, 64)
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|