Files
larksuite-cli/internal/cmdutil/fileupload.go
MaxHuang22 344ff88701 feat: add --file flag for multipart/form-data file uploads (#395)
* feat(cmdutil): add shared file upload helpers

Add ParseFileFlag, ValidateFileFlag, and BuildFormdata to support
multipart file upload via --file flag across raw API and meta API commands.

Change-Id: Ib724cf8b055b0b314af11d8d830f38559dac60eb

* feat(api): add --file flag for multipart/form-data file uploads

Add --file flag to `lark-cli api` command enabling file upload via
multipart/form-data. The flag accepts [field=]path format and supports
stdin (-). Includes mutual exclusion validation with --output,
--page-all, and GET method. Dry-run mode shows file metadata instead
of building actual formdata.

Change-Id: Icf34aba5da3a558219a97a583e8f6aa951ded199

* feat(service): add --file flag with auto-detection from metadata

Add file upload support to meta API service method commands. The --file
flag is conditionally registered only for methods whose metadata declares
file-type fields (POST/PUT/PATCH/DELETE). The default field name is
auto-detected from metadata when exactly one file field exists.

Change-Id: Ibbf04eb42341ba11bb1fd9750e63bc1d0eacd08d

* feat(schema): show file upload indicators in method detail display

Add hasFileFields helper to detect file-type fields in requestBody
metadata. Modify printMethodDetail to display [file upload] tag on
--data line, --file flag description with default field name, and
--file <path> in CLI example for methods that accept file uploads.

Change-Id: Iae3bc14fe07e16a8b5f6a50a2b3592d6d8490ed9

* fix: address code review findings for file upload feature

- ParseFileFlag: change idx >= 0 to idx > 0 to prevent empty field name
  when input like "=photo.jpg" is passed
- BuildFormdata: read file into bytes.Reader with defer Close to prevent
  file handle leak on later errors
- BuildFormdata: remove unused ctx parameter from signature and callers
- Eliminate duplicated dry-run logic by having buildAPIRequest and
  buildServiceRequest return FileUploadMeta when in dry-run mode,
  removing ~60 lines of copy-pasted URL building and validation code

Change-Id: I27b9534fd0eaefce40390f6e723dd0c04a2cdf80

* fix: address PR review findings

- Remove opts.File=="" guard on dual-stdin check so --file photo.jpg
  --params - --data - correctly reports an error instead of silently
  dropping --data content (P1 bug in both api.go and service.go)
- Extract shared DetectFileFields into cmdutil, deduplicate
  detectFileFields (service.go) and hasFileFields (schema.go)
- Show "<stdin>" instead of empty path in dry-run output for --file -

Change-Id: Iccc5d879165ea6a3d04f0425ec6a5018a10e72e1

* fix: reject non-object --data with --file and improve multi-file schema

- --data with --file now requires a JSON object; arrays/strings/numbers
  are rejected with a clear error instead of being silently dropped
- Schema display for multi-file methods shows explicit field=path syntax
  and lists valid field names instead of advertising a false default

Change-Id: I0facdb3ad86f68cb125c7ea109a33714fd91dba0
2026-04-10 17:49:41 +08:00

131 lines
3.8 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// DetectFileFields returns field names with type "file" in the method's requestBody.
func DetectFileFields(method map[string]interface{}) []string {
rb, _ := method["requestBody"].(map[string]interface{})
var fields []string
for name, field := range rb {
f, _ := field.(map[string]interface{})
if registry.GetStrFromMap(f, "type") == "file" {
fields = append(fields, name)
}
}
return fields
}
// 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 output.ErrValidation("--file: empty file path")
}
if outputPath != "" {
return output.ErrValidation("--file and --output are mutually exclusive")
}
if pageAll {
return output.ErrValidation("--file and --page-all are mutually exclusive")
}
if isStdin && data == "-" {
return output.ErrValidation("--file and --data cannot both read from stdin")
}
if isStdin && params == "-" {
return output.ErrValidation("--file and --params cannot both read from stdin")
}
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return output.ErrValidation("--file requires POST, PUT, PATCH, or DELETE method")
}
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, output.ErrValidation("--file: stdin is not available")
}
data, err := io.ReadAll(stdin)
if err != nil {
return nil, output.ErrValidation("--file: failed to read stdin: %v", err)
}
if len(data) == 0 {
return nil, output.ErrValidation("--file: stdin is empty")
}
fd.AddFile(fieldName, bytes.NewReader(data))
} else {
f, err := fileIO.Open(filePath)
if err != nil {
return nil, output.ErrValidation("cannot open file: %s", filePath)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, output.ErrValidation("--file: failed to read %s: %v", filePath, err)
}
fd.AddFile(fieldName, 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, fmt.Sprintf("%v", v))
}
}
return fd, nil
}