Files
larksuite-cli/internal/cmdutil/dryrun.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

298 lines
7.0 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"fmt"
"io"
"net/url"
"sort"
"strings"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
// DryRunAPICall describes a single API call in dry-run output.
type DryRunAPICall struct {
Desc string `json:"desc,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params,omitempty"`
Body interface{} `json:"body,omitempty"`
}
// DryRunAPI is the builder and result type for dry-run output.
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
type DryRunAPI struct {
desc string
calls []DryRunAPICall
extra map[string]interface{}
}
func NewDryRunAPI() *DryRunAPI {
return &DryRunAPI{extra: map[string]interface{}{}}
}
// --- HTTP method builders (add a call, return self for chaining) ---
func (d *DryRunAPI) GET(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
return d
}
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
return d
}
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
return d
}
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
return d
}
// Body sets the request body on the last added call.
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Body = body
}
return d
}
// Params sets query parameters on the last added call.
func (d *DryRunAPI) Params(params map[string]interface{}) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Params = params
}
return d
}
// Desc sets a description on the last added call.
// If no calls exist yet, sets the top-level description.
func (d *DryRunAPI) Desc(desc string) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Desc = desc
} else {
d.desc = desc
}
return d
}
// Set adds an extra context field. Values are also used to resolve :key placeholders in URLs.
func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
d.extra[key] = value
return d
}
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
func (d *DryRunAPI) resolveURL(rawURL string) string {
for k, v := range d.extra {
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
}
return rawURL
}
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
resolved := make([]DryRunAPICall, len(d.calls))
for i, c := range d.calls {
resolved[i] = DryRunAPICall{
Desc: c.Desc,
Method: c.Method,
URL: d.resolveURL(c.URL),
Params: c.Params,
Body: c.Body,
}
}
m := make(map[string]interface{}, len(d.extra)+2)
if d.desc != "" {
m["description"] = d.desc
}
m["api"] = resolved
for k, v := range d.extra {
m[k] = v
}
return json.Marshal(m)
}
// Format renders the dry-run output as plain text for AI/human consumption.
func (d *DryRunAPI) Format() string {
var b strings.Builder
if d.desc != "" {
b.WriteString("# ")
b.WriteString(d.desc)
b.WriteByte('\n')
}
for i, c := range d.calls {
if i > 0 || d.desc != "" {
b.WriteByte('\n')
}
if c.Desc != "" {
b.WriteString("# ")
b.WriteString(c.Desc)
b.WriteByte('\n')
}
u := d.resolveURL(c.URL)
if len(c.Params) > 0 {
u += "?" + encodeParams(c.Params)
}
method := c.Method
if method == "" {
method = "GET"
}
b.WriteString(method)
b.WriteByte(' ')
b.WriteString(u)
b.WriteByte('\n')
if !util.IsNil(c.Body) {
j, _ := json.Marshal(c.Body)
b.WriteString(" ")
b.Write(j)
b.WriteByte('\n')
}
}
if len(d.calls) == 0 && len(d.extra) > 0 {
if d.desc != "" {
b.WriteByte('\n')
}
keys := make([]string, 0, len(d.extra))
for k := range d.extra {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
sv := dryRunFormatValue(d.extra[k])
if sv == "" {
continue
}
b.WriteString(k)
b.WriteString(": ")
b.WriteString(sv)
b.WriteByte('\n')
}
}
return b.String()
}
func dryRunFormatValue(v interface{}) string {
switch val := v.(type) {
case string:
return val
case nil:
return ""
default:
j, _ := json.Marshal(val)
return string(j)
}
}
func encodeParams(params map[string]interface{}) string {
vals := url.Values{}
for k, v := range params {
vals.Set(k, fmt.Sprintf("%v", v))
}
return vals.Encode()
}
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
filePathDisplay := filePath
if filePathDisplay == "" {
filePathDisplay = "<stdin>"
}
fileInfo := map[string]any{
"file": map[string]string{"field": fileField, "path": filePathDisplay},
}
if formFields != nil {
fileInfo["form_fields"] = formFields
}
fileInfo["options"] = []string{"WithFileUpload"}
dr.Body(fileInfo)
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
}
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
// When format is "pretty", outputs human-readable text; otherwise JSON.
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
if !util.IsNil(request.Data) {
dr.Body(request.Data)
}
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
}