mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
refactor: converge command pipelines onto a typed metadata model + catalog (#1191)
This commit is contained in:
@@ -66,6 +66,24 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null parses to a nil map; writing page_size onto it must
|
||||
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
|
||||
// write into the map ParseJSONMap returns.
|
||||
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "page_size") {
|
||||
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_BotMode(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -92,16 +92,11 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
// Fallback: read from from_meta spec (legacy)
|
||||
meta := registry.LoadFromMeta(name)
|
||||
// Fallback: read from the typed service spec (legacy)
|
||||
dm := domainMeta{Name: name}
|
||||
if meta != nil {
|
||||
if t, ok := meta["title"].(string); ok {
|
||||
dm.Title = t
|
||||
}
|
||||
if d, ok := meta["description"].(string); ok {
|
||||
dm.Description = d
|
||||
}
|
||||
if svc, ok := registry.ServiceTyped(name); ok {
|
||||
dm.Title = svc.Title
|
||||
dm.Description = svc.Description
|
||||
}
|
||||
return dm
|
||||
}
|
||||
|
||||
52
cmd/command_catalog_path_test.go
Normal file
52
cmd/command_catalog_path_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
|
||||
// service command tree for any depth — flat dotted resources AND genuinely
|
||||
// nested resources — so it round-trips through apicatalog.Resolve instead of
|
||||
// assuming a fixed root->service->resource->method shape.
|
||||
func TestCommandCatalogPath(t *testing.T) {
|
||||
chain := func(names ...string) *cobra.Command {
|
||||
var parent, leaf *cobra.Command
|
||||
for _, n := range names {
|
||||
c := &cobra.Command{Use: n}
|
||||
if parent != nil {
|
||||
parent.AddCommand(c)
|
||||
}
|
||||
parent = c
|
||||
leaf = c
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
leaf *cobra.Command
|
||||
want []string
|
||||
}{
|
||||
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
|
||||
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
|
||||
{"service level", chain("lark-cli", "im"), []string{"im"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The root command (no parent) has no catalog path.
|
||||
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
|
||||
t.Errorf("root path = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -118,38 +119,37 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command from the embedded from_meta registry.
|
||||
// service/resource/method command. It reconstructs the catalog path from the
|
||||
// command ancestry and resolves it through the same navigation Module the
|
||||
// command tree is built from (apicatalog), so it stays correct for nested
|
||||
// resources instead of hard-coding a root->service->resource->method depth.
|
||||
// Non-method commands (services, resources, shortcuts) resolve to a non-method
|
||||
// target and yield no scopes.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
// Service-method scope lookup only applies to commands mounted as
|
||||
// root -> service -> resource -> method. Non-resource/method commands
|
||||
// intentionally return no scopes here so auth-hint enrichment does not
|
||||
// change runtime semantics for other command shapes.
|
||||
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
|
||||
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(cmd.Name(), "+") {
|
||||
path := commandCatalogPath(cmd)
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
target, err := registry.RuntimeCatalog().Resolve(path)
|
||||
if err != nil || target.Kind != apicatalog.TargetMethod {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
|
||||
}
|
||||
|
||||
service := cmd.Parent().Parent().Name()
|
||||
resource := cmd.Parent().Name()
|
||||
method := cmd.Name()
|
||||
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
return nil
|
||||
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
|
||||
// from a command's ancestry, excluding the root command. It is the inverse of
|
||||
// the service command tree's construction, so any depth (flat or nested)
|
||||
// round-trips through apicatalog.Resolve.
|
||||
func commandCatalogPath(cmd *cobra.Command) []string {
|
||||
var path []string
|
||||
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
|
||||
path = append([]string{c.Name()}, path...)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resMap, _ := resources[resource].(map[string]interface{})
|
||||
if resMap == nil {
|
||||
return nil
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methodMap, _ := methods[method].(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(methodMap, identity)
|
||||
return path
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
|
||||
@@ -36,7 +36,7 @@ const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
USAGE:
|
||||
lark-cli <command> [subcommand] [method] [options]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method> [--format pretty]
|
||||
lark-cli schema <service.resource.method>
|
||||
|
||||
EXAMPLES:
|
||||
# View upcoming events
|
||||
|
||||
@@ -5,17 +5,17 @@ package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"errors"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -24,336 +24,10 @@ type SchemaOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string // first positional, when only one is given
|
||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
||||
|
||||
// Flags
|
||||
Format string
|
||||
}
|
||||
|
||||
func printServices(w io.Writer) {
|
||||
services := registry.ListFromMetaProjects()
|
||||
fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset)
|
||||
for _, s := range services {
|
||||
spec := registry.LoadFromMeta(s)
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
if title == "" {
|
||||
title = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
|
||||
}
|
||||
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
|
||||
name := registry.GetStrFromMap(spec, "name")
|
||||
version := registry.GetStrFromMap(spec, "version")
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
if title == "" {
|
||||
title = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
|
||||
fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title)
|
||||
fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset)
|
||||
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
for _, resName := range sortedKeys(resources) {
|
||||
resMap, _ := resources[resName].(map[string]interface{})
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
if len(methods) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
for _, methodName := range sortedKeys(methods) {
|
||||
m, _ := methods[methodName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
danger := ""
|
||||
if d, _ := m["danger"].(bool); d {
|
||||
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
|
||||
}
|
||||
|
||||
// hasFileFields returns true if any requestBody field has type "file".
|
||||
func hasFileFields(method map[string]interface{}) (bool, []string) {
|
||||
names := cmdutil.DetectFileFields(method)
|
||||
return len(names) > 0, names
|
||||
}
|
||||
|
||||
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
methodPath := registry.GetStrFromMap(method, "path")
|
||||
fullPath := servicePath + "/" + methodPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
isFileUpload, fileFieldNames := hasFileFields(method)
|
||||
|
||||
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
|
||||
|
||||
httpColor := output.Yellow
|
||||
if httpMethod == "GET" {
|
||||
httpColor = output.Green
|
||||
} else if httpMethod == "DELETE" {
|
||||
httpColor = output.Red
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath)
|
||||
if desc != "" {
|
||||
fmt.Fprintf(w, " %s\n", desc)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Parameters
|
||||
params, _ := method["parameters"].(map[string]interface{})
|
||||
if len(params) > 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
fmt.Fprintf(w, " %s--params%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
for _, paramName := range sortedParamKeys(params) {
|
||||
p, _ := params[paramName].(map[string]interface{})
|
||||
pType := registry.GetStrFromMap(p, "type")
|
||||
if pType == "" {
|
||||
pType = "string"
|
||||
}
|
||||
location := registry.GetStrFromMap(p, "location")
|
||||
required, _ := p["required"].(bool)
|
||||
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
|
||||
if required {
|
||||
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
|
||||
}
|
||||
locColor := output.Dim
|
||||
if location == "path" {
|
||||
locColor = output.Yellow
|
||||
}
|
||||
// Options (enum values)
|
||||
optStr := formatOptions(p)
|
||||
fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr)
|
||||
if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" {
|
||||
pdesc = util.TruncateStrWithEllipsis(pdesc, 100)
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset)
|
||||
}
|
||||
if ex := registry.GetStrFromMap(p, "example"); ex != "" {
|
||||
fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset)
|
||||
}
|
||||
if rangeStr := formatRange(p); rangeStr != "" {
|
||||
fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --data for write methods
|
||||
if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" {
|
||||
if len(params) == 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
}
|
||||
fileUploadTag := ""
|
||||
if isFileUpload {
|
||||
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
|
||||
requestBody, _ := method["requestBody"].(map[string]interface{})
|
||||
if len(requestBody) > 0 {
|
||||
printNestedFields(w, requestBody, " ", "")
|
||||
}
|
||||
|
||||
if isFileUpload {
|
||||
if len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Response
|
||||
responseBody, _ := method["responseBody"].(map[string]interface{})
|
||||
if len(responseBody) > 0 {
|
||||
fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset)
|
||||
printNestedFields(w, responseBody, " ", "")
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Identity
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
var identities []string
|
||||
for _, t := range tokens {
|
||||
if s, ok := t.(string); ok {
|
||||
switch s {
|
||||
case "user":
|
||||
identities = append(identities, "user")
|
||||
case "tenant":
|
||||
identities = append(identities, "bot")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(identities) > 0 {
|
||||
fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes (all)
|
||||
if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 {
|
||||
var scopeStrs []string
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok {
|
||||
scopeStrs = append(scopeStrs, str)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", "))
|
||||
}
|
||||
|
||||
// CLI example
|
||||
if isFileUpload && len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else if isFileUpload {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
}
|
||||
|
||||
// Docs
|
||||
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
|
||||
fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) {
|
||||
for _, fieldName := range sortedFieldKeys(fields) {
|
||||
f, _ := fields[fieldName].(map[string]interface{})
|
||||
fullName := fieldName
|
||||
if prefix != "" {
|
||||
fullName = prefix + "." + fieldName
|
||||
}
|
||||
fType := registry.GetStrFromMap(f, "type")
|
||||
required, _ := f["required"].(bool)
|
||||
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
|
||||
if required {
|
||||
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
|
||||
}
|
||||
optStr := formatOptions(f)
|
||||
fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr)
|
||||
desc := registry.GetStrFromMap(f, "description")
|
||||
if desc != "" {
|
||||
desc = util.TruncateStrWithEllipsis(desc, 100)
|
||||
fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset)
|
||||
}
|
||||
if ex := registry.GetStrFromMap(f, "example"); ex != "" {
|
||||
fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset)
|
||||
}
|
||||
if rangeStr := formatRange(f); rangeStr != "" {
|
||||
fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset)
|
||||
}
|
||||
if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 {
|
||||
printNestedFields(w, props, indent+" ", fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatOptions returns " — val1 | val2 | ..." if field has options, else "".
|
||||
func formatOptions(f map[string]interface{}) string {
|
||||
opts, ok := f["options"].([]interface{})
|
||||
if !ok || len(opts) == 0 {
|
||||
return ""
|
||||
}
|
||||
var vals []string
|
||||
for _, o := range opts {
|
||||
if om, ok := o.(map[string]interface{}); ok {
|
||||
if v := registry.GetStrFromMap(om, "value"); v != "" {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset)
|
||||
}
|
||||
|
||||
// formatRange returns "min..max" if field has min/max, else "".
|
||||
func formatRange(f map[string]interface{}) string {
|
||||
minVal := registry.GetStrFromMap(f, "min")
|
||||
maxVal := registry.GetStrFromMap(f, "max")
|
||||
if minVal == "" && maxVal == "" {
|
||||
return ""
|
||||
}
|
||||
if minVal != "" && maxVal != "" {
|
||||
return minVal + ".." + maxVal
|
||||
}
|
||||
if minVal != "" {
|
||||
return ">=" + minVal
|
||||
}
|
||||
return "<=" + maxVal
|
||||
}
|
||||
|
||||
// sortedKeys returns map keys in alphabetical order.
|
||||
func sortedKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// sortedParamKeys returns parameter keys sorted: required first, then alphabetical.
|
||||
func sortedParamKeys(params map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
pi, _ := params[keys[i]].(map[string]interface{})
|
||||
pj, _ := params[keys[j]].(map[string]interface{})
|
||||
ri, _ := pi["required"].(bool)
|
||||
rj, _ := pj["required"].(bool)
|
||||
if ri != rj {
|
||||
return ri
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
// sortedFieldKeys returns field keys sorted: required first, then alphabetical.
|
||||
func sortedFieldKeys(fields map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
fi, _ := fields[keys[i]].(map[string]interface{})
|
||||
fj, _ := fields[keys[j]].(map[string]interface{})
|
||||
ri, _ := fi["required"].(bool)
|
||||
rj, _ := fj["required"].(bool)
|
||||
if ri != rj {
|
||||
return ri
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) {
|
||||
for i := len(parts); i >= 1; i-- {
|
||||
candidateName := strings.Join(parts[:i], ".")
|
||||
if res, ok := resources[candidateName]; ok {
|
||||
if resMap, ok := res.(map[string]interface{}); ok {
|
||||
return resMap, candidateName, parts[i:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", nil
|
||||
// Args are the positional path segments, in either the dotted single-arg
|
||||
// form ("im.messages.reply") or the space-separated form ("im messages
|
||||
// reply"); apicatalog.ParsePath normalizes both.
|
||||
Args []string
|
||||
}
|
||||
|
||||
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
|
||||
@@ -365,12 +39,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
opts.ExtraArgs = args[1:]
|
||||
}
|
||||
opts.Args = append([]string(nil), args...)
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -380,433 +49,89 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
// Tolerated for agent compatibility; ignored — schema only emits the JSON
|
||||
// envelope, and its output is identity-independent (strict-mode filtering
|
||||
// comes from ResolveStrictMode, never from --as).
|
||||
cmd.Flags().String("format", "json", "")
|
||||
cmd.Flags().Bool("json", true, "")
|
||||
cmd.Flags().String("as", "", "")
|
||||
_ = cmd.Flags().MarkHidden("format")
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
_ = cmd.Flags().MarkHidden("as")
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath(f)
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
||||
// newer space-separated form (e.g. `schema im messages reply`).
|
||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||
// It uses the embedded source so completion candidates match what `schema`
|
||||
// execution can resolve (both overlay-free).
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
|
||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
|
||||
// Case 2: space-form, args already has segments
|
||||
// Walk down service -> resource(s) -> method based on existing args
|
||||
serviceName := args[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// args[1:] are resource path segments (possibly partial); current
|
||||
// toComplete is the next segment under cursor.
|
||||
consumed := args[1:]
|
||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
||||
if resource == nil {
|
||||
// Suggest top-level resource names that match toComplete
|
||||
var completions []string
|
||||
for resName := range resources {
|
||||
if strings.HasPrefix(resName, toComplete) {
|
||||
completions = append(completions, resName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
// Already typed past the resource — suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Resource matched exactly, suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
return completions, directive
|
||||
}
|
||||
}
|
||||
|
||||
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
|
||||
var completions []string
|
||||
|
||||
for resName, resVal := range resources {
|
||||
if strings.HasPrefix(resName, afterService) {
|
||||
completions = append(completions, serviceName+"."+resName+".")
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(afterService, resName+".") {
|
||||
continue
|
||||
}
|
||||
methodPrefix := afterService[len(resName)+1:]
|
||||
resMap, _ := resVal.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
for methodName := range methods {
|
||||
if strings.HasPrefix(methodName, methodPrefix) {
|
||||
completions = append(completions, serviceName+"."+resName+"."+methodName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(completions)
|
||||
return completions
|
||||
}
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
// args may have arrived as a single string (legacy single-arg path) or
|
||||
// split into multiple — normalize to a single args slice.
|
||||
var rawArgs []string
|
||||
if opts.Path != "" {
|
||||
rawArgs = []string{opts.Path}
|
||||
}
|
||||
if len(opts.ExtraArgs) > 0 {
|
||||
if opts.Path != "" {
|
||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
||||
} else {
|
||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
||||
}
|
||||
}
|
||||
parts := schema.ParsePath(rawArgs)
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
return runPrettyMode(out, parts, mode)
|
||||
}
|
||||
return runJSONMode(out, parts, mode)
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
}
|
||||
|
||||
// runJSONMode dispatches list/single envelope output based on parts.
|
||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
||||
// output is deterministic across machines.
|
||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
filter := strictModeFilter(mode)
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
envs := schema.AssembleAll(filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
case 1:
|
||||
spec := registry.EmbeddedSpec(parts[0])
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(parts[0])
|
||||
// runSchema resolves the path through the embedded catalog and renders the
|
||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
catalog := registry.EmbeddedCatalog()
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
}
|
||||
refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode))
|
||||
if target.Kind == apicatalog.TargetMethod {
|
||||
if len(refs) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"Method %s not available in current identity mode", target.Method.SchemaPath()).
|
||||
WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token")
|
||||
}
|
||||
envs := schema.AssembleService(parts[0], spec, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
default:
|
||||
return runJSONForPath(out, parts, filter)
|
||||
}
|
||||
}
|
||||
|
||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
||||
// to single-method match. Uses embedded data only.
|
||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
||||
serviceName := parts[0]
|
||||
spec := registry.EmbeddedSpec(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(serviceName)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
// Resource-scoped envelope array
|
||||
envs := assembleResource(serviceName, resName, resource, filter)
|
||||
output.PrintJson(out, envs)
|
||||
output.PrintJson(out, schema.EnvelopeOf(refs[0]))
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but caller appended extra segments — reject so they
|
||||
// don't silently get this method's schema when they typo'd the path.
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
// Method exists in spec but filtered out by strict mode
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
||||
"Use --as user / --as bot to switch")
|
||||
}
|
||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
||||
output.PrintJson(out, env)
|
||||
output.PrintJson(out, schema.Envelopes(refs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
resourcePath := []string{resName}
|
||||
var envs []schema.Envelope
|
||||
for methodName, raw := range methods {
|
||||
method, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError
|
||||
// (CategoryValidation drives the exit code; Hint promotes to the envelope),
|
||||
// preserving the historical message + hint text.
|
||||
func resolveError(err error) error {
|
||||
var re *apicatalog.ResolveError
|
||||
if !errors.As(err, &re) {
|
||||
return err
|
||||
}
|
||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
||||
return envs
|
||||
}
|
||||
|
||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
if len(parts) == 0 {
|
||||
printServices(out)
|
||||
return nil
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownService(serviceName)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
printResourceList(out, spec, mode)
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
||||
// nil if strict mode is not active.
|
||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
return func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
return true // permissive when meta_data lacks accessTokens
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
||||
// overlay-only services would mislead callers when those services subsequently
|
||||
// fail to resolve in envelope output.
|
||||
func errUnknownEmbeddedService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() {
|
||||
return spec
|
||||
}
|
||||
result := make(map[string]interface{}, len(spec))
|
||||
for k, v := range spec {
|
||||
result[k] = v
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return result
|
||||
}
|
||||
filteredRes := make(map[string]interface{}, len(resources))
|
||||
for resName, resVal := range resources {
|
||||
resMap, ok := resVal.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
filtered := filterMethodsByStrictMode(methods, mode)
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
}
|
||||
resCopy := make(map[string]interface{}, len(resMap))
|
||||
for k, v := range resMap {
|
||||
resCopy[k] = v
|
||||
}
|
||||
resCopy["methods"] = filtered
|
||||
filteredRes[resName] = resCopy
|
||||
}
|
||||
result["resources"] = filteredRes
|
||||
return result
|
||||
}
|
||||
|
||||
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
|
||||
// Returns the original map unmodified when strict mode is off.
|
||||
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() || methods == nil {
|
||||
return methods
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
filtered := make(map[string]interface{}, len(methods))
|
||||
for name, val := range methods {
|
||||
m, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tokens, _ := m["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
filtered[name] = val
|
||||
continue
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == token {
|
||||
filtered[name] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
switch re.Kind {
|
||||
case apicatalog.ErrService:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrResource:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrMethod:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrPath:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject).
|
||||
WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,29 +20,46 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"})
|
||||
cmd.SetArgs([]string{"calendar.events.list"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Path != "calendar.events.list" {
|
||||
t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path)
|
||||
}
|
||||
if gotOpts.Format != "pretty" {
|
||||
t.Errorf("expected Format=pretty, got %s", gotOpts.Format)
|
||||
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
|
||||
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
|
||||
// Agents are habituated to --format/--json/--as from api/service commands.
|
||||
// schema must accept them without erroring and always emit the JSON envelope —
|
||||
// its output is structured JSON and identity-independent, so the values have
|
||||
// no effect.
|
||||
argSets := [][]string{
|
||||
{"--format", "json"},
|
||||
{"--format", "pretty"},
|
||||
{"--format", "table"}, // no table rendering for a nested schema -> JSON
|
||||
{"--format", "csv"},
|
||||
{"--json"},
|
||||
{"--json", "--format", "ndjson"},
|
||||
{"--as", "user"},
|
||||
{"--as", "bot"},
|
||||
{"--as", "user", "--json"},
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Available services") {
|
||||
t.Error("expected service list in pretty mode")
|
||||
for _, extra := range argSets {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +67,7 @@ func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{}) // default --format json
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -76,7 +92,7 @@ func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
||||
cmd.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -179,23 +195,6 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Existing pretty rendering surfaces these markers — they must still appear
|
||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -212,168 +211,6 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_FileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"description": "Upload an image",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "images", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "file upload") {
|
||||
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file") {
|
||||
t.Errorf("expected '--file' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) {
|
||||
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file <path>") {
|
||||
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "calendar",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "events",
|
||||
"httpMethod": "POST",
|
||||
"description": "Create an event",
|
||||
"requestBody": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "events", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "file upload") {
|
||||
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--file") {
|
||||
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
wantBool bool
|
||||
wantFields []string
|
||||
}{
|
||||
{
|
||||
name: "has file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: true,
|
||||
wantFields: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, names := hasFileFields(tt.method)
|
||||
if got != tt.wantBool {
|
||||
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
|
||||
}
|
||||
if tt.wantFields == nil && names != nil {
|
||||
t.Errorf("expected nil names, got %v", names)
|
||||
}
|
||||
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
|
||||
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSchemaPathForSpec(t *testing.T) {
|
||||
resources := map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"create": map[string]interface{}{},
|
||||
"list": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"record_permissions": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"get": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := completeSchemaPathForSpec("base", resources, "records.cr")
|
||||
if len(got) != 1 || got[0] != "base.records.create" {
|
||||
t.Fatalf("completions = %v, want [base.records.create]", got)
|
||||
}
|
||||
|
||||
got = completeSchemaPathForSpec("base", resources, "record")
|
||||
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
|
||||
t.Fatalf("resource completions = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"resources": map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
|
||||
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
|
||||
resources, _ := filtered["resources"].(map[string]interface{})
|
||||
got := completeSchemaPathForSpec("base", resources, "records.")
|
||||
if len(got) != 1 || got[0] != "base.records.list" {
|
||||
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
|
||||
}
|
||||
}
|
||||
// Completion candidate generation (dotted + space forms, strict-mode filtering,
|
||||
// dotted-resource handling) now lives in internal/apicatalog and is covered by
|
||||
// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra.
|
||||
|
||||
80
cmd/service/affordance.go
Normal file
80
cmd/service/affordance.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// methodLong composes a method command's long help in one place: the
|
||||
// description, the affordance guidance block (when the method has one), the
|
||||
// pointer to the full schema, and the params-only addendum (params whose flag
|
||||
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
|
||||
// sits near the top so an agent sees when-to-use and few-shot examples before
|
||||
// the flag list.
|
||||
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(description)
|
||||
if affordance != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(affordance)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(paramsOnly)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderAffordance renders a method's affordance as a help block — when to use,
|
||||
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
|
||||
// the method carries no affordance. It reads the single typed model
|
||||
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
|
||||
func renderAffordance(m meta.Method) string {
|
||||
a, ok := m.ParsedAffordance()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
for _, it := range items {
|
||||
if strings.TrimSpace(it) != "" {
|
||||
nonEmpty = append(nonEmpty, it)
|
||||
}
|
||||
}
|
||||
if len(nonEmpty) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(&b, "%s:\n", title)
|
||||
for _, it := range nonEmpty {
|
||||
fmt.Fprintf(&b, " • %s\n", it)
|
||||
}
|
||||
}
|
||||
|
||||
bullets("When to use", a.UseWhen)
|
||||
bullets("Avoid when", a.DoNotUseWhen)
|
||||
bullets("Prerequisites", a.Prerequisites)
|
||||
if len(a.Examples) > 0 {
|
||||
var lines []string
|
||||
for _, ex := range a.Examples {
|
||||
if ex.Command == "" {
|
||||
continue
|
||||
}
|
||||
if ex.Description != "" {
|
||||
lines = append(lines, fmt.Sprintf(" • %s\n %s", ex.Description, ex.Command))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf(" • %s", ex.Command))
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
bullets("Related", a.Related)
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
72
cmd/service/affordance_test.go
Normal file
72
cmd/service/affordance_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
func TestRenderAffordance(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"use_when": ["发送文本消息"],
|
||||
"do_not_use_when": ["群已解散"],
|
||||
"prerequisites": ["已获取 chat_id"],
|
||||
"examples": [
|
||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||
{"command":"lark-cli im messages list"},
|
||||
{"description":"no command, skipped","command":""}
|
||||
],
|
||||
"related": ["im.messages.list"]
|
||||
}`)
|
||||
out := renderAffordance(meta.Method{Affordance: raw})
|
||||
for _, want := range []string{
|
||||
"When to use:", "发送文本消息",
|
||||
"Avoid when:", "群已解散",
|
||||
"Prerequisites:", "已获取 chat_id",
|
||||
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
||||
"lark-cli im messages list", // example with no description -> bare command line
|
||||
"Related:", "im.messages.list",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("renderAffordance missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "no command, skipped") {
|
||||
t.Errorf("example with empty command should be skipped:\n%s", out)
|
||||
}
|
||||
|
||||
// Absent or empty affordance renders nothing (so methods without an overlay
|
||||
// add nothing to their help).
|
||||
if renderAffordance(meta.Method{}) != "" || renderAffordance(meta.Method{Affordance: json.RawMessage(`{}`)}) != "" {
|
||||
t.Error("empty affordance should render nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
||||
withAff := map[string]interface{}{
|
||||
"path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"affordance": map[string]interface{}{
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||
},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
|
||||
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
|
||||
}
|
||||
|
||||
// A method with no affordance adds no guidance block.
|
||||
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
|
||||
if strings.Contains(cmd2.Long, "Examples:") {
|
||||
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
|
||||
}
|
||||
}
|
||||
211
cmd/service/flaggroups.go
Normal file
211
cmd/service/flaggroups.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Flag annotations the grouped service-method help renderer reads.
|
||||
const (
|
||||
flagGroupAnnotation = "lark_flag_group" // display group key
|
||||
flagSubAnnotation = "lark_flag_sub" // "required" | "optional" within API Parameters
|
||||
flagNoteAnnotation = "lark_flag_note" // extra lines shown indented under a flag
|
||||
|
||||
groupParams = "params" // typed path/query flags
|
||||
groupBody = "body" // --data, --file
|
||||
groupRaw = "raw" // --params
|
||||
groupExecution = "execution" // --as/--dry-run/--page-*/--yes
|
||||
groupOutput = "output" // --output/--format/--jq
|
||||
|
||||
subRequired = "required"
|
||||
subOptional = "optional"
|
||||
)
|
||||
|
||||
// serviceFlagGroupOrder is the display order + titles of the flag groups. API
|
||||
// Parameters carries only typed path/query flags; raw --params, request body and
|
||||
// execution/output controls each get their own group so an agent can tell the
|
||||
// distinct input kinds apart.
|
||||
var serviceFlagGroupOrder = []struct{ key, title string }{
|
||||
{groupParams, "API Parameters"},
|
||||
{groupBody, "Request Body"},
|
||||
{groupRaw, "Raw Parameter Input"},
|
||||
{groupExecution, "Execution"},
|
||||
{groupOutput, "Output"},
|
||||
}
|
||||
|
||||
// applyGroupedUsage installs the grouped usage renderer on a service method
|
||||
// cmd: local flags via the grouped renderer instead of cobra's flat Flags:
|
||||
// list; global (inherited) flags and the Risk/Tips sections appended by the
|
||||
// root help func are unaffected. Rendered by hand rather than via
|
||||
// cmd.SetUsageTemplate: cobra lazy-links text/template on the first
|
||||
// SetUsageTemplate call, whose executor reaches reflect.Value.MethodByName —
|
||||
// that disables the linker's method-level dead-code elimination and costs
|
||||
// ~19 MB of binary size.
|
||||
func applyGroupedUsage(cmd *cobra.Command) {
|
||||
cmd.SetUsageFunc(func(c *cobra.Command) error {
|
||||
w := c.OutOrStderr()
|
||||
fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine())
|
||||
if c.HasAvailableLocalFlags() {
|
||||
fmt.Fprintf(w, "\n%s\n", renderServiceFlagGroups(c))
|
||||
}
|
||||
if c.HasAvailableInheritedFlags() {
|
||||
fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n"))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func annotate(f *pflag.Flag, key string, vals []string) {
|
||||
if f.Annotations == nil {
|
||||
f.Annotations = map[string][]string{}
|
||||
}
|
||||
f.Annotations[key] = vals
|
||||
}
|
||||
|
||||
// tagFlagGroup records a flag's display group (no-op if the flag is absent).
|
||||
func tagFlagGroup(fs *pflag.FlagSet, name, group string) {
|
||||
if f := fs.Lookup(name); f != nil {
|
||||
annotate(f, flagGroupAnnotation, []string{group})
|
||||
}
|
||||
}
|
||||
|
||||
func annotationOf(f *pflag.Flag, key string) []string {
|
||||
if f.Annotations != nil {
|
||||
return f.Annotations[key]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagGroupOf(f *pflag.Flag) string {
|
||||
if v := annotationOf(f, flagGroupAnnotation); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func flagSubOf(f *pflag.Flag) string {
|
||||
if v := annotationOf(f, flagSubAnnotation); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderServiceFlagGroups renders the command's local flags into ordered,
|
||||
// titled groups; the API Parameters group is further split into Required /
|
||||
// Optional. It is the body of the usage func applyGroupedUsage installs.
|
||||
func renderServiceFlagGroups(cmd *cobra.Command) string {
|
||||
var b strings.Builder
|
||||
seen := map[*pflag.Flag]bool{}
|
||||
for _, g := range serviceFlagGroupOrder {
|
||||
flags := groupFlags(cmd, g.key, seen)
|
||||
if len(flags) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s:\n", g.title)
|
||||
if g.key == groupParams {
|
||||
writeSection(&b, " Required:", subFlags(flags, subRequired))
|
||||
writeSection(&b, " Optional:", subFlags(flags, subOptional))
|
||||
} else {
|
||||
writeSection(&b, "", flags)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
}
|
||||
// Anything untagged (e.g. -h/--help) goes last under "Other".
|
||||
var other []*pflag.Flag
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || seen[f] {
|
||||
return
|
||||
}
|
||||
other = append(other, f)
|
||||
})
|
||||
if len(other) > 0 {
|
||||
fmt.Fprintln(&b, "Other:")
|
||||
writeSection(&b, "", other)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// groupFlags returns the visible local flags tagged with group key, marking them
|
||||
// seen so the trailing "Other" bucket only catches genuinely untagged flags.
|
||||
func groupFlags(cmd *cobra.Command, key string, seen map[*pflag.Flag]bool) []*pflag.Flag {
|
||||
var flags []*pflag.Flag
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || flagGroupOf(f) != key {
|
||||
return
|
||||
}
|
||||
flags = append(flags, f)
|
||||
seen[f] = true
|
||||
})
|
||||
return flags
|
||||
}
|
||||
|
||||
func subFlags(flags []*pflag.Flag, sub string) []*pflag.Flag {
|
||||
var out []*pflag.Flag
|
||||
for _, f := range flags {
|
||||
s := flagSubOf(f)
|
||||
// Untagged subgroup defaults to Optional so nothing is dropped.
|
||||
if s == sub || (s == "" && sub == subOptional) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSection prints an optional (sub)header and the flags, aligned in a
|
||||
// column, each flag row followed by its note lines indented under the usage.
|
||||
func writeSection(b *strings.Builder, header string, flags []*pflag.Flag) {
|
||||
if len(flags) == 0 {
|
||||
return
|
||||
}
|
||||
if header != "" {
|
||||
fmt.Fprintf(b, "%s\n", header)
|
||||
}
|
||||
specs := make([]string, len(flags))
|
||||
maxSpec := 0
|
||||
for i, f := range flags {
|
||||
specs[i] = flagSpec(f)
|
||||
if len(specs[i]) > maxSpec {
|
||||
maxSpec = len(specs[i])
|
||||
}
|
||||
}
|
||||
for i, f := range flags {
|
||||
_, usage := pflag.UnquoteUsage(f)
|
||||
if showsDefault(f) {
|
||||
usage += fmt.Sprintf(" (default %s)", f.DefValue)
|
||||
}
|
||||
fmt.Fprintf(b, "%-*s %s\n", maxSpec, specs[i], strings.TrimSpace(usage))
|
||||
for _, note := range annotationOf(f, flagNoteAnnotation) {
|
||||
fmt.Fprintf(b, "%*s%s\n", maxSpec+3+4, "", note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flagSpec is pflag's " --name type" / " -x, --name type" left column.
|
||||
func flagSpec(f *pflag.Flag) string {
|
||||
typeName, _ := pflag.UnquoteUsage(f)
|
||||
spec := " --" + f.Name
|
||||
if f.Shorthand != "" && f.ShorthandDeprecated == "" {
|
||||
spec = " -" + f.Shorthand + ", --" + f.Name
|
||||
}
|
||||
if typeName != "" {
|
||||
spec += " " + typeName
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// showsDefault mirrors pflag's "non-zero default" rule for the flag types these
|
||||
// commands use, so the grouped rendering shows the same "(default x)" hints as
|
||||
// cobra's flat list.
|
||||
func showsDefault(f *pflag.Flag) bool {
|
||||
switch f.DefValue {
|
||||
case "", "0", "false", "[]":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
115
cmd/service/flaggroups_test.go
Normal file
115
cmd/service/flaggroups_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
func TestServiceFlagGroups_AgentContract(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "chats/:chat_id/members",
|
||||
"httpMethod": "POST",
|
||||
"parameters": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"member_id_type": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "open_id", "description": "以 open_id 标识用户"},
|
||||
map[string]interface{}{"value": "user_id", "description": "以 user_id 标识用户"},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Documented body field -> --data belongs under Request Body.
|
||||
"requestBody": map[string]interface{}{
|
||||
"id_list": map[string]interface{}{"type": "list", "required": true},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "create", "chat.members", nil)
|
||||
out := renderServiceFlagGroups(cmd)
|
||||
|
||||
idx := func(s string) int { return strings.Index(out, s) }
|
||||
|
||||
// Section order: API Parameters → Request Body → Raw Parameter Input → Execution → Output.
|
||||
iParams, iBody, iRaw, iExec, iOut := idx("API Parameters:"), idx("Request Body:"), idx("Raw Parameter Input:"), idx("Execution:"), idx("Output:")
|
||||
for name, i := range map[string]int{"API Parameters": iParams, "Request Body": iBody, "Raw Parameter Input": iRaw, "Execution": iExec, "Output": iOut} {
|
||||
if i < 0 {
|
||||
t.Fatalf("missing section %q in:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
if !(iParams < iBody && iBody < iRaw && iRaw < iExec && iExec < iOut) {
|
||||
t.Errorf("section order wrong:\n%s", out)
|
||||
}
|
||||
|
||||
// Required/Optional subsections under API Parameters.
|
||||
if i := idx(" Required:"); i < iParams || i > iBody {
|
||||
t.Errorf("Required subsection misplaced:\n%s", out)
|
||||
}
|
||||
if i := idx(" Optional:"); i < iParams || i > iBody {
|
||||
t.Errorf("Optional subsection misplaced:\n%s", out)
|
||||
}
|
||||
|
||||
// Typed flags are API Parameters; required path flag under Required, enum
|
||||
// flag under Optional with an inline "enum: ..." (not multi-line meanings).
|
||||
if i := idx("--chat-id"); i < iParams || i > iBody {
|
||||
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "chat_id, required") {
|
||||
t.Errorf("typed flag help format wrong:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||
}
|
||||
|
||||
// --data is Request Body; --params is Raw Parameter Input (NOT API Parameters)
|
||||
// and carries the precedence rule.
|
||||
if i := idx("--data"); i < iBody || i > iRaw {
|
||||
t.Errorf("--data not under Request Body:\n%s", out)
|
||||
}
|
||||
if i := idx("--params"); i < iRaw || i > iExec {
|
||||
t.Errorf("--params not under Raw Parameter Input:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "typed flags override matching keys in --params") {
|
||||
t.Errorf("missing --params precedence rule:\n%s", out)
|
||||
}
|
||||
|
||||
// Control flags land in Execution/Output.
|
||||
if i := idx("--dry-run"); i < iExec || i > iOut {
|
||||
t.Errorf("--dry-run not under Execution:\n%s", out)
|
||||
}
|
||||
if idx("--format") < iOut {
|
||||
t.Errorf("--format not under Output:\n%s", out)
|
||||
}
|
||||
|
||||
// The usage template is wired to the grouped renderer (no flat Flags: list).
|
||||
if u := cmd.UsageString(); !strings.Contains(u, "API Parameters:") || strings.Contains(u, "\nFlags:\n") {
|
||||
t.Errorf("usage template not grouped:\n%s", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceFlagGroups_UndocumentedBodyIsRaw: a POST with no documented body
|
||||
// fields still offers --data (escape hatch) but must NOT imply a declared body —
|
||||
// it goes under Raw Parameter Input, not "Request Body".
|
||||
func TestServiceFlagGroups_UndocumentedBodyIsRaw(t *testing.T) {
|
||||
method := map[string]interface{}{"path": "things/do", "httpMethod": "POST"} // POST, no requestBody, no params
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "do", "things", nil)
|
||||
out := renderServiceFlagGroups(cmd)
|
||||
|
||||
if strings.Contains(out, "Request Body:") {
|
||||
t.Errorf("undocumented body must not render a Request Body section:\n%s", out)
|
||||
}
|
||||
iRaw, iData := strings.Index(out, "Raw Parameter Input:"), strings.Index(out, "--data")
|
||||
if iRaw < 0 || iData < iRaw {
|
||||
t.Errorf("--data not under Raw Parameter Input:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "no documented fields") {
|
||||
t.Errorf("--data should be labeled a raw escape hatch:\n%s", out)
|
||||
}
|
||||
}
|
||||
166
cmd/service/paramflags.go
Normal file
166
cmd/service/paramflags.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
type boundParamFlag struct {
|
||||
field meta.Field
|
||||
read func() interface{}
|
||||
}
|
||||
|
||||
// paramsOnlyField is a path/query parameter that got no typed flag because its
|
||||
// kebab name is already taken by another flag (a standard flag like --format, or
|
||||
// a root persistent flag). It stays reachable via --params; the binder keeps it,
|
||||
// with the flag that claimed the name, so --help can show the exact --params form
|
||||
// and steer the reader off the wrong flag.
|
||||
type paramsOnlyField struct {
|
||||
field meta.Field
|
||||
claimed *pflag.Flag
|
||||
}
|
||||
|
||||
// paramFlagBinder owns one service method's generated typed param flags: it
|
||||
// registers them (kind, help, enum completion, reserved-name skip) and applies
|
||||
// the --params overlay, where a changed typed flag overrides its key in the
|
||||
// --params JSON. Holding the field<->flag binding here keeps the request builder
|
||||
// from re-deriving which flags map to which param keys.
|
||||
type paramFlagBinder struct {
|
||||
bound []boundParamFlag
|
||||
paramsOnly []paramsOnlyField
|
||||
}
|
||||
|
||||
// newParamFlagBinder registers one typed kebab flag per path/query parameter on
|
||||
// cmd and returns a binder for the --params overlay. A name already taken by
|
||||
// another flag is skipped — pflag panics on a local duplicate and a generated
|
||||
// flag would silently shadow a persistent one — and recorded as paramsOnly so
|
||||
// the parameter stays reachable (and discoverable) via --params. The taken set
|
||||
// is derived, not hand-listed: local flags (the standard set, registered before
|
||||
// this runs) via cmd, the lazily-added --help materialized here, and the root's
|
||||
// persistent flags via reserved (nil for direct callers that have no root).
|
||||
func newParamFlagBinder(cmd *cobra.Command, params []meta.Field, reserved *pflag.FlagSet) *paramFlagBinder {
|
||||
cmd.InitDefaultHelpFlag() // materialize --help/-h so the local guard below sees it
|
||||
b := ¶mFlagBinder{}
|
||||
for _, f := range params {
|
||||
name := f.FlagName()
|
||||
if claimed := flagClaiming(cmd, reserved, name); claimed != nil {
|
||||
b.paramsOnly = append(b.paramsOnly, paramsOnlyField{field: f, claimed: claimed})
|
||||
continue
|
||||
}
|
||||
read := registerTypedFlag(cmd.Flags(), name, f.CanonicalType(), paramFlagUsage(f))
|
||||
if values := enumStrings(f.EnumValues()); len(values) > 0 {
|
||||
cmdutil.RegisterFlagCompletion(cmd, name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return values, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
// Group as an API parameter and mark required/optional for the
|
||||
// Required/Optional subsections of the grouped --help renderer.
|
||||
if fl := cmd.Flags().Lookup(name); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupParams})
|
||||
sub := subOptional
|
||||
if f.Required {
|
||||
sub = subRequired
|
||||
}
|
||||
annotate(fl, flagSubAnnotation, []string{sub})
|
||||
}
|
||||
b.bound = append(b.bound, boundParamFlag{field: f, read: read})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// flagClaiming returns the flag already occupying name (so a typed param flag
|
||||
// would collide), or nil when the name is free. It checks the command's own
|
||||
// flags (the standard set + the materialized --help) and the root's persistent
|
||||
// flags — so the reserved set is whatever is actually registered, never a
|
||||
// hand-kept list that drifts when a global flag is added.
|
||||
func flagClaiming(cmd *cobra.Command, reserved *pflag.FlagSet, name string) *pflag.Flag {
|
||||
if fl := cmd.Flags().Lookup(name); fl != nil {
|
||||
return fl
|
||||
}
|
||||
if reserved != nil {
|
||||
return reserved.Lookup(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// paramsOnlyHelp renders the --help addendum for parameters that have no typed
|
||||
// flag, or "" when there are none. Per field: a copy-pasteable --params form,
|
||||
// the same fieldFacts a typed flag would show on its usage line, and what the
|
||||
// colliding flag actually does — so neither a human nor an agent sets the
|
||||
// wrong one (e.g. --format, which is the output format, not the API parameter).
|
||||
func (b *paramFlagBinder) paramsOnlyHelp() string {
|
||||
if len(b.paramsOnly) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\nParameters set via --params (no typed flag; the name is taken by another flag):\n")
|
||||
for _, p := range b.paramsOnly {
|
||||
name := p.field.Name
|
||||
fmt.Fprintf(&sb, " %s: --params '{%q: %s}'\n", name, name, paramExample(p.field))
|
||||
for _, fact := range fieldFacts(p.field) {
|
||||
fmt.Fprintf(&sb, " %s\n", fact)
|
||||
}
|
||||
if p.claimed != nil {
|
||||
fmt.Fprintf(&sb, " do not use --%s (%s)\n", p.claimed.Name, p.claimed.Usage)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// hasTypedFlag reports whether the binder registered a typed flag for the
|
||||
// param named name. False for params-only fields — a flag with the same kebab
|
||||
// name may exist (that's the collision), but it is not this param's input.
|
||||
// Nil-safe for direct buildServiceRequest callers that have no binder.
|
||||
func (b *paramFlagBinder) hasTypedFlag(name string) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
for _, pf := range b.bound {
|
||||
if pf.field.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// overlay lets an explicit typed flag override the same key in --params
|
||||
// (--params is the base). Only changed flags apply, so the --params-only path is
|
||||
// unchanged. A nil binder or cmd is a no-op.
|
||||
func (b *paramFlagBinder) overlay(cmd *cobra.Command, params map[string]interface{}) {
|
||||
if b == nil || cmd == nil {
|
||||
return
|
||||
}
|
||||
for _, pf := range b.bound {
|
||||
if cmd.Flags().Changed(pf.field.FlagName()) {
|
||||
params[pf.field.Name] = pf.read()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerTypedFlag registers one flag of the given canonical JSON-Schema kind
|
||||
// and returns a reader for its parsed value; the kind→pflag-type switch lives
|
||||
// only here.
|
||||
func registerTypedFlag(fs *pflag.FlagSet, name, kind, usage string) func() interface{} {
|
||||
switch kind {
|
||||
case "integer":
|
||||
return flagReader(fs.Int(name, 0, usage))
|
||||
case "boolean":
|
||||
return flagReader(fs.Bool(name, false, usage))
|
||||
case "array":
|
||||
return flagReader(fs.StringArray(name, nil, usage))
|
||||
default:
|
||||
return flagReader(fs.String(name, "", usage))
|
||||
}
|
||||
}
|
||||
|
||||
func flagReader[T any](p *T) func() interface{} {
|
||||
return func() interface{} { return *p }
|
||||
}
|
||||
626
cmd/service/paramflags_test.go
Normal file
626
cmd/service/paramflags_test.go
Normal file
@@ -0,0 +1,626 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// imChatMembersCreate: POST chats/{chat_id}/members with one path param and one
|
||||
// optional enum query param — the canonical case from the screenshot feedback.
|
||||
func imChatMembersCreate() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "chats/{chat_id}/members",
|
||||
"httpMethod": "POST",
|
||||
"parameters": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
"member_id_type": map[string]interface{}{
|
||||
"type": "string", "location": "query", "required": false,
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "open_id"},
|
||||
map[string]interface{}{"value": "user_id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlagRegistered(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
|
||||
if cmd.Flags().Lookup("chat-id") == nil {
|
||||
t.Error("expected generated --chat-id flag for path param chat_id")
|
||||
}
|
||||
if cmd.Flags().Lookup("member-id-type") == nil {
|
||||
t.Error("expected generated --member-id-type flag for query param member_id_type")
|
||||
}
|
||||
}
|
||||
|
||||
// A query param literally named "format" kebab-collides with the global
|
||||
// --format flag. Generation must skip it (never re-register, never panic) and
|
||||
// leave the standard --format flag intact.
|
||||
func TestServiceMethod_TypedFlagReservedCollisionSkipped(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"format": map[string]interface{}{"type": "string", "location": "query"},
|
||||
},
|
||||
}
|
||||
|
||||
var cmd *cobra.Command
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("flag generation panicked on reserved-name collision: %v", r)
|
||||
}
|
||||
}()
|
||||
cmd = NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "list", "messages", nil)
|
||||
}()
|
||||
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil || fl.DefValue != "json" {
|
||||
t.Fatalf("standard --format flag must be preserved, got %+v", fl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_DrivesPathParam(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--data", `{"id_list":["ou_x"]}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id substituted from --chat-id, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_DrivesQueryParam(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--member-id-type", "open_id", "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "member_id_type") || !strings.Contains(out, "open_id") {
|
||||
t.Errorf("expected query param member_id_type=open_id from flag, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_AgreesWithParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("same value via flag and --params should be accepted, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --params is the base; an explicit typed flag overrides the same key.
|
||||
func TestServiceMethod_TypedFlag_OverridesParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_flag", "--params", `{"chat_id":"oc_params"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "chats/oc_flag/members") {
|
||||
t.Errorf("expected --chat-id to override --params chat_id, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "oc_params") {
|
||||
t.Errorf("--params value should have been overridden by the flag, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Override works for a non-string (integer) param too, exercising the int
|
||||
// register/read path end to end.
|
||||
func TestServiceMethod_TypedFlag_IntegerOverridesParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "messages", nil)
|
||||
cmd.SetArgs([]string{"--page-size", "100", "--params", `{"page_size":5}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "page_size") || !strings.Contains(out, "100") {
|
||||
t.Errorf("expected --page-size 100 to override --params page_size=5, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: with no typed flags passed, behavior is byte-identical to today.
|
||||
func TestServiceMethod_TypedFlag_OnlyParamsStillWorks(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id from --params, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null is valid JSON that unmarshals to a nil map. A typed
|
||||
// flag overlaying onto it must not panic (assignment to a nil map) — null is
|
||||
// treated as "no base params", with the flag value applied on top.
|
||||
func TestServiceMethod_TypedFlag_OverridesNullParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", "null", "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with a typed flag should not error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected chat_id from --chat-id over null --params, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Startup smoke test: registering every embedded method must not panic on a
|
||||
// generated-flag name collision (pflag panics on duplicate registration, which
|
||||
// would crash the whole CLI at startup), and a known path param must surface as
|
||||
// a typed flag end to end.
|
||||
func TestRegisterServiceCommands_GeneratesFlagsNoPanic(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("registering all service commands panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
RegisterServiceCommands(root, f)
|
||||
|
||||
create, _, err := root.Find([]string{"im", "chat.members", "create"})
|
||||
if err != nil {
|
||||
t.Fatalf("im chat.members create not registered: %v", err)
|
||||
}
|
||||
if create.Flags().Lookup("chat-id") == nil {
|
||||
t.Error("expected generated --chat-id flag on im chat.members create")
|
||||
}
|
||||
}
|
||||
|
||||
// Locks the boolean and array branches of bindParamFlag end to end (string and
|
||||
// integer are covered above): a bool flag yields true and a repeatable array
|
||||
// flag yields all its elements in the request.
|
||||
func TestServiceMethod_TypedFlag_BoolAndArrayKinds(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
"ids": map[string]interface{}{"type": "list", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted", "--ids", "a", "--ids", "b", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"with_deleted", "true", "ids", "\"a\"", "\"b\""} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override (--params base, typed flag wins) is covered for string and integer
|
||||
// above; this locks the same semantics for the boolean and array kinds.
|
||||
func TestServiceMethod_TypedFlag_BoolAndArrayOverrideParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
"ids": map[string]interface{}{"type": "list", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--params", `{"with_deleted":false,"ids":["from_params"]}`,
|
||||
"--with-deleted", "--ids", "a", "--ids", "b",
|
||||
"--dry-run",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"with_deleted", "true", "\"a\"", "\"b\""} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected flag to override --params (want %q), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "from_params") {
|
||||
t.Errorf("--params array value should have been overridden by --ids, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A param whose kebab name collides with a global flag (here "format" vs the
|
||||
// global --format) gets no typed flag, but the collision is no longer silent:
|
||||
// non-colliding params still get flags, the global --format is untouched, and
|
||||
// --help shows the exact --params form and steers the reader off --format.
|
||||
func TestServiceMethod_ParamsOnly_HelpSteersToParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "things/{thing_id}",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"thing_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"format": map[string]interface{}{"type": "string", "location": "query", "min": "1", "max": "64", "description": "返回的消息体格式。", "options": []interface{}{
|
||||
map[string]interface{}{"value": "full"},
|
||||
map[string]interface{}{"value": "metadata"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "get", "things", nil)
|
||||
|
||||
if cmd.Flags().Lookup("thing-id") == nil {
|
||||
t.Error("non-colliding param should still get a typed --thing-id flag")
|
||||
}
|
||||
if fl := cmd.Flags().Lookup("format"); fl == nil || fl.DefValue != "json" {
|
||||
t.Fatalf("global --format must be preserved (not shadowed), got %+v", fl)
|
||||
}
|
||||
for _, want := range []string{`--params '{"format"`, "返回的消息体格式", "full", "metadata", "min: 1, max: 64", "do not use --format"} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Errorf("help should contain %q so the reader uses --params, not --format; got:\n%s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The collision guard derives reserved names from the actual flag sets — local
|
||||
// flags plus the root's persistent flags passed in — so a future persistent
|
||||
// flag is covered with no hand-maintained list. Here a param named "profile"
|
||||
// (a root persistent flag) is skipped while a normal param is bound.
|
||||
func TestParamFlagBinder_PersistentFlagReserved(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
reserved := pflag.NewFlagSet("root", pflag.ContinueOnError)
|
||||
reserved.String("profile", "", "use a specific profile")
|
||||
|
||||
m := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"profile": map[string]interface{}{"type": "string", "location": "query"},
|
||||
"id": map[string]interface{}{"type": "string", "location": "path"},
|
||||
}})
|
||||
b := newParamFlagBinder(cmd, m.Params(), reserved)
|
||||
|
||||
if cmd.Flags().Lookup("id") == nil {
|
||||
t.Error("non-colliding param should get a typed flag")
|
||||
}
|
||||
if cmd.Flags().Lookup("profile") != nil {
|
||||
t.Error("param colliding with a reserved persistent flag must not be registered")
|
||||
}
|
||||
found := false
|
||||
for _, p := range b.paramsOnly {
|
||||
if p.field.Name == "profile" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("colliding param should be recorded for the --params help note")
|
||||
}
|
||||
}
|
||||
|
||||
// boolIntQueryMethod is the fixture for the zero-value semantics tests: one
|
||||
// boolean and one integer query param, where false and 0 are meaningful values.
|
||||
func boolIntQueryMethod(required bool) meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query", "required": required},
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
|
||||
// so --flag=false / --flag 0 are real values and must be sent — not silently
|
||||
// dropped as "empty", which would let the API default win over an explicit
|
||||
// user choice.
|
||||
func TestServiceMethod_TypedFlag_ExplicitFalseAndZeroAreSent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted=false", "--page-size", "0", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("explicit zero value must be sent (want %s), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly provided false satisfies a required query parameter — the
|
||||
// pre-flight must not report "missing" for a value the user just set.
|
||||
func TestServiceMethod_TypedFlag_ExplicitFalseSatisfiesRequired(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(true), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted=false", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("required param explicitly set to false must pass pre-flight, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"with_deleted": false`) {
|
||||
t.Errorf("explicit false must be sent, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The same presence-is-intent rule applies to the --params JSON base: a key
|
||||
// deliberately written as false/0 is sent. (Zero values used to be silently
|
||||
// dropped; this locks the corrected semantics as the contract.)
|
||||
func TestServiceMethod_Params_JSONZeroValuesAreSent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"with_deleted":false,"page_size":0}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("--params zero value must be sent (want %s), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "" stays unusable: a required parameter fed an empty-string placeholder is
|
||||
// still caught by the friendly pre-flight error, not sent as an empty value.
|
||||
func TestServiceMethod_Params_EmptyStringStillMissing(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"user_id_type": map[string]interface{}{"type": "string", "location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"user_id_type":""}`, "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required query parameter") {
|
||||
t.Fatalf("empty string for a required param should still pre-flight error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A declared optional query param fed "" is dropped (unusable value), not sent
|
||||
// as an empty query value — the declared-param loop owns the decision and the
|
||||
// undeclared passthrough must not resurrect it. Undeclared keys stay the
|
||||
// verbatim raw escape hatch.
|
||||
func TestServiceMethod_Params_EmptyOptionalDroppedUndeclaredKept(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"user_id_type": map[string]interface{}{"type": "string", "location": "query"},
|
||||
},
|
||||
})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"user_id_type":"","custom_key":"v1"}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "user_id_type") {
|
||||
t.Errorf("declared optional param with empty value must be dropped, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"custom_key": "v1"`) {
|
||||
t.Errorf("undeclared key must pass through verbatim, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// min/max from the metadata surface on the typed flag's help line, in the same
|
||||
// vocabulary as the envelope's minimum/maximum.
|
||||
func TestParamFlagUsage_Bounds(t *testing.T) {
|
||||
cases := []struct{ name, min, max, want string }{
|
||||
{"both", "1", "100", "min: 1, max: 100"},
|
||||
{"min only", "1", "", "min: 1"},
|
||||
{"max only", "", "64", "max: 64"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": tc.min, "max": tc.max},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); !strings.Contains(usage, tc.want) {
|
||||
t.Errorf("usage = %q, want contains %q", usage, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("no bounds, no clause", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"page_token": map[string]interface{}{"type": "string", "location": "query"},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "min:") || strings.Contains(usage, "max:") {
|
||||
t.Errorf("usage without bounds should not mention min/max, got %q", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The sanitized field description rides the help line — a bare name like
|
||||
// user_mailbox_id carries no meaning. The cut is at note separators (;), NOT
|
||||
// at sentence ends (。): the later sentence often holds the key affordance.
|
||||
func TestParamFlagUsage_Description(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"user_mailbox_id": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
"description": `用户邮箱地址。当使用用户身份访问时,可以输入"me"代表当前调用接口用户;后续补充说明不该出现`,
|
||||
},
|
||||
}}).Params()
|
||||
usage := paramFlagUsage(fields[0])
|
||||
if !strings.Contains(usage, `可以输入"me"代表当前调用接口用户`) {
|
||||
t.Errorf("description must keep full sentences up to the note separator, got %q", usage)
|
||||
}
|
||||
if strings.Contains(usage, "补充说明") {
|
||||
t.Errorf("text after the note separator must be cut, got %q", usage)
|
||||
}
|
||||
|
||||
t.Run("long description truncated", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"x": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"description": strings.Repeat("长", 80),
|
||||
},
|
||||
}}).Params()
|
||||
usage := paramFlagUsage(fields[0])
|
||||
if !strings.Contains(usage, "...") {
|
||||
t.Errorf("long description should be truncated with ellipsis, got %q", usage)
|
||||
}
|
||||
if strings.Contains(usage, strings.Repeat("长", 61)) {
|
||||
t.Errorf("description should not exceed the cap, got %q", usage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trailing sentence punctuation trimmed", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"x": map[string]interface{}{
|
||||
"type": "string", "location": "query", "description": "返回格式。",
|
||||
},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "。.") {
|
||||
t.Errorf("clause join must not double the punctuation, got %q", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pins the convergence contract: the params-only addendum renders the SAME
|
||||
// fieldFacts list the typed flag's usage line joins inline — a fact added to
|
||||
// fieldFacts reaches both surfaces, and neither can drift over what a param's
|
||||
// help says (the addendum once rendered values-only enums and silently lacked
|
||||
// the API default).
|
||||
func TestParamHelp_BothSurfacesRenderFieldFacts(t *testing.T) {
|
||||
f := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"mode": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"description": "模式选择。",
|
||||
"default": "fast",
|
||||
"min": "1", "max": "8",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "fast", "description": "快速"},
|
||||
map[string]interface{}{"value": "full"},
|
||||
},
|
||||
},
|
||||
}}).Params()[0]
|
||||
|
||||
facts := fieldFacts(f)
|
||||
if len(facts) != 4 { // description, enum, bounds, API default
|
||||
t.Fatalf("fieldFacts = %v, want 4 facts", facts)
|
||||
}
|
||||
usage := paramFlagUsage(f)
|
||||
help := (¶mFlagBinder{paramsOnly: []paramsOnlyField{{field: f}}}).paramsOnlyHelp()
|
||||
for _, fact := range facts {
|
||||
if !strings.Contains(usage, fact) {
|
||||
t.Errorf("usage line missing fact %q: %q", fact, usage)
|
||||
}
|
||||
if !strings.Contains(help, fact) {
|
||||
t.Errorf("params-only addendum missing fact %q:\n%s", fact, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bounds reach the registered flag's help end to end.
|
||||
func TestServiceMethod_TypedFlag_HelpShowsBounds(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": "1", "max": "100", "default": "20"},
|
||||
},
|
||||
})
|
||||
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), method, "list", "items", nil)
|
||||
fl := cmd.Flags().Lookup("page-size")
|
||||
if fl == nil {
|
||||
t.Fatal("expected generated --page-size flag")
|
||||
}
|
||||
if !strings.Contains(fl.Usage, "min: 1, max: 100") {
|
||||
t.Errorf("flag usage should carry bounds, got %q", fl.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// The missing-required hint must name both recovery paths — the typed flag and
|
||||
// the --params fallback — so a reader who only knows one input style can
|
||||
// proceed without a round-trip through schema.
|
||||
func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`, "lark-cli schema im.chat.members.create"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint %q should contain %q", ve.Hint, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A params-only required field (kebab name claimed by the standard --format
|
||||
// flag) has no typed flag to offer: the hint must give only the --params form,
|
||||
// never steer the reader to the colliding flag.
|
||||
func TestServiceMethod_MissingRequired_ParamsOnlyHintSkipsFlag(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"format": map[string]interface{}{"type": "string", "location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "messages", nil)
|
||||
cmd.SetArgs([]string{"--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, `--params '{"format": "<value>"}'`) {
|
||||
t.Errorf("hint %q should carry the --params form", ve.Hint)
|
||||
}
|
||||
if strings.Contains(ve.Hint, "set --format") {
|
||||
t.Errorf("hint %q must not steer to the colliding --format flag", ve.Hint)
|
||||
}
|
||||
}
|
||||
162
cmd/service/paramhelp.go
Normal file
162
cmd/service/paramhelp.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Help rendering for generated param flags. fieldFacts is the single list of
|
||||
// agent-relevant facts a param exposes; every help surface (the typed flag's
|
||||
// usage line, the params-only --params addendum) renders that one list, so the
|
||||
// surfaces cannot drift over which facts exist. Values come from the
|
||||
// meta.Field accessors, so nothing here depends on internal/schema.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// fieldFacts returns a param field's facts in display order, each as a compact
|
||||
// one-line clause: the sanitized description, the allowed enum values (with
|
||||
// meanings), the min/max constraint, and the API default. This is the ONE
|
||||
// place that decides what a param's help says — add a fact here (e.g. a future
|
||||
// deprecation marker) and every surface shows it. Unabridged prose and
|
||||
// per-option detail stay in `lark-cli schema`.
|
||||
func fieldFacts(f meta.Field) []string {
|
||||
var facts []string
|
||||
if d := sanitizeFieldDesc(f.Description); d != "" {
|
||||
facts = append(facts, d)
|
||||
}
|
||||
if opts := f.EnumOptions(); len(opts) > 0 {
|
||||
facts = append(facts, "enum: "+formatEnumInline(opts))
|
||||
}
|
||||
if b := formatBoundsInline(f); b != "" {
|
||||
facts = append(facts, b)
|
||||
}
|
||||
if s := literalStr(f.CoercedDefault()); s != "" {
|
||||
facts = append(facts, "API default: "+s)
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
// paramFlagUsage renders the typed param flag's help line:
|
||||
//
|
||||
// <param_name>, required|optional[. <fact>]...
|
||||
//
|
||||
// It leads with the canonical underscore param name (the key this flag
|
||||
// overrides in --params) and required/optional, then joins the field's facts
|
||||
// inline.
|
||||
func paramFlagUsage(f meta.Field) string {
|
||||
req := "optional"
|
||||
if f.Required {
|
||||
req = "required"
|
||||
}
|
||||
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
|
||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||
// its first allowed enum value, else its example, else a placeholder.
|
||||
func paramExample(f meta.Field) string {
|
||||
if vals := enumStrings(f.EnumValues()); len(vals) > 0 {
|
||||
return fmt.Sprintf("%q", vals[0])
|
||||
}
|
||||
if s := literalStr(f.CoercedExample()); s != "" {
|
||||
return fmt.Sprintf("%q", s)
|
||||
}
|
||||
return `"<value>"`
|
||||
}
|
||||
|
||||
var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
|
||||
|
||||
// inlineClause compresses metadata prose into one help clause: markdown links
|
||||
// keep their text, the clause cuts at the first rune in stops, whitespace
|
||||
// collapses, trailing punctuation goes — sentence enders (the clause join adds
|
||||
// its own) and connectors a cut can strand, like a colon introducing a list the
|
||||
// newline cut dropped — and the result caps at max runes. The two policies
|
||||
// below differ only in where they cut and how much they keep.
|
||||
func inlineClause(s, stops string, max int) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = markdownLinkRe.ReplaceAllString(s, "$1")
|
||||
// Backquotes must go: pflag's UnquoteUsage treats a backquoted word in a
|
||||
// flag's usage string as the flag's metavar, so a description like wiki
|
||||
// space_id's "可替换为`my_library`" would render the flag as
|
||||
// "--space-id my_library" instead of "--space-id string".
|
||||
s = strings.ReplaceAll(s, "`", "")
|
||||
if i := strings.IndexAny(s, stops); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
s = strings.TrimRight(s, "。.::,,、")
|
||||
return util.TruncateStrWithEllipsis(s, max)
|
||||
}
|
||||
|
||||
// sanitizeOptionDesc is the enum-option policy: many values share one line, so
|
||||
// keep only the first clause (cut at 。 too) and stay ultra-compact.
|
||||
func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", 40) }
|
||||
|
||||
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
||||
// keep full sentences and cut only at note separators (meta_data appends
|
||||
// bullet notes after ;/;) — the later sentence often carries the key
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) }
|
||||
|
||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||
// enums like succeed_type read as "0=…|1=…|2=…" — else just "v". Full meanings
|
||||
// live in the envelope's enumDescriptions / `lark-cli schema`.
|
||||
func formatEnumInline(opts []meta.EnumOption) string {
|
||||
items := make([]string, len(opts))
|
||||
for i, o := range opts {
|
||||
if d := sanitizeOptionDesc(o.Description); d != "" {
|
||||
items[i] = fmt.Sprintf("%v=%s", o.Value, d)
|
||||
} else {
|
||||
items[i] = fmt.Sprintf("%v", o.Value)
|
||||
}
|
||||
}
|
||||
return strings.Join(items, "|")
|
||||
}
|
||||
|
||||
// formatBoundsInline renders the field's min/max constraint ("min: 1, max:
|
||||
// 100", or the single declared side), or "" when the field declares neither.
|
||||
// The vocabulary matches the envelope's minimum/maximum, so help and `lark-cli
|
||||
// schema` state the same constraint.
|
||||
func formatBoundsInline(f meta.Field) string {
|
||||
min, max := f.MinBound(), f.MaxBound()
|
||||
switch {
|
||||
case min != nil && max != nil:
|
||||
return fmt.Sprintf("min: %s, max: %s", formatBound(*min), formatBound(*max))
|
||||
case min != nil:
|
||||
return "min: " + formatBound(*min)
|
||||
case max != nil:
|
||||
return "max: " + formatBound(*max)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatBound renders a bound without a float artifact (100 not 100.000000).
|
||||
func formatBound(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// literalStr renders a coerced literal (default/example) for flag help,
|
||||
// returning "" for a nil or empty value so the caller can omit the clause.
|
||||
func literalStr(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
func enumStrings(enum []interface{}) []string {
|
||||
out := make([]string, 0, len(enum))
|
||||
for _, e := range enum {
|
||||
out = append(out, fmt.Sprintf("%v", e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
61
cmd/service/sanitize_test.go
Normal file
61
cmd/service/sanitize_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeOptionDesc(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"以 open_id 标识用户": "以 open_id 标识用户",
|
||||
"中文。English second clause": "中文", // first clause only (。)
|
||||
"head;tail": "head", // first clause (;)
|
||||
"line one\nline two": "line one", // first clause (newline)
|
||||
" spaced out ": "spaced out", // whitespace collapsed
|
||||
"see [飞书后台](https://x/admin) 详情": "see 飞书后台 详情", // markdown link -> text, url dropped
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeOptionDesc(in); got != want {
|
||||
t.Errorf("sanitizeOptionDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Truncation: a long single clause is cut to 40 runes with an ellipsis,
|
||||
// rune-safe (no split mid-character).
|
||||
long := strings.Repeat("文", 60)
|
||||
got := sanitizeOptionDesc(long)
|
||||
if r := []rune(got); len(r) != 40 || !strings.HasSuffix(got, "...") {
|
||||
t.Errorf("truncation = %q (%d runes), want 40 runes ending in ...", got, len(r))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldDesc_TrimsDanglingPunctuation(t *testing.T) {
|
||||
// A clause cut can strand a connector (e.g. a colon introducing a list the
|
||||
// newline cut drops, as in im.reactions.list's message_id); the help line
|
||||
// joiner then renders "…获取方式:." — so dangling punctuation must go too.
|
||||
cases := map[string]string{
|
||||
"待查询的消息ID。ID 获取方式:\n- 调用接口获取": "待查询的消息ID。ID 获取方式",
|
||||
"see the list below:\nitem": "see the list below",
|
||||
"逗号结尾,\n下一行": "逗号结尾",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeFieldDesc(in); got != want {
|
||||
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldDesc_StripsBackquotes(t *testing.T) {
|
||||
// pflag's UnquoteUsage takes a backquoted word in a flag's usage string as
|
||||
// the flag's metavar: wiki space_id's description rendered the flag as
|
||||
// "--space-id my_library" instead of "--space-id string".
|
||||
in := "[知识空间id](https://x/wiki),如果查询我的文档库可替换为`my_library`"
|
||||
want := "知识空间id,如果查询我的文档库可替换为my_library"
|
||||
if got := sanitizeFieldDesc(in); got != want {
|
||||
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,20 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// RegisterServiceCommands registers all service commands from from_meta specs.
|
||||
@@ -30,85 +32,74 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
// Drive the service list from the same navigation catalog the method walk
|
||||
// uses — RuntimeCatalog().Services() is the deterministic, sorted view of the
|
||||
// merged metadata — so registration is catalog-sourced end to end. Kept as a
|
||||
// per-service loop rather than a flat WalkMethods(nil) drive precisely so a
|
||||
// service with no methods still gets its bare command (WalkMethods yields one
|
||||
// ref per method, so empty services would vanish).
|
||||
for _, svc := range registry.RuntimeCatalog().Services() {
|
||||
if svc.Name == "" || svc.ServicePath == "" {
|
||||
continue
|
||||
}
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
if specName == "" || servicePath == "" {
|
||||
continue
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
registerServiceWithContext(ctx, parent, svc, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
func registerService(parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, svc, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
if specDesc == "" {
|
||||
specDesc = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
|
||||
svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc))
|
||||
|
||||
// Find existing service command or create one
|
||||
var svc *cobra.Command
|
||||
// Build the service's subtree from the catalog's method walk
|
||||
// (apicatalog.ServiceMethods recurses nested resources), so the command tree
|
||||
// is sourced from the same navigation Module as schema/scope rather than a
|
||||
// hand-rolled resource/method walk. Each ref's ResourcePath becomes the
|
||||
// resource-command chain — one level for a flat dotted resource like
|
||||
// "chat.members", deeper for genuinely nested resources. A service with no
|
||||
// methods keeps its bare command (svcCmd is created above regardless).
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
resCmd := svcCmd
|
||||
for _, seg := range ref.ResourcePath {
|
||||
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
|
||||
}
|
||||
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
||||
}
|
||||
}
|
||||
|
||||
// serviceShort is the service command's help summary: the localized description
|
||||
// from the registry, falling back to the metadata's own description.
|
||||
func serviceShort(svc meta.Service) string {
|
||||
if d := registry.GetServiceDescription(svc.Name, "en"); d != "" {
|
||||
return d
|
||||
}
|
||||
return svc.Description
|
||||
}
|
||||
|
||||
// ensureChildCommand returns the child of parent named name, creating it (with
|
||||
// short) when absent — so re-registration merges into an existing command tree
|
||||
// instead of duplicating a level.
|
||||
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Name() == specName {
|
||||
svc = c
|
||||
break
|
||||
if c.Name() == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
if svc == nil {
|
||||
svc = &cobra.Command{
|
||||
Use: specName,
|
||||
Short: specDesc,
|
||||
}
|
||||
parent.AddCommand(svc)
|
||||
}
|
||||
|
||||
for resName, resource := range resources {
|
||||
resMap, _ := resource.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
}
|
||||
parent.AddCommand(res)
|
||||
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
for methodName, method := range methods {
|
||||
methodMap, _ := method.(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
}
|
||||
cmd := &cobra.Command{Use: name, Short: short}
|
||||
parent.AddCommand(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ServiceMethodOptions holds all inputs for a dynamically registered service method command.
|
||||
type ServiceMethodOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ctx context.Context
|
||||
Spec map[string]interface{}
|
||||
Method map[string]interface{}
|
||||
SchemaPath string
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ctx context.Context
|
||||
ServicePath string
|
||||
Method meta.Method
|
||||
SchemaPath string
|
||||
|
||||
// Flags
|
||||
Params string
|
||||
@@ -123,41 +114,113 @@ type ServiceMethodOptions struct {
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
|
||||
// binder owns the generated typed param flags — registration and the
|
||||
// --params overlay — replacing the raw paramFlags side-channel.
|
||||
binder *paramFlagBinder
|
||||
}
|
||||
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
// detectFileFields returns the request-body file-upload field names.
|
||||
func detectFileFields(m meta.Method) []string {
|
||||
files := m.Files()
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service method.
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, svc, m, name, resName, runF)
|
||||
}
|
||||
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
// NewCmdServiceMethodWithContext builds the command for one service method from
|
||||
// its (service, resource, method) coordinates, deriving the methodCommandSpec
|
||||
// via an apicatalog.MethodRef so direct callers and the catalog-driven
|
||||
// registration assemble the command identically.
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
m.Name = name
|
||||
ref := apicatalog.MethodRef{Service: svc, ResourcePath: []string{resName}, Method: m}
|
||||
// No root in scope here; persistent-flag collisions don't apply to a
|
||||
// standalone command, and local/standard-flag collisions are still caught.
|
||||
return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF, nil)
|
||||
}
|
||||
|
||||
// methodCommandSpec is the static description of one generated service method
|
||||
// command, read off an apicatalog.MethodRef — the single place command
|
||||
// construction gets the method's facts (schema path, HTTP base path, risk,
|
||||
// identities, params, file fields, request-body support), so the cobra command
|
||||
// is assembled from a typed spec rather than recomputing paths/flags inline.
|
||||
type methodCommandSpec struct {
|
||||
method meta.Method
|
||||
schemaPath string // "service.resource.method", for the --help hint
|
||||
servicePath string // service HTTP base path
|
||||
risk string // RiskRead | RiskWrite | RiskHighRiskWrite
|
||||
restricts bool // method declares accessTokens (identity-restricted)
|
||||
identities []string // permitted --as values; empty when unrestricted
|
||||
params []meta.Field // path/query params -> typed flags
|
||||
fileFields []string // request-body file-upload field names
|
||||
// acceptsBody is whether the HTTP method allows a request body at all (so
|
||||
// --data is offered as a raw escape hatch). declaresBody is whether the
|
||||
// metadata documents body fields (data or file). They differ for e.g. a POST
|
||||
// with no documented requestBody: --data still works, but help must not imply
|
||||
// the API declares a body.
|
||||
acceptsBody bool
|
||||
declaresBody bool
|
||||
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
|
||||
}
|
||||
|
||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
m := ref.Method
|
||||
return methodCommandSpec{
|
||||
method: m,
|
||||
schemaPath: ref.SchemaPath(),
|
||||
servicePath: ref.Service.ServicePath,
|
||||
risk: m.Risk,
|
||||
restricts: m.RestrictsIdentity(),
|
||||
identities: m.Identities(),
|
||||
params: m.Params(),
|
||||
fileFields: detectFileFields(m),
|
||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||
affordance: renderAffordance(m),
|
||||
}
|
||||
}
|
||||
|
||||
// methodTakesBody reports whether the HTTP method allows a request body, i.e.
|
||||
// whether --data applies (as a raw escape hatch even when no body is declared).
|
||||
func methodTakesBody(httpMethod string) bool {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildMethodCommand assembles the cobra command for a service method from its
|
||||
// static spec: the standard flags, the conditional --data/--file/--yes flags,
|
||||
// the generated typed param flags (via paramFlagBinder), and the risk/identity
|
||||
// policy annotations.
|
||||
func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodCommandSpec, runF func(*ServiceMethodOptions) error, reserved *pflag.FlagSet) *cobra.Command {
|
||||
m := spec.method
|
||||
opts := &ServiceMethodOptions{
|
||||
Factory: f,
|
||||
Spec: spec,
|
||||
Method: method,
|
||||
SchemaPath: schemaPath,
|
||||
Factory: f,
|
||||
ServicePath: spec.servicePath,
|
||||
Method: m,
|
||||
SchemaPath: spec.schemaPath,
|
||||
FileFields: spec.fileFields,
|
||||
}
|
||||
var asStr string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: desc,
|
||||
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
|
||||
Use: m.Name,
|
||||
Short: m.Description,
|
||||
// Long is assembled below, once the binder knows which params got no
|
||||
// typed flag.
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
@@ -169,10 +232,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
},
|
||||
}
|
||||
|
||||
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, @file for file input)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
|
||||
if spec.acceptsBody {
|
||||
dataUsage := "JSON request body. Supports - and @file."
|
||||
if !spec.declaresBody {
|
||||
// POST/etc. with no documented body fields: --data is a raw escape
|
||||
// hatch, not a declared body — say so rather than imply structure.
|
||||
dataUsage = "Raw JSON request body (no documented fields; see schema). Supports - and @file."
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", dataUsage)
|
||||
}
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
@@ -183,27 +251,61 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if risk == "high-risk-write" {
|
||||
if spec.risk == cmdutil.RiskHighRiskWrite {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
// --file only for body methods that actually declare file-type fields.
|
||||
if len(spec.fileFields) > 0 && spec.acceptsBody {
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
||||
// Single composition point for Long: description, affordance, schema
|
||||
// pointer, and the binder's params-only addendum (params whose flag name is
|
||||
// taken, reachable via --params only).
|
||||
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
|
||||
|
||||
// Group flags for the grouped --help renderer (typed param flags are grouped
|
||||
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
||||
// registered above (e.g. --data/--file/--yes only exist for some methods).
|
||||
// --data sits under Request Body only when the metadata documents body
|
||||
// fields; otherwise it's a raw escape hatch, grouped with --params so help
|
||||
// doesn't imply a declared body the API doesn't have.
|
||||
if fl := cmd.Flags().Lookup("data"); fl != nil {
|
||||
if spec.declaresBody {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupBody})
|
||||
} else {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
}
|
||||
}
|
||||
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
||||
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
// State the precedence rule where the agent reads it: --params is the
|
||||
// base, typed flags override. Only meaningful when typed flags exist.
|
||||
if len(spec.params) > 0 {
|
||||
annotate(fl, flagNoteAnnotation, []string{
|
||||
"Typed API parameter flags above are preferred.",
|
||||
"If both are set, typed flags override matching keys in --params.",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||
tagFlagGroup(cmd.Flags(), name, groupExecution)
|
||||
}
|
||||
for _, name := range []string{"output", "format", "jq"} {
|
||||
tagFlagGroup(cmd.Flags(), name, groupOutput)
|
||||
}
|
||||
applyGroupedUsage(cmd)
|
||||
|
||||
cmdutil.SetTips(cmd, m.Tips)
|
||||
cmdutil.SetRisk(cmd, spec.risk)
|
||||
if spec.restricts {
|
||||
cmdutil.SetSupportedIdentities(cmd, spec.identities)
|
||||
}
|
||||
|
||||
return cmd
|
||||
@@ -218,8 +320,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// Check if this API method supports the resolved identity.
|
||||
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil {
|
||||
if opts.Method.RestrictsIdentity() {
|
||||
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -235,12 +337,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
// Identity is not printed to stderr here: it is part of the JSON envelope.
|
||||
|
||||
scopes, _ := opts.Method["scopes"].([]interface{})
|
||||
if !opts.As.IsBot() {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -257,7 +357,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
|
||||
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
|
||||
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
|
||||
return cmdutil.RequireConfirmation(opts.SchemaPath)
|
||||
}
|
||||
@@ -302,7 +402,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -311,23 +411,15 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
|
||||
}
|
||||
|
||||
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
|
||||
|
||||
if hasRequired && len(requiredScopes) > 0 {
|
||||
if len(method.RequiredScopes) > 0 {
|
||||
// Strict: ALL requiredScopes must be present
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 {
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(scopes) == 0 {
|
||||
if len(method.Scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -336,12 +428,12 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
for _, s := range strings.Fields(result.Scopes) {
|
||||
grantedSet[s] = true
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok && grantedSet[str] {
|
||||
for _, s := range method.Scopes {
|
||||
if grantedSet[s] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(scopes, "user")
|
||||
recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user")
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
|
||||
}
|
||||
|
||||
@@ -362,14 +454,44 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri
|
||||
WithIdentity(identity)
|
||||
}
|
||||
|
||||
// unusableParamValue reports whether a provided path/query parameter value
|
||||
// cannot form a usable request value: nil or an empty string. A key's presence
|
||||
// in params is the intent signal — a typed flag is overlaid only when
|
||||
// explicitly Changed, and a --params JSON key is deliberately written — so
|
||||
// false and 0 are real values and must not be conflated with "unset"
|
||||
// (reflect.IsZero would drop an explicit --with-deleted=false or --foo 0).
|
||||
// Only nil/"" stay treated as missing: that keeps the friendly pre-flight
|
||||
// error when a required param is fed an empty placeholder, and never emits a
|
||||
// declared param as an empty path segment or query value. Undeclared keys are
|
||||
// not judged by this rule — they pass through verbatim as the raw escape hatch.
|
||||
func unusableParamValue(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return ok && s == ""
|
||||
}
|
||||
|
||||
// missingParamHint is the recovery hint for a missing required parameter. It
|
||||
// names both input paths — the typed flag when the binder registered one, and
|
||||
// the --params fallback — plus the schema pointer. A params-only field gets
|
||||
// only the --params form: a flag with its kebab name exists but belongs to
|
||||
// something else (e.g. the output --format), and the hint must not steer
|
||||
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
|
||||
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
|
||||
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
|
||||
if opts.binder.hasTypedFlag(f.Name) {
|
||||
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
|
||||
}
|
||||
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
spec := opts.Spec
|
||||
method := opts.Method
|
||||
schemaPath := opts.SchemaPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
httpMethod := method.HTTPMethod
|
||||
|
||||
// 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.
|
||||
@@ -387,53 +509,55 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
opts.binder.overlay(opts.Cmd, params)
|
||||
|
||||
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
|
||||
url := opts.ServicePath + "/" + method.Path
|
||||
|
||||
parameters, _ := method["parameters"].(map[string]interface{})
|
||||
for name, param := range parameters {
|
||||
p, _ := param.(map[string]interface{})
|
||||
if registry.GetStrFromMap(p, "location") != "path" {
|
||||
specs := method.Params()
|
||||
for _, s := range specs {
|
||||
if s.Location != "path" {
|
||||
continue
|
||||
}
|
||||
val, ok := params[name]
|
||||
if !ok || util.IsEmptyValue(val) {
|
||||
val, ok := params[s.Name]
|
||||
if !ok || unusableParamValue(val) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required path parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
"missing required path parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, name); err != nil {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
|
||||
if err := validate.ResourceName(valStr, s.Name); err != nil {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(s.Name).WithCause(err)
|
||||
}
|
||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, name)
|
||||
url = strings.Replace(url, "{"+s.Name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, s.Name)
|
||||
}
|
||||
|
||||
queryParams := map[string]interface{}{}
|
||||
for name, param := range parameters {
|
||||
p, _ := param.(map[string]interface{})
|
||||
if registry.GetStrFromMap(p, "location") != "query" {
|
||||
for _, s := range specs {
|
||||
if s.Location != "query" {
|
||||
continue
|
||||
}
|
||||
value, exists := params[name]
|
||||
required, _ := p["required"].(bool)
|
||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||
value, exists := params[s.Name]
|
||||
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
|
||||
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required query parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
"missing required query parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
}
|
||||
if exists && !util.IsEmptyValue(value) {
|
||||
queryParams[name] = value
|
||||
if exists && !unusableParamValue(value) {
|
||||
queryParams[s.Name] = value
|
||||
}
|
||||
// This loop owns declared query params: consume the key so the
|
||||
// passthrough below can't resurrect a value the gate dropped (an
|
||||
// unusable "" would otherwise be sent as an empty query value).
|
||||
delete(params, s.Name)
|
||||
}
|
||||
// Whatever remains is undeclared — the raw escape hatch for params the
|
||||
// metadata doesn't (yet) describe; passed through verbatim, no filtering.
|
||||
for name, value := range params {
|
||||
if _, ok := queryParams[name]; !ok {
|
||||
queryParams[name] = value
|
||||
}
|
||||
queryParams[name] = value
|
||||
}
|
||||
|
||||
request := client.RawApiRequest{
|
||||
|
||||
@@ -8,13 +8,14 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
|
||||
// parameter and risk metadata. The returned map is what service registration
|
||||
// reads; the test exercises --yes registration and the gate behavior.
|
||||
func highRiskDeleteMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
// parameter and risk metadata. The test exercises --yes registration and the
|
||||
// gate behavior.
|
||||
func highRiskDeleteMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"risk": "high-risk-write",
|
||||
@@ -23,11 +24,11 @@ func highRiskDeleteMethod() map[string]interface{} {
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeMethodNoRisk() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func writeMethodNoRisk() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"parameters": map[string]interface{}{
|
||||
@@ -35,7 +36,7 @@ func writeMethodNoRisk() map[string]interface{} {
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -20,14 +21,14 @@ var testConfig = &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
|
||||
func driveSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func driveSpec() meta.Service {
|
||||
return meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "drive",
|
||||
"servicePath": "/open-apis/drive/v1",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} {
|
||||
func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
|
||||
m := map[string]interface{}{
|
||||
"path": "files/{file_token}/copy",
|
||||
"httpMethod": httpMethod,
|
||||
@@ -41,7 +42,7 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
|
||||
},
|
||||
}
|
||||
}
|
||||
return m
|
||||
return meta.FromMap(m)
|
||||
}
|
||||
|
||||
// ── registerService ──
|
||||
@@ -49,23 +50,23 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
|
||||
func TestRegisterService(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
f := &cmdutil.Factory{}
|
||||
spec := map[string]interface{}{
|
||||
base := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "base",
|
||||
"description": "Base API",
|
||||
"servicePath": "/open-apis/base/v3",
|
||||
}
|
||||
resources := map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{
|
||||
"description": "List tables",
|
||||
"httpMethod": "GET",
|
||||
"resources": map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{
|
||||
"description": "List tables",
|
||||
"httpMethod": "GET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
registerService(parent, spec, resources, f)
|
||||
registerService(parent, base, f)
|
||||
|
||||
// service command exists
|
||||
svc, _, err := parent.Find([]string{"base"})
|
||||
@@ -90,18 +91,18 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
|
||||
parent.AddCommand(existing)
|
||||
|
||||
f := &cmdutil.Factory{}
|
||||
spec := map[string]interface{}{
|
||||
svc := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3",
|
||||
}
|
||||
resources := map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
|
||||
"resources": map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
registerService(parent, spec, resources, f)
|
||||
registerService(parent, svc, f)
|
||||
|
||||
// Should reuse existing, not duplicate
|
||||
count := 0
|
||||
@@ -143,7 +144,7 @@ func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
|
||||
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil)
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("data") != nil {
|
||||
t.Error("GET method should not have --data flag")
|
||||
@@ -159,7 +160,7 @@ func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
|
||||
func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil)
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "POST"}), "create", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("data") == nil {
|
||||
t.Error("POST method should have --data flag")
|
||||
@@ -171,7 +172,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
@@ -268,15 +269,15 @@ func TestServiceMethod_MissingPathParam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items", "httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"q": map[string]interface{}{"location": "query", "required": true},
|
||||
},
|
||||
}
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{}`, "--dry-run"})
|
||||
@@ -291,15 +292,15 @@ func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items", "httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"location": "query", "required": true},
|
||||
},
|
||||
}
|
||||
})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"})
|
||||
@@ -315,10 +316,10 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", "{bad", "--dry-run"})
|
||||
|
||||
@@ -333,10 +334,10 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_InvalidDataJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
||||
cmd.SetArgs([]string{"--data", "{bad", "--dry-run"})
|
||||
|
||||
@@ -351,10 +352,10 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
|
||||
|
||||
@@ -369,10 +370,10 @@ func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
@@ -398,8 +399,8 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot"})
|
||||
|
||||
@@ -427,8 +428,8 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
|
||||
|
||||
@@ -450,8 +451,8 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
|
||||
|
||||
@@ -470,7 +471,7 @@ func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
@@ -492,7 +493,7 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
@@ -508,10 +509,10 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
@@ -542,8 +543,8 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
|
||||
|
||||
@@ -561,10 +562,10 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
|
||||
|
||||
@@ -579,10 +580,10 @@ func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
|
||||
|
||||
@@ -611,8 +612,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
|
||||
@@ -630,8 +631,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func imImageMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"requestBody": map[string]interface{}{
|
||||
@@ -645,14 +646,14 @@ func imImageMethod() map[string]interface{} {
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func imSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func imSpec() meta.Service {
|
||||
return meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
|
||||
@@ -684,7 +685,7 @@ func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(getMethod), "get", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for GET method")
|
||||
@@ -752,7 +753,7 @@ func TestDetectFileFields(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(tt.method)
|
||||
got := detectFileFields(meta.FromMap(tt.method))
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
@@ -771,7 +772,7 @@ func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user