mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
* 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
298 lines
7.0 KiB
Go
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
|
|
}
|