diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index a966f1430..639798c84 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -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, diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index a68efc142..70e01065c 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -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 } diff --git a/cmd/command_catalog_path_test.go b/cmd/command_catalog_path_test.go new file mode 100644 index 000000000..c13538a84 --- /dev/null +++ b/cmd/command_catalog_path_test.go @@ -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) + } +} diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index 2851a1b4e..1c3f37e68 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -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 } - - service := cmd.Parent().Parent().Name() - resource := cmd.Parent().Name() - method := cmd.Name() - - spec := registry.LoadFromMeta(service) - if spec == nil { - return nil - } - resources, _ := spec["resources"].(map[string]interface{}) - resMap, _ := resources[resource].(map[string]interface{}) - if resMap == nil { + target, err := registry.RuntimeCatalog().Resolve(path) + if err != nil || target.Kind != apicatalog.TargetMethod { 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 registry.DeclaredScopesForMethod(target.Method.Method, identity) +} + +// 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...) + } + return path } // shortcutSupportsIdentity reports whether a shortcut supports the requested diff --git a/cmd/root.go b/cmd/root.go index e86ff069e..f74fff0b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -33,7 +33,7 @@ const rootLong = `lark-cli — Lark/Feishu CLI tool. USAGE: lark-cli [subcommand] [method] [options] lark-cli api [--params ] [--data ] - lark-cli schema [--format pretty] + lark-cli schema EXAMPLES: # View upcoming events diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 5276052e0..95dbac9c8 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -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 ..%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..%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 %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 %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 %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 \n", output.Bold, output.Reset, specName, resName, methodName) - } else if isFileUpload { - fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file \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,85 @@ 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. + cmd.Flags().String("format", "json", "") + cmd.Flags().Bool("json", true, "") + _ = cmd.Flags().MarkHidden("format") + _ = cmd.Flags().MarkHidden("json") + 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 - } - - // 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 - } -} - -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) - } + completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode)) + directive := cobra.ShellCompDirectiveNoFileComp + if noSpace { + directive |= cobra.ShellCompDirectiveNoSpace } + return completions, directive } - - 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) -} - -// 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]) - } - envs := schema.AssembleService(parts[0], spec, filter) - output.PrintJson(out, envs) + return runSchema(out, apicatalog.ParsePath(opts.Args), mode) +} + +// 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") + } + output.PrintJson(out, schema.EnvelopeOf(refs[0])) 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) - 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)) - } - 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.%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 +// 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 + } + 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 } diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index cb9e51c8b..c5f9c9a11 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -4,7 +4,6 @@ package schema import ( - "bytes" "encoding/json" "strings" "testing" @@ -21,29 +20,42 @@ 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) - } - if !strings.Contains(stdout.String(), "Available services") { - t.Error("expected service list in pretty mode") +func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) { + // Agents are habituated to --format/--json from api/service commands. schema + // must accept them without erroring and always emit the JSON envelope — its + // output is structured JSON, so the values have no alternative rendering. + argSets := [][]string{ + {"--format", "json"}, + {"--format", "pretty"}, + {"--format", "table"}, // no table rendering for a nested schema -> JSON + {"--format", "csv"}, + {"--json"}, + {"--json", "--format", "ndjson"}, + } + 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 +63,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 +88,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 +191,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 +207,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 ") { - t.Errorf("expected CLI example with --file , 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. diff --git a/cmd/service/affordance.go b/cmd/service/affordance.go new file mode 100644 index 000000000..d59c6d9db --- /dev/null +++ b/cmd/service/affordance.go @@ -0,0 +1,77 @@ +// 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: the description, the +// affordance guidance block (when the method has one), and the pointer to the +// full schema. 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 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) + 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") +} diff --git a/cmd/service/affordance_test.go b/cmd/service/affordance_test.go new file mode 100644 index 000000000..e3111f62c --- /dev/null +++ b/cmd/service/affordance_test.go @@ -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) + } +} diff --git a/cmd/service/flaggroups.go b/cmd/service/flaggroups.go new file mode 100644 index 000000000..523fc896c --- /dev/null +++ b/cmd/service/flaggroups.go @@ -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"}, +} + +// serviceMethodUsageTemplate renders a generated method command's 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. +const serviceMethodUsageTemplate = `Usage: + {{.UseLine}} +{{if .HasAvailableLocalFlags}} +{{serviceFlagGroups .}} +{{end}}{{if .HasAvailableInheritedFlags}} +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}} +{{end}}` + +func init() { + cobra.AddTemplateFunc("serviceFlagGroups", renderServiceFlagGroups) +} + +// applyGroupedUsage installs the grouped usage template on a service method cmd. +func applyGroupedUsage(cmd *cobra.Command) { + cmd.SetUsageTemplate(serviceMethodUsageTemplate) +} + +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 template func behind serviceMethodUsageTemplate. +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 +} diff --git a/cmd/service/flaggroups_test.go b/cmd/service/flaggroups_test.go new file mode 100644 index 000000000..59d741a42 --- /dev/null +++ b/cmd/service/flaggroups_test.go @@ -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) + } +} diff --git a/cmd/service/paramflags.go b/cmd/service/paramflags.go new file mode 100644 index 000000000..36f228949 --- /dev/null +++ b/cmd/service/paramflags.go @@ -0,0 +1,176 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "fmt" + "regexp" + "strings" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/meta" + "github.com/larksuite/cli/internal/util" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// nonLocalReservedFlags are flags the collision guard can't see at registration +// time — cobra adds --help lazily and --profile is a root persistent flag, +// neither attached to the method command yet. +var nonLocalReservedFlags = map[string]bool{"help": true, "profile": true} + +type boundParamFlag struct { + field meta.Field + read func() interface{} +} + +// 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 +} + +// newParamFlagBinder registers one typed kebab flag per path/query parameter on +// cmd and returns a binder for the --params overlay. A name colliding with an +// existing or reserved flag is skipped — pflag panics on duplicate registration +// — leaving that parameter reachable via --params. +func newParamFlagBinder(cmd *cobra.Command, params []meta.Field) *paramFlagBinder { + b := ¶mFlagBinder{} + for _, f := range params { + name := f.FlagName() + if nonLocalReservedFlags[name] || cmd.Flags().Lookup(name) != nil { + 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 +} + +// 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 } +} + +// paramFlagUsage renders the typed param flag's agent-readable help line: +// +// , required|optional[. enum: a|b|c][. API default: x] +// +// It leads with the canonical underscore param name (the key this flag overrides +// in --params), states required/optional, lists the allowed enum values, and the +// API default. Full prose descriptions and per-option meanings are intentionally +// left to `lark-cli schema` so --help stays scannable. Values come from the +// meta.Field accessors, so this carries no internal/schema dependency. +func paramFlagUsage(f meta.Field) string { + req := "optional" + if f.Required { + req = "required" + } + parts := []string{fmt.Sprintf("%s, %s", f.Name, req)} + if opts := f.EnumOptions(); len(opts) > 0 { + parts = append(parts, "enum: "+formatEnumInline(opts)) + } + if s := literalStr(f.CoercedDefault()); s != "" { + parts = append(parts, "API default: "+s) + } + return strings.Join(parts, ". ") + "." +} + +// formatEnumInline renders allowed values for the flag 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, "|") +} + +var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`) + +// sanitizeOptionDesc compresses an enum option description for the inline help +// line: strips markdown link URLs (keeping the link text), keeps the first +// clause, collapses whitespace and truncates. The full text stays in the +// envelope / `lark-cli schema`. +func sanitizeOptionDesc(s string) string { + if s == "" { + return "" + } + s = markdownLinkRe.ReplaceAllString(s, "$1") + if i := strings.IndexAny(s, "。;;\n\r"); i >= 0 { + s = s[:i] + } + s = strings.Join(strings.Fields(s), " ") + return util.TruncateStrWithEllipsis(s, 40) +} + +// 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 +} diff --git a/cmd/service/paramflags_test.go b/cmd/service/paramflags_test.go new file mode 100644 index 000000000..c25191565 --- /dev/null +++ b/cmd/service/paramflags_test.go @@ -0,0 +1,269 @@ +// 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" + "github.com/spf13/cobra" +) + +// 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) + } +} diff --git a/cmd/service/sanitize_test.go b/cmd/service/sanitize_test.go new file mode 100644 index 000000000..0e24f61ab --- /dev/null +++ b/cmd/service/sanitize_test.go @@ -0,0 +1,34 @@ +// 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)) + } +} diff --git a/cmd/service/service.go b/cmd/service/service.go index 125cc584f..9727a58eb 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -10,12 +10,14 @@ 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" @@ -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") - } - - // Find existing service command or create one - var svc *cobra.Command - for _, c := range parent.Commands() { - if c.Name() == specName { - svc = c - break - } - } - if svc == nil { - svc = &cobra.Command{ - Use: specName, - Short: specDesc, - } - parent.AddCommand(svc) - } +func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) { + svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc)) - for resName, resource := range resources { - resMap, _ := resource.(map[string]interface{}) - if resMap == nil { - continue + // 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") } - registerResourceWithContext(ctx, svc, spec, resName, resMap, f) + resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil)) } } -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", +// 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 } - parent.AddCommand(res) + return svc.Description +} - methods, _ := resource["methods"].(map[string]interface{}) - for methodName, method := range methods { - methodMap, _ := method.(map[string]interface{}) - if methodMap == nil { - continue +// 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() == name { + return c } - 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,110 @@ type ServiceMethodOptions struct { DryRun bool File string // --file flag value FileFields []string // auto-detected file field names from metadata -} -// detectFileFields delegates to the shared cmdutil.DetectFileFields helper. -func detectFileFields(method map[string]interface{}) []string { - return cmdutil.DetectFileFields(method) + // binder owns the generated typed param flags — registration and the + // --params overlay — replacing the raw paramFlags side-channel. + binder *paramFlagBinder } -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) +} + +// 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} + return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF) +} + +// 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 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) +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) *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: methodLong(m.Description, spec.affordance, spec.schemaPath), RunE: func(cmd *cobra.Command, args []string) error { opts.Cmd = cmd opts.Ctx = cmd.Context() @@ -169,10 +229,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") @@ -182,27 +247,57 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") 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) + + // 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 @@ -217,8 +312,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 } } @@ -237,9 +332,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { // Identity info is now included in the JSON envelope; skip stderr printing. // cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected) - 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 } } @@ -256,7 +350,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) } @@ -301,7 +395,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() } @@ -310,23 +404,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 } @@ -335,12 +421,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}) } @@ -365,10 +451,9 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri // 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. @@ -386,47 +471,45 @@ 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] + val, ok := params[s.Name] if !ok || util.IsEmptyValue(val) { return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, - "missing required path parameter: %s", name). + "missing required path parameter: %s", s.Name). WithHint("lark-cli schema %s", schemaPath). - WithParam(name) + 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 || util.IsEmptyValue(value)) { return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, - "missing required query parameter: %s", name). + "missing required query parameter: %s", s.Name). WithHint("lark-cli schema %s", schemaPath). - WithParam(name) + WithParam(s.Name) } if exists && !util.IsEmptyValue(value) { - queryParams[name] = value + queryParams[s.Name] = value } } for name, value := range params { diff --git a/cmd/service/service_risk_test.go b/cmd/service/service_risk_test.go index 636bfa692..87b278a99 100644 --- a/cmd/service/service_risk_test.go +++ b/cmd/service/service_risk_test.go @@ -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) { diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 42377850e..b7e58a198 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -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 diff --git a/internal/apicatalog/catalog.go b/internal/apicatalog/catalog.go new file mode 100644 index 000000000..cf31c8a72 --- /dev/null +++ b/internal/apicatalog/catalog.go @@ -0,0 +1,396 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package apicatalog is the single navigation Module over the API metadata. It +// owns every "which services/resources/methods exist and how does a path +// resolve" question that was previously duplicated across cmd/schema, +// cmd/service, internal/schema and internal/registry. It depends only on +// internal/meta; registry is the source Adapter (EmbeddedCatalog/RuntimeCatalog), +// so apicatalog never imports registry. +package apicatalog + +import ( + "sort" + "strings" + + "github.com/larksuite/cli/internal/meta" +) + +// Source records whether a catalog includes the remote overlay. It is carried +// so callers (and tests) can assert determinism instead of guessing. +type Source string + +const ( + SourceEmbedded Source = "embedded" // compiled-in metadata only; deterministic + SourceRuntime Source = "runtime" // embedded + remote overlay +) + +// MethodFilter optionally drops methods (e.g. by identity in strict mode). +// A nil filter includes everything. +type MethodFilter func(meta.Method) bool + +// Catalog is a navigation view over services with a name index. It owns its +// ordering — New sorts by name — so WalkMethods/Resolve/Complete are +// deterministic regardless of how the source adapter ordered its input. +type Catalog struct { + source Source + services []meta.Service + byName map[string]meta.Service +} + +// New builds a Catalog over the given services, owning its navigation order: +// the slice is copied and sorted by name so callers may pass any order and the +// ordering contract is not delegated to the adapter. The copy is shallow — +// meta.Service values share their Resources maps, which are treated as +// read-only. +func New(source Source, services []meta.Service) Catalog { + sorted := append([]meta.Service(nil), services...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + byName := make(map[string]meta.Service, len(sorted)) + for _, s := range sorted { + byName[s.Name] = s + } + return Catalog{source: source, services: sorted, byName: byName} +} + +// Source reports embedded vs runtime. +func (c Catalog) Source() Source { return c.source } + +// Services returns the services in name order. Treat the result as read-only: +// it is the Catalog's own ordered slice and its element Resources maps are +// shared. +func (c Catalog) Services() []meta.Service { return c.services } + +// Service looks up one service by name. +func (c Catalog) Service(name string) (meta.Service, bool) { + s, ok := c.byName[name] + return s, ok +} + +// Resolve maps a path (already split into segments) to a Target. An empty path +// is TargetAll. Failures return a *ResolveError carrying the available +// candidates so the command layer can render a hint. +func (c Catalog) Resolve(parts []string) (Target, error) { + if len(parts) == 0 { + return Target{Kind: TargetAll}, nil + } + svc, ok := c.byName[parts[0]] + if !ok { + return Target{}, &ResolveError{Kind: ErrService, Subject: parts[0], Candidates: c.serviceNames()} + } + if len(parts) == 1 { + return Target{Kind: TargetService, Service: svc}, nil + } + res, path, remaining, ok := findResource(svc, parts[1:]) + if !ok { + return Target{}, &ResolveError{ + Kind: ErrResource, + Subject: svc.Name + "." + strings.Join(parts[1:], "."), + Candidates: resourceNames(svc), + } + } + resPath := strings.Join(path, ".") + if len(remaining) == 0 { + return Target{Kind: TargetResource, Service: svc, Resource: &ResourceRef{Service: svc, Resource: res, Path: path}}, nil + } + methodName := remaining[0] + m, ok := res.Method(methodName) + if !ok { + return Target{}, &ResolveError{ + Kind: ErrMethod, + Subject: svc.Name + "." + resPath + "." + methodName, + Candidates: methodNames(res), + } + } + if len(remaining) > 1 { + // Method exists but trailing segments don't resolve — reject so a typo + // doesn't silently return this method's schema. + return Target{}, &ResolveError{ + Kind: ErrPath, + Subject: svc.Name + "." + resPath + "." + strings.Join(remaining, "."), + Method: methodName, + Trailing: strings.Join(remaining[1:], "."), + } + } + return Target{Kind: TargetMethod, Service: svc, Method: &MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m}}, nil +} + +// MethodRefs returns the method refs selected by a resolved Target, filtered: +// TargetAll -> every method, TargetService / TargetResource -> that subtree, +// TargetMethod -> the single method if it passes the filter (else empty). It +// unifies WalkMethods/ServiceMethods/ResourceMethods so the command layer maps a +// Target to refs in one call instead of re-deciding the walker per Kind. +func (c Catalog) MethodRefs(target Target, filter MethodFilter) []MethodRef { + switch target.Kind { + case TargetService: + return ServiceMethods(target.Service, filter) + case TargetResource: + return ResourceMethods(*target.Resource, filter) + case TargetMethod: + if filter != nil && !filter(target.Method.Method) { + return nil + } + return []MethodRef{*target.Method} + case TargetAll: + return c.WalkMethods(filter) + default: + // Unknown / zero-value Kind: return nothing rather than silently + // dumping every method (the safe direction for an invalid Target). + return nil + } +} + +// WalkMethods returns one MethodRef per method across all services (optionally +// filtered), recursing nested resources, in a deterministic order: services by +// name, resources by name, methods by name. +func (c Catalog) WalkMethods(filter MethodFilter) []MethodRef { + var out []MethodRef + for _, svc := range c.services { + out = append(out, ServiceMethods(svc, filter)...) + } + return out +} + +// ServiceMethods returns the method refs of one service (filtered), recursing +// nested resources, in deterministic resource/method name order. +func ServiceMethods(svc meta.Service, filter MethodFilter) []MethodRef { + var out []MethodRef + walkResources(svc, svc.ResourceList(), nil, filter, &out) + return out +} + +// ResourceMethods returns the method refs under one resource (filtered), using +// the resource's resolved path as the base and recursing nested resources. +func ResourceMethods(r ResourceRef, filter MethodFilter) []MethodRef { + var out []MethodRef + for _, m := range r.Resource.MethodList() { + if filter == nil || filter(m) { + out = append(out, MethodRef{Service: r.Service, Resource: r.Resource, ResourcePath: r.Path, Method: m}) + } + } + walkResources(r.Service, r.Resource.SubResources(), r.Path, filter, &out) + return out +} + +func walkResources(svc meta.Service, resources []meta.Resource, parentPath []string, filter MethodFilter, out *[]MethodRef) { + for _, res := range resources { + path := append(append([]string(nil), parentPath...), res.Name) + for _, m := range res.MethodList() { + if filter == nil || filter(m) { + *out = append(*out, MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m}) + } + } + walkResources(svc, res.SubResources(), path, filter, out) + } +} + +// Complete returns shell-completion candidates for the schema path argument, +// supporting both the legacy single dotted arg ("im.reac") and the +// space-separated form ("im reactions"). noSpace mirrors cobra's +// ShellCompDirectiveNoSpace (so "service." / "service.resource." stay open for +// the next segment). Filtering uses the caller's MethodFilter so strict-mode +// unavailable methods are hidden. +func (c Catalog) Complete(args []string, toComplete string, filter MethodFilter) (completions []string, noSpace bool) { + // Case 1: legacy single dotted arg — no resolved args yet. + if len(args) == 0 { + parts := strings.Split(toComplete, ".") + if len(parts) <= 1 { + for _, name := range c.serviceNames() { + if strings.HasPrefix(name, toComplete) { + completions = append(completions, name+".") + } + } + return completions, true + } + svc, ok := c.byName[parts[0]] + if !ok { + return nil, false + } + completions = c.completeDotted(svc, strings.Join(parts[1:], "."), filter) + allTrailingDot := len(completions) > 0 + for _, comp := range completions { + if !strings.HasSuffix(comp, ".") { + allTrailingDot = false + break + } + } + return completions, allTrailingDot + } + + // Case 2: space-separated form — args holds resolved segments. + svc, ok := c.byName[args[0]] + if !ok { + return nil, false + } + resource, _, _, ok := findResource(svc, args[1:]) + if !ok { + // No resource matched yet — suggest top-level resources reachable in the + // current identity mode. + return completeChildren(svc.ResourceList(), nil, toComplete, filter), false + } + // Positioned in a resource — offer its methods and its sub-resources, so the + // next segment can drill deeper, symmetric to findResource's descent. + return completeChildren(resource.SubResources(), resource.MethodList(), toComplete, filter), false +} + +// completeDotted suggests dotted completions for the text after the service +// segment. It descends fully-typed "resource." segments (longest match per +// level, so flat dotted keys like "chat.members" and genuinely nested resources +// both resolve), then offers the reachable sub-resources (as "…name.") and the +// methods (as "…name") of the level it lands in whose names extend the trailing +// partial token. This descent is symmetric to findResource, so completion can +// reach every method Resolve can. +func (c Catalog) completeDotted(svc meta.Service, afterService string, filter MethodFilter) []string { + subs := svc.ResourceList() + base := svc.Name + rest := afterService + var here *meta.Resource // resource we're positioned in; nil at the service root + for { + matched, n, ok := longestResourceFollowedByDot(subs, rest) + if !ok { + break + } + base += "." + matched.Name + rest = rest[n:] + r := matched + here = &r + subs = matched.SubResources() + } + + var out []string + for _, sub := range subs { + if strings.HasPrefix(sub.Name, rest) && resourceReachable(sub, filter) { + out = append(out, base+"."+sub.Name+".") + } + } + if here != nil { + for _, m := range here.MethodList() { + if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, rest) { + out = append(out, base+"."+m.Name) + } + } + } + sort.Strings(out) + return out +} + +// completeChildren returns the sorted next-segment candidates at one level: the +// (filtered) methods and the reachable sub-resources whose names extend prefix. +// Methods are terminal; sub-resources are bare names the caller drills into on +// the next segment. +func completeChildren(subResources []meta.Resource, methods []meta.Method, prefix string, filter MethodFilter) []string { + var out []string + for _, m := range methods { + if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, prefix) { + out = append(out, m.Name) + } + } + for _, sub := range subResources { + if strings.HasPrefix(sub.Name, prefix) && resourceReachable(sub, filter) { + out = append(out, sub.Name) + } + } + sort.Strings(out) + return out +} + +// longestResourceFollowedByDot finds the longest resource in resources whose +// name is a fully-typed segment of text (text begins with "name."), returning +// it, the byte length consumed (incl. the dot), and whether one matched. +func longestResourceFollowedByDot(resources []meta.Resource, text string) (meta.Resource, int, bool) { + best := meta.Resource{} + bestLen := -1 + for _, r := range resources { + if len(r.Name) > bestLen && strings.HasPrefix(text, r.Name+".") { + best = r + bestLen = len(r.Name) + } + } + if bestLen < 0 { + return meta.Resource{}, 0, false + } + return best, len(best.Name) + 1, true +} + +// findResource resolves a resource path against a service, descending nested +// resources. At each level it consumes the longest leading run of parts that +// names a resource at that level, so both flat dotted keys ("chat.members") +// and genuinely nested resources ("spaces" > "items") resolve. This descent is +// symmetric to walkResources, which guarantees every path WalkMethods emits +// resolves back (the round-trip contract). Returns the deepest matched resource +// (Name injected), its path segments, the unconsumed remainder, and whether +// anything matched. +// +// Descent is greedy and resource-first: the one ambiguous case is a resource +// that has BOTH a method and a sub-resource of the same name — the sub-resource +// wins and shadows the method, so Resolve can never reach that method. Real +// metadata never collides the two, so this is theoretical. +func findResource(svc meta.Service, parts []string) (res meta.Resource, path []string, remaining []string, ok bool) { + level := svc.Resources + remaining = parts + for len(remaining) > 0 { + matched, name, n := longestResourcePrefix(level, remaining) + if n == 0 { + break + } + matched.Name = name + res = matched + path = append(path, name) + remaining = remaining[n:] + level = matched.Resources + ok = true + } + return res, path, remaining, ok +} + +// longestResourcePrefix finds the longest leading run of segs (joined by ".") +// that names a resource in level, returning the resource, its dotted name, and +// the number of segments consumed (0 if none match). Longest-first lets a flat +// dotted key win over its single leading segment when present. +func longestResourcePrefix(level map[string]meta.Resource, segs []string) (meta.Resource, string, int) { + for i := len(segs); i >= 1; i-- { + name := strings.Join(segs[:i], ".") + if r, ok := level[name]; ok { + return r, name, i + } + } + return meta.Resource{}, "", 0 +} + +// resourceReachable reports whether a resource exposes a method reachable under +// the filter — directly or in any nested sub-resource (a nil filter accepts any +// method). A resource whose methods are all filtered out but which contains a +// reachable nested method is still offerable, so completion can drill into it. +func resourceReachable(res meta.Resource, filter MethodFilter) bool { + for _, m := range res.MethodList() { + if filter == nil || filter(m) { + return true + } + } + for _, sub := range res.SubResources() { + if resourceReachable(sub, filter) { + return true + } + } + return false +} + +func (c Catalog) serviceNames() []string { + names := make([]string, len(c.services)) + for i, s := range c.services { + names[i] = s.Name + } + return names // c.services is already name-sorted +} + +func resourceNames(svc meta.Service) []string { return sortedKeys(svc.Resources) } +func methodNames(res meta.Resource) []string { return sortedKeys(res.Methods) } + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/apicatalog/catalog_test.go b/internal/apicatalog/catalog_test.go new file mode 100644 index 000000000..97a866f07 --- /dev/null +++ b/internal/apicatalog/catalog_test.go @@ -0,0 +1,340 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apicatalog_test + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/meta" +) + +// testCatalog builds a small embedded catalog: services drive (no resources) +// and im with a dotted resource (chat.members), a multi-method resource +// (reactions, where list is user-only), and images. +func testCatalog() apicatalog.Catalog { + im := meta.ServiceFromMap(map[string]interface{}{ + "name": "im", + "resources": map[string]interface{}{ + "chat.members": map[string]interface{}{ + "methods": map[string]interface{}{"create": map[string]interface{}{}}, + }, + "reactions": map[string]interface{}{ + "methods": map[string]interface{}{ + "create": map[string]interface{}{}, + "list": map[string]interface{}{"accessTokens": []interface{}{"user"}}, + }, + }, + "images": map[string]interface{}{ + "methods": map[string]interface{}{"create": map[string]interface{}{}}, + }, + }, + }) + drive := meta.ServiceFromMap(map[string]interface{}{"name": "drive"}) + return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{drive, im}) // already name-sorted +} + +func TestNew_PreservesOrderAndLookup(t *testing.T) { + c := testCatalog() + if c.Source() != apicatalog.SourceEmbedded { + t.Fatalf("source = %q", c.Source()) + } + names := []string{} + for _, s := range c.Services() { + names = append(names, s.Name) + } + if !reflect.DeepEqual(names, []string{"drive", "im"}) { + t.Errorf("Services order = %v, want [drive im]", names) + } + if _, ok := c.Service("im"); !ok { + t.Error("Service(im) not found") + } + if _, ok := c.Service("nope"); ok { + t.Error("Service(nope) should not be found") + } +} + +// TestNew_SortsAndIsolatesInput pins the ordering contract New owns: it sorts +// arbitrary input by service name and shallow-copies the slice so later caller +// mutation can't reorder the Catalog. +func TestNew_SortsAndIsolatesInput(t *testing.T) { + in := []meta.Service{ + meta.ServiceFromMap(map[string]interface{}{"name": "zeta"}), + meta.ServiceFromMap(map[string]interface{}{"name": "alpha"}), + } + c := apicatalog.New(apicatalog.SourceEmbedded, in) + + names := func() []string { + var out []string + for _, s := range c.Services() { + out = append(out, s.Name) + } + return out + } + if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) { + t.Errorf("New did not sort unsorted input: %v", got) + } + + // Mutating the caller's slice afterward must not reorder the Catalog. + in[0] = meta.ServiceFromMap(map[string]interface{}{"name": "MUTATED"}) + if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) { + t.Errorf("Catalog order changed after caller mutated its input slice: %v", got) + } +} + +func TestWalkMethods_AllAndFiltered(t *testing.T) { + c := testCatalog() + + all := c.WalkMethods(nil) + got := map[string]bool{} + for _, r := range all { + got[r.SchemaPath()] = true + } + want := []string{ + "im.chat.members.create", + "im.images.create", + "im.reactions.create", + "im.reactions.list", + } + if len(all) != len(want) { + t.Fatalf("WalkMethods(nil) = %d refs, want %d (%v)", len(all), len(want), got) + } + for _, w := range want { + if !got[w] { + t.Errorf("WalkMethods(nil) missing %q", w) + } + } + + // Deterministic order: services by name, resources by name, methods by name. + var order []string + for _, r := range all { + order = append(order, r.SchemaPath()) + } + if !reflect.DeepEqual(order, want) { + t.Errorf("WalkMethods order = %v, want %v", order, want) + } + + // Filter to bot-only ("tenant"): reactions.list (user-only) drops; methods + // with no accessTokens are permissive and stay. + botOnly := func(m meta.Method) bool { + if m.AccessTokens == nil { + return true + } + for _, tok := range m.AccessTokens { + if tok == "tenant" { + return true + } + } + return false + } + filtered := c.WalkMethods(botOnly) + for _, r := range filtered { + if r.SchemaPath() == "im.reactions.list" { + t.Error("filtered walk should drop user-only im.reactions.list") + } + } + if len(filtered) != len(all)-1 { + t.Errorf("filtered walk = %d, want %d", len(filtered), len(all)-1) + } +} + +func TestMethodRef_Paths_DottedResourceStaysOneSegment(t *testing.T) { + c := testCatalog() + target, err := c.Resolve([]string{"im", "chat.members", "create"}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if target.Kind != apicatalog.TargetMethod { + t.Fatalf("kind = %v", target.Kind) + } + m := target.Method + if m.SchemaPath() != "im.chat.members.create" { + t.Errorf("SchemaPath = %q", m.SchemaPath()) + } + if !reflect.DeepEqual(m.CommandPath(), []string{"im", "chat.members", "create"}) { + t.Errorf("CommandPath = %v", m.CommandPath()) + } + if m.ResourceName() != "chat.members" { + t.Errorf("ResourceName = %q, want chat.members (one segment)", m.ResourceName()) + } + if m.Method.Name != "create" { + t.Errorf("Method.Name not injected: %q", m.Method.Name) + } +} + +func TestResolve_DottedAndSplitFormsEquivalent(t *testing.T) { + c := testCatalog() + // schema.ParsePath splits both "im.chat.members.create" and + // "im chat.members create" into segments; findResource's longest-prefix + // must resolve the dotted resource either way. + a, errA := c.Resolve([]string{"im", "chat", "members", "create"}) // fully split + b, errB := c.Resolve([]string{"im", "chat.members", "create"}) // resource as one segment + if errA != nil || errB != nil { + t.Fatalf("errA=%v errB=%v", errA, errB) + } + if a.Method.SchemaPath() != b.Method.SchemaPath() || a.Method.SchemaPath() != "im.chat.members.create" { + t.Errorf("forms diverged: %q vs %q", a.Method.SchemaPath(), b.Method.SchemaPath()) + } +} + +func TestResolve_Targets(t *testing.T) { + c := testCatalog() + if tg, _ := c.Resolve(nil); tg.Kind != apicatalog.TargetAll { + t.Errorf("empty -> %v, want all", tg.Kind) + } + if tg, _ := c.Resolve([]string{"im"}); tg.Kind != apicatalog.TargetService || tg.Service.Name != "im" { + t.Errorf("[im] -> %v/%q", tg.Kind, tg.Service.Name) + } + if tg, _ := c.Resolve([]string{"im", "reactions"}); tg.Kind != apicatalog.TargetResource || tg.Resource.SchemaPath() != "im.reactions" { + t.Errorf("[im reactions] -> %v", tg.Kind) + } +} + +func TestResolve_Errors(t *testing.T) { + c := testCatalog() + cases := []struct { + parts []string + kind apicatalog.ResolveErrorKind + }{ + {[]string{"nope"}, apicatalog.ErrService}, + {[]string{"im", "nope"}, apicatalog.ErrResource}, + {[]string{"im", "reactions", "nope"}, apicatalog.ErrMethod}, + {[]string{"im", "reactions", "list", "extra"}, apicatalog.ErrPath}, + } + for _, tc := range cases { + _, err := c.Resolve(tc.parts) + var re *apicatalog.ResolveError + if !errors.As(err, &re) { + t.Errorf("%v -> err %v, want *ResolveError", tc.parts, err) + continue + } + if re.Kind != tc.kind { + t.Errorf("%v -> kind %q, want %q", tc.parts, re.Kind, tc.kind) + } + if tc.kind != apicatalog.ErrPath && len(re.Candidates) == 0 { + t.Errorf("%v -> expected candidates", tc.parts) + } + } +} + +// nestedCatalog adds a genuinely nested resource (spaces > items) on top of a +// flat dotted resource (chat.members), so the round-trip contract is exercised +// for real nesting — not just flat dotted keys. +func nestedCatalog() apicatalog.Catalog { + im := meta.ServiceFromMap(map[string]interface{}{ + "name": "im", + "resources": map[string]interface{}{ + "chat.members": map[string]interface{}{ + "methods": map[string]interface{}{"create": map[string]interface{}{}}, + }, + "spaces": map[string]interface{}{ + "methods": map[string]interface{}{"create": map[string]interface{}{}}, + "resources": map[string]interface{}{ + "items": map[string]interface{}{ + "methods": map[string]interface{}{"get": map[string]interface{}{}}, + }, + }, + }, + }, + }) + return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{im}) +} + +// TestResolve_WalkMethodsRoundTrip is the core catalog contract: every method +// WalkMethods emits must Resolve back to the same method — both from its dotted +// SchemaPath (fully split) and from its CommandPath (resource as one segment). +// This pins findResource's nested-resource descent symmetric to walkResources, +// so "traversable" implies "resolvable". +func TestResolve_WalkMethodsRoundTrip(t *testing.T) { + for _, c := range []apicatalog.Catalog{testCatalog(), nestedCatalog()} { + for _, ref := range c.WalkMethods(nil) { + want := ref.SchemaPath() + for _, parts := range [][]string{ + strings.Split(want, "."), // fully-split dotted form + ref.CommandPath(), // command form (resource stays one segment) + } { + tg, err := c.Resolve(parts) + if err != nil { + t.Errorf("round-trip %v: %v", parts, err) + continue + } + if tg.Kind != apicatalog.TargetMethod { + t.Errorf("round-trip %v: kind=%v, want method", parts, tg.Kind) + continue + } + if tg.Method.SchemaPath() != want { + t.Errorf("round-trip %v: resolved to %q, want %q", parts, tg.Method.SchemaPath(), want) + } + } + } + } +} + +// TestComplete_Nested pins completion closure for genuinely nested resources: +// both the dotted and space forms must reach a nested method, symmetric to +// Resolve (findResource descends, so completion must too). +func TestComplete_Nested(t *testing.T) { + c := nestedCatalog() + + // dotted: under a resource, offer its methods AND its sub-resources + if comps, ns := c.Complete(nil, "im.spaces.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.create", "im.spaces.items."}) || ns { + t.Errorf("Complete([], im.spaces.) = %v noSpace=%v, want [im.spaces.create im.spaces.items.] false", comps, ns) + } + // dotted: drill into the nested sub-resource's method + if comps, ns := c.Complete(nil, "im.spaces.items.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items.get"}) || ns { + t.Errorf("Complete([], im.spaces.items.) = %v noSpace=%v, want [im.spaces.items.get] false", comps, ns) + } + // dotted: partial sub-resource name -> the sub-resource (NoSpace, more to type) + if comps, ns := c.Complete(nil, "im.spaces.it", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items."}) || !ns { + t.Errorf("Complete([], im.spaces.it) = %v noSpace=%v, want [im.spaces.items.] true", comps, ns) + } + // space form: under a resource, offer methods AND sub-resources + if comps, _ := c.Complete([]string{"im", "spaces"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "items"}) { + t.Errorf("Complete([im spaces], '') = %v, want [create items]", comps) + } + // space form: drill into the nested sub-resource's methods + if comps, _ := c.Complete([]string{"im", "spaces", "items"}, "", nil); !reflect.DeepEqual(comps, []string{"get"}) { + t.Errorf("Complete([im spaces items], '') = %v, want [get]", comps) + } +} + +func TestComplete(t *testing.T) { + c := testCatalog() + + // dotted: service prefix -> "im." (NoSpace) + if comps, ns := c.Complete(nil, "i", nil); !reflect.DeepEqual(comps, []string{"im."}) || !ns { + t.Errorf("Complete([], i) = %v noSpace=%v", comps, ns) + } + // dotted: resource prefix -> "im.reactions." (NoSpace) + if comps, _ := c.Complete(nil, "im.rea", nil); !reflect.DeepEqual(comps, []string{"im.reactions."}) { + t.Errorf("Complete([], im.rea) = %v", comps) + } + // space form: resource candidates under im (deterministic order) + comps, ns := c.Complete([]string{"im"}, "", nil) + if !reflect.DeepEqual(comps, []string{"chat.members", "images", "reactions"}) || ns { + t.Errorf("Complete([im], '') = %v noSpace=%v", comps, ns) + } + // space form: method candidates under reactions + if comps, _ := c.Complete([]string{"im", "reactions"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "list"}) { + t.Errorf("Complete([im reactions], '') = %v", comps) + } + // filter applied: bot-only hides user-only list + botOnly := func(m meta.Method) bool { + if m.AccessTokens == nil { + return true + } + for _, tok := range m.AccessTokens { + if tok == "tenant" { + return true + } + } + return false + } + if comps, _ := c.Complete([]string{"im", "reactions"}, "", botOnly); !reflect.DeepEqual(comps, []string{"create"}) { + t.Errorf("Complete with bot filter = %v, want [create]", comps) + } +} diff --git a/internal/apicatalog/methodref.go b/internal/apicatalog/methodref.go new file mode 100644 index 000000000..4bcea96e7 --- /dev/null +++ b/internal/apicatalog/methodref.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apicatalog + +import ( + "strings" + + "github.com/larksuite/cli/internal/meta" +) + +// TargetKind classifies what a schema/command path resolves to. +type TargetKind string + +const ( + TargetAll TargetKind = "all" // empty path: every method + TargetService TargetKind = "service" // + TargetResource TargetKind = "resource" // + TargetMethod TargetKind = "method" // +) + +// Target is the result of Catalog.Resolve. Resource and Method are populated +// only for TargetResource and TargetMethod respectively. +type Target struct { + Kind TargetKind + Service meta.Service + Resource *ResourceRef + Method *MethodRef +} + +// ResourceRef identifies one resource within a service. Path holds the resource +// path segments (one element for the common flat dotted resource like +// "chat.members"; multiple for genuinely nested resources). +type ResourceRef struct { + Service meta.Service + Resource meta.Resource + Path []string +} + +// MethodRef identifies one method, carrying the full navigation context so the +// command path and schema path can be derived without re-walking the catalog. +type MethodRef struct { + Service meta.Service + Resource meta.Resource + ResourcePath []string + Method meta.Method +} + +// SchemaPath is the dotted "service.resource" identifier. +func (r ResourceRef) SchemaPath() string { + return r.Service.Name + "." + strings.Join(r.Path, ".") +} + +// ServiceName returns the owning service name. +func (r MethodRef) ServiceName() string { return r.Service.Name } + +// ResourceName is the dotted resource path, e.g. "chat.members". +func (r MethodRef) ResourceName() string { return strings.Join(r.ResourcePath, ".") } + +// MethodName returns the method's own name. +func (r MethodRef) MethodName() string { return r.Method.Name } + +// SchemaPath is the dotted "service.resource.method" identifier, e.g. +// "im.chat.members.create". +func (r MethodRef) SchemaPath() string { + return r.Service.Name + "." + strings.Join(r.ResourcePath, ".") + "." + r.Method.Name +} + +// CommandPath is the CLI argv segments, e.g. ["im", "chat.members", "create"]. +func (r MethodRef) CommandPath() []string { + out := make([]string, 0, len(r.ResourcePath)+2) + out = append(out, r.Service.Name) + out = append(out, r.ResourcePath...) + return append(out, r.Method.Name) +} diff --git a/internal/apicatalog/path.go b/internal/apicatalog/path.go new file mode 100644 index 000000000..ec085bd18 --- /dev/null +++ b/internal/apicatalog/path.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apicatalog + +import "strings" + +// ParsePath normalizes positional command arguments into the path segments +// Resolve consumes. It accepts two equivalent forms: +// +// im.messages.reply -> single arg, split on "." +// im messages reply -> multiple args, used as-is +// +// "im chat.members bots" as a single quoted arg is NOT supported; quote +// arguments individually if your shell needs it. A resource keeps its internal +// dots when passed as one segment (e.g. "chat.members"); findResource's +// longest-prefix descent resolves both the split and the one-segment forms to +// the same target. Returns nil for zero args (bare invocation -> TargetAll). +func ParsePath(args []string) []string { + switch len(args) { + case 0: + return nil + case 1: + if strings.Contains(args[0], ".") { + return strings.Split(args[0], ".") + } + return []string{args[0]} + default: + return args + } +} diff --git a/internal/schema/path_test.go b/internal/apicatalog/path_test.go similarity index 90% rename from internal/schema/path_test.go rename to internal/apicatalog/path_test.go index ec8934450..fdcb9d2de 100644 --- a/internal/schema/path_test.go +++ b/internal/apicatalog/path_test.go @@ -1,11 +1,13 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package schema +package apicatalog_test import ( "reflect" "testing" + + "github.com/larksuite/cli/internal/apicatalog" ) func TestParsePath(t *testing.T) { @@ -25,7 +27,7 @@ func TestParsePath(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ParsePath(tt.args) + got := apicatalog.ParsePath(tt.args) if !reflect.DeepEqual(got, tt.want) { t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want) } diff --git a/internal/apicatalog/resolveerror.go b/internal/apicatalog/resolveerror.go new file mode 100644 index 000000000..e81863bad --- /dev/null +++ b/internal/apicatalog/resolveerror.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apicatalog + +// ResolveErrorKind classifies a Resolve failure so the command layer can render +// the right hint without re-deriving what was being looked up. +type ResolveErrorKind string + +const ( + ErrService ResolveErrorKind = "service" + ErrResource ResolveErrorKind = "resource" + ErrMethod ResolveErrorKind = "method" + ErrPath ResolveErrorKind = "path" // method exists but trailing segments don't resolve +) + +// ResolveError is returned by Catalog.Resolve. Subject is the dotted thing that +// failed to resolve; Candidates lists the available names at that level (nil for +// ErrPath, which instead carries the matched Method and the unresolved Trailing). +type ResolveError struct { + Kind ResolveErrorKind + Subject string + Candidates []string + Method string + Trailing string +} + +func (e *ResolveError) Error() string { + return "unknown " + string(e.Kind) + ": " + e.Subject +} diff --git a/internal/cmdutil/fileupload.go b/internal/cmdutil/fileupload.go index 15a99980d..9dfc22e2a 100644 --- a/internal/cmdutil/fileupload.go +++ b/internal/cmdutil/fileupload.go @@ -12,23 +12,9 @@ import ( "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/registry" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) -// DetectFileFields returns field names with type "file" in the method's requestBody. -func DetectFileFields(method map[string]interface{}) []string { - rb, _ := method["requestBody"].(map[string]interface{}) - var fields []string - for name, field := range rb { - f, _ := field.(map[string]interface{}) - if registry.GetStrFromMap(f, "type") == "file" { - fields = append(fields, name) - } - } - return fields -} - // ParseFileFlag parses a --file flag value into its components. // The format is either "path" or "field=path". When no explicit "field=" // prefix is present, defaultField is used as the field name. diff --git a/internal/cmdutil/identity.go b/internal/cmdutil/identity.go index 040f9e621..897cac5bd 100644 --- a/internal/cmdutil/identity.go +++ b/internal/cmdutil/identity.go @@ -10,22 +10,6 @@ import ( "github.com/larksuite/cli/internal/core" ) -// AccessTokensToIdentities converts from_meta accessTokens (e.g. ["tenant", "user"]) -// to CLI identity names (e.g. ["bot", "user"]). -func AccessTokensToIdentities(tokens []interface{}) []string { - var identities []string - for _, t := range tokens { - if ts, ok := t.(string); ok { - if ts == "tenant" { - identities = append(identities, "bot") - } else { - identities = append(identities, ts) - } - } - } - return identities -} - // PrintIdentity outputs the current identity to stderr so callers (including AI agents) // can see which identity is being used for the API call. func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) { diff --git a/internal/cmdutil/identity_test.go b/internal/cmdutil/identity_test.go index d65e2b08f..bb6e4aee6 100644 --- a/internal/cmdutil/identity_test.go +++ b/internal/cmdutil/identity_test.go @@ -11,54 +11,6 @@ import ( "github.com/larksuite/cli/internal/core" ) -func TestAccessTokensToIdentities(t *testing.T) { - tests := []struct { - name string - tokens []interface{} - want []string - }{ - { - name: "tenant becomes bot", - tokens: []interface{}{"tenant"}, - want: []string{"bot"}, - }, - { - name: "user stays user", - tokens: []interface{}{"user"}, - want: []string{"user"}, - }, - { - name: "tenant and user", - tokens: []interface{}{"tenant", "user"}, - want: []string{"bot", "user"}, - }, - { - name: "empty list", - tokens: []interface{}{}, - want: nil, - }, - { - name: "non-string values skipped", - tokens: []interface{}{"tenant", 42, "user"}, - want: []string{"bot", "user"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := AccessTokensToIdentities(tt.tokens) - if len(got) != len(tt.want) { - t.Fatalf("len: want %d, got %d (%v)", len(tt.want), len(got), got) - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("[%d] want %s, got %s", i, tt.want[i], got[i]) - } - } - }) - } -} - func TestPrintIdentity_BotExplicit(t *testing.T) { var buf bytes.Buffer PrintIdentity(&buf, core.AsBot, nil, false) diff --git a/internal/cmdutil/json.go b/internal/cmdutil/json.go index 817aad446..fef4ea4fe 100644 --- a/internal/cmdutil/json.go +++ b/internal/cmdutil/json.go @@ -34,7 +34,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F return body, nil } -// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty. +// ParseJSONMap parses a JSON string into a map. Returns an empty (never nil) map +// for empty input or the JSON literal null, so callers can always overlay onto +// the result without a nil-map panic. // Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput. func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) { resolved, err := ResolveInput(input, stdin, fileIO) @@ -48,5 +50,10 @@ func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (m if err := json.Unmarshal([]byte(resolved), &result); err != nil { return nil, output.ErrValidation("%s invalid format, expected JSON object", label) } + if result == nil { + // `null` unmarshals into a nil map without error; normalize it so the + // returned map is always writable, matching the empty-input case. + return map[string]any{}, nil + } return result, nil } diff --git a/internal/cmdutil/json_test.go b/internal/cmdutil/json_test.go index 44a7a51d7..687c652c4 100644 --- a/internal/cmdutil/json_test.go +++ b/internal/cmdutil/json_test.go @@ -47,6 +47,7 @@ func TestParseJSONMap(t *testing.T) { wantErr bool }{ {"empty input", "", "--params", 0, false}, + {"json null", "null", "--params", 0, false}, {"valid json", `{"a":"1","b":"2"}`, "--params", 2, false}, {"invalid json", `{bad}`, "--params", 0, true}, {"json array", `[1,2]`, "--data", 0, true}, @@ -61,6 +62,12 @@ func TestParseJSONMap(t *testing.T) { if !tt.wantErr && len(got) != tt.wantLen { t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen) } + // A successful parse must yield a non-nil, writable map: callers + // overlay onto it (params[k]=v), so `null` — which unmarshals to a + // nil map without error — must normalize to {} like empty input. + if !tt.wantErr && got == nil { + t.Error("ParseJSONMap() = nil map on success, want non-nil") + } }) } } diff --git a/internal/cmdutil/risk.go b/internal/cmdutil/risk.go index 22fb092c3..29ce402bb 100644 --- a/internal/cmdutil/risk.go +++ b/internal/cmdutil/risk.go @@ -3,17 +3,20 @@ package cmdutil -import "github.com/spf13/cobra" +import ( + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) const riskLevelAnnotationKey = "risk_level" -// Risk level constants — the three-tier convention used across the CLI. -// Use these in place of string literals so the typo radius is one place, -// not every call site. +// Risk level constants — aliases of the canonical core.Risk* values, re-exported +// here so command code gets the risk vocabulary and the SetRisk/GetRisk helpers +// from one package. core is the single source of truth. const ( - RiskRead = "read" - RiskWrite = "write" - RiskHighRiskWrite = "high-risk-write" + RiskRead = core.RiskRead + RiskWrite = core.RiskWrite + RiskHighRiskWrite = core.RiskHighRiskWrite ) // SetRisk stores a command's static risk level on cobra annotations so the diff --git a/internal/core/risk.go b/internal/core/risk.go new file mode 100644 index 000000000..4c9014010 --- /dev/null +++ b/internal/core/risk.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +// Risk levels — the three-tier convention used across the CLI. They live here, +// at the leaf, so the envelope renderer (internal/schema) and the command +// toolkit (internal/cmdutil) share one vocabulary without a renderer depending +// on command utilities. Framework confirmation gating acts only on +// RiskHighRiskWrite. +const ( + RiskRead = "read" + RiskWrite = "write" + RiskHighRiskWrite = "high-risk-write" +) diff --git a/internal/meta/affordance.go b/internal/meta/affordance.go new file mode 100644 index 000000000..ef0de6614 --- /dev/null +++ b/internal/meta/affordance.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import "encoding/json" + +// Affordance is the hand-authored usage guidance overlaid on a method: when to +// use it, when not to, prerequisites, few-shot examples, and related methods. +// It is the single typed model of the affordance shape; the envelope renderer +// and the command help both parse through ParsedAffordance so the vocabulary +// is defined once. The JSON tags double as the envelope's wire shape. +type Affordance struct { + UseWhen []string `json:"use_when,omitempty"` + DoNotUseWhen []string `json:"do_not_use_when,omitempty"` + Prerequisites []string `json:"prerequisites,omitempty"` + Examples []AffordanceCase `json:"examples,omitempty"` + Related []string `json:"related,omitempty"` +} + +// AffordanceCase is one few-shot example: a one-line description and a +// ready-to-run command. +type AffordanceCase struct { + Description string `json:"description"` + Command string `json:"command"` +} + +// ParsedAffordance decodes the method's raw affordance overlay into the typed +// Affordance. ok is false when the method carries no affordance, the JSON is +// malformed, or every section is empty — so callers can treat "no guidance" +// uniformly. +func (m Method) ParsedAffordance() (Affordance, bool) { + if len(m.Affordance) == 0 { + return Affordance{}, false + } + var a Affordance + if json.Unmarshal(m.Affordance, &a) != nil { + return Affordance{}, false + } + if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 { + return Affordance{}, false + } + return a, true +} diff --git a/internal/meta/affordance_test.go b/internal/meta/affordance_test.go new file mode 100644 index 000000000..8c177b95d --- /dev/null +++ b/internal/meta/affordance_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "encoding/json" + "testing" +) + +func TestMethod_ParsedAffordance(t *testing.T) { + // absent / empty / malformed all resolve to ok=false. + notOK := map[string]string{ + "nil": ``, + "empty object": `{}`, + "all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`, + "malformed string": `"not an object"`, + "malformed number": `42`, + "nested type mismatch": `{"examples":"should be a list"}`, + } + for name, raw := range notOK { + t.Run(name, func(t *testing.T) { + if _, ok := (Method{Affordance: json.RawMessage(raw)}).ParsedAffordance(); ok { + t.Errorf("ParsedAffordance(%s) ok=true, want false", raw) + } + }) + } + + // Populated affordance parses with all fields. + raw := `{ + "use_when": ["需要拿到当前用户的主日历 ID"], + "do_not_use_when": ["已知具体 calendar_id"], + "prerequisites": ["user 身份登录"], + "examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}], + "related": ["calendars.list"] + }` + a, ok := (Method{Affordance: json.RawMessage(raw)}).ParsedAffordance() + if !ok { + t.Fatal("ParsedAffordance ok=false, want populated") + } + if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" { + t.Errorf("UseWhen = %v", a.UseWhen) + } + if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" { + t.Errorf("Examples = %+v", a.Examples) + } + if len(a.Related) != 1 || a.Related[0] != "calendars.list" { + t.Errorf("Related = %v", a.Related) + } +} diff --git a/internal/meta/identity.go b/internal/meta/identity.go new file mode 100644 index 000000000..0b5548f93 --- /dev/null +++ b/internal/meta/identity.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import "sort" + +// IdentityForToken maps a metadata accessToken to the CLI identity (--as value) +// that uses it: "tenant" -> "bot" (bot calls use tenant_access_token), "user" +// -> "user". ok is false for unrecognized tokens. This is the single source of +// truth for the token<->identity vocabulary; schema, registry and command code +// all go through it instead of re-spelling the mapping. +func IdentityForToken(token string) (string, bool) { + switch token { + case "tenant": + return "bot", true + case "user": + return "user", true + } + return "", false +} + +// TokenForIdentity is the inverse of IdentityForToken: "bot" -> "tenant"; +// everything else (notably "user") maps to itself. +func TokenForIdentity(identity string) string { + if identity == "bot" { + return "tenant" + } + return identity +} + +// RestrictsIdentity reports whether the method limits which identities may call +// it: true exactly when it declares one or more accessTokens. nil OR an empty +// slice means unrestricted (any identity). This is the single rule that both +// the strict-mode predicate (SupportsToken) and command identity gates use, so +// nil and [] never diverge across schema/scope and execution. +func (m Method) RestrictsIdentity() bool { + return len(m.AccessTokens) > 0 +} + +// SupportsToken reports whether this method is reachable with the given access +// token ("tenant"/"user" — see TokenForIdentity). An unrestricted method +// (RestrictsIdentity == false, i.e. nil or empty accessTokens) is reachable by +// any token. This is the single source of truth for the predicate; registry +// scope policy and command identity checks build on it. +func (m Method) SupportsToken(token string) bool { + if !m.RestrictsIdentity() { + return true + } + for _, t := range m.AccessTokens { + if t == token { + return true + } + } + return false +} + +// Identities returns the CLI identities (--as values) that can call this +// method, derived from its metadata accessTokens: "tenant" -> "bot", "user" +// stays "user"; unrecognized tokens are dropped; the result is deduped and +// name-sorted. The slice is always non-nil so callers rendering it (e.g. the +// envelope's access_tokens) emit [] rather than null. +// +// An empty result does NOT imply unrestricted — use RestrictsIdentity() for +// that. Identities() lists only CLI-known identities, so a method restricted +// solely to unrecognized tokens returns empty yet RestrictsIdentity() is true. +func (m Method) Identities() []string { + seen := make(map[string]bool, len(m.AccessTokens)) + for _, t := range m.AccessTokens { + if id, ok := IdentityForToken(t); ok { + seen[id] = true + } + } + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + sort.Strings(out) + return out +} diff --git a/internal/meta/identity_test.go b/internal/meta/identity_test.go new file mode 100644 index 000000000..99843e89a --- /dev/null +++ b/internal/meta/identity_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "reflect" + "testing" +) + +func TestIdentityTokenBijection(t *testing.T) { + if got := TokenForIdentity("bot"); got != "tenant" { + t.Errorf("TokenForIdentity(bot) = %q, want tenant", got) + } + if got := TokenForIdentity("user"); got != "user" { + t.Errorf("TokenForIdentity(user) = %q, want user", got) + } + if id, ok := IdentityForToken("tenant"); id != "bot" || !ok { + t.Errorf("IdentityForToken(tenant) = %q,%v want bot,true", id, ok) + } + if id, ok := IdentityForToken("user"); id != "user" || !ok { + t.Errorf("IdentityForToken(user) = %q,%v want user,true", id, ok) + } + if _, ok := IdentityForToken("weird"); ok { + t.Error("IdentityForToken(weird) ok=true, want false") + } +} + +func TestMethod_RestrictsIdentity(t *testing.T) { + // nil and empty both mean "unrestricted"; only a populated list restricts. + if (Method{}).RestrictsIdentity() { + t.Error("nil accessTokens must be unrestricted") + } + if (Method{AccessTokens: []string{}}).RestrictsIdentity() { + t.Error("empty accessTokens must be unrestricted (same as nil)") + } + if !(Method{AccessTokens: []string{"tenant"}}).RestrictsIdentity() { + t.Error("populated accessTokens must restrict identity") + } +} + +func TestMethod_SupportsToken(t *testing.T) { + // unrestricted (nil OR empty) -> permissive for any token; the two must not + // diverge, else strict/scope and the command gate disagree. + for _, m := range []Method{{}, {AccessTokens: []string{}}} { + if !m.SupportsToken("tenant") || !m.SupportsToken("user") { + t.Errorf("unrestricted method %#v should support any token", m.AccessTokens) + } + } + // restricted: only the declared tokens are reachable + m := Method{AccessTokens: []string{"tenant"}} + if !m.SupportsToken("tenant") { + t.Error("tenant-declared method should support tenant") + } + if m.SupportsToken("user") { + t.Error("tenant-only method must NOT support user") + } +} + +func TestMethod_Identities(t *testing.T) { + // tenant->bot, user stays; deduped + name-sorted (so order-independent); + // unrecognized dropped; absent tokens -> empty but NON-nil so the envelope + // renders [] not null. + tests := []struct { + name string + tokens []string + want []string + }{ + {"tenant only", []string{"tenant"}, []string{"bot"}}, + {"user only", []string{"user"}, []string{"user"}}, + {"tenant then user", []string{"tenant", "user"}, []string{"bot", "user"}}, + {"user then tenant", []string{"user", "tenant"}, []string{"bot", "user"}}, + {"deduped", []string{"tenant", "tenant", "user"}, []string{"bot", "user"}}, + {"empty", []string{}, []string{}}, + {"nil", nil, []string{}}, + {"unknown skipped", []string{"user", "admin"}, []string{"user"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := (Method{AccessTokens: tt.tokens}).Identities(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Identities(%v) = %#v, want %#v", tt.tokens, got, tt.want) + } + }) + } +} diff --git a/internal/meta/meta.go b/internal/meta/meta.go new file mode 100644 index 000000000..b4135e912 --- /dev/null +++ b/internal/meta/meta.go @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package meta is the typed model of the API metadata registry and the single +// place that parses it. The metadata is a fixed, regular vocabulary, so a plain +// typed json.Unmarshal replaces hand-rolled map[string]interface{} walking. Map +// key order is not preserved (Go maps are unordered); callers that need a +// deterministic sequence get fields/methods/resources sorted by name via the +// list accessors below. +package meta + +import ( + "encoding/json" + "sort" + "strings" +) + +// Option is one enum option of a field. Value is `any` (not string) so a +// metadata value that arrives as a JSON number — rather than the usual quoted +// string — coerces like Field.Enum / EnumOption.Value instead of failing the +// whole registry unmarshal and blanking the entire catalog. coerceLiteral +// normalizes it to the field's declared type. +type Option struct { + Value any `json:"value"` + Description string `json:"description"` +} + +// Field is one parameter or body/response field. Name is the parent map key, +// populated by the list accessors (not a JSON field). ref/annotations/enumName +// exist in the metadata but are intentionally not modeled (unused downstream). +type Field struct { + Name string `json:"-"` + Type string `json:"type"` + Location string `json:"location"` // "path" | "query"; empty for body/response + Required bool `json:"required"` + Description string `json:"description"` + Default any `json:"default"` + Example any `json:"example"` + Min string `json:"min"` + Max string `json:"max"` + Enum []any `json:"enum"` + Options []Option `json:"options"` + Properties map[string]Field `json:"properties"` +} + +// FlagName is the kebab-case CLI flag for this field (chat_id -> chat-id). +func (f Field) FlagName() string { return strings.ReplaceAll(f.Name, "_", "-") } + +// Children returns the field's nested properties sorted by name. +func (f Field) Children() []Field { return fieldsOf(f.Properties, nil) } + +// Method is one API operation. Name is the parent map key. Affordance is kept +// raw so this package stays free of envelope concerns. +type Method struct { + Name string `json:"-"` + ID string `json:"id"` + Path string `json:"path"` + HTTPMethod string `json:"httpMethod"` + Description string `json:"description"` + Risk string `json:"risk"` + DocURL string `json:"docUrl"` + Danger bool `json:"danger"` + Tips []string `json:"tips"` + Scopes []string `json:"scopes"` + RequiredScopes []string `json:"requiredScopes"` + AccessTokens []string `json:"accessTokens"` + Affordance json.RawMessage `json:"affordance"` + Parameters map[string]Field `json:"parameters"` + RequestBody map[string]Field `json:"requestBody"` + ResponseBody map[string]Field `json:"responseBody"` +} + +// Params are the path/query parameters, sorted by name. +func (m Method) Params() []Field { + return fieldsOf(m.Parameters, func(f Field) bool { + return f.Location == "path" || f.Location == "query" + }) +} + +// Data are the non-file request-body fields (--data JSON), sorted by name. +func (m Method) Data() []Field { + return fieldsOf(m.RequestBody, func(f Field) bool { return f.Type != "file" }) +} + +// Files are the file-typed request-body fields (--file uploads), sorted by name. +func (m Method) Files() []Field { + return fieldsOf(m.RequestBody, func(f Field) bool { return f.Type == "file" }) +} + +// Response are the response-body fields, sorted by name. +func (m Method) Response() []Field { return fieldsOf(m.ResponseBody, nil) } + +// fieldsOf materializes a name->field map into a name-injected slice, optionally +// filtered, sorted by name for deterministic output. +func fieldsOf(byName map[string]Field, keep func(Field) bool) []Field { + out := make([]Field, 0, len(byName)) + for name, f := range byName { + f.Name = name + if keep == nil || keep(f) { + out = append(out, f) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Resource groups methods (and may nest sub-resources). Name is the parent key. +type Resource struct { + Name string `json:"-"` + Methods map[string]Method `json:"methods"` + Resources map[string]Resource `json:"resources"` +} + +// MethodList returns the resource's methods, name-injected and sorted by name. +func (r Resource) MethodList() []Method { + out := make([]Method, 0, len(r.Methods)) + for name, m := range r.Methods { + m.Name = name + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Method looks up one method by name with Name injected, or false if absent. +// Use this instead of indexing Methods directly so Name is never left empty. +func (r Resource) Method(name string) (Method, bool) { + m, ok := r.Methods[name] + if !ok { + return Method{}, false + } + m.Name = name + return m, true +} + +// SubResources returns nested resources, name-injected and sorted by name. +func (r Resource) SubResources() []Resource { return resourcesOf(r.Resources) } + +// Service is one API service. Name is a real JSON field (services is an array). +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Title string `json:"title"` + Description string `json:"description"` + ServicePath string `json:"servicePath"` + Resources map[string]Resource `json:"resources"` +} + +// ResourceList returns the service's top-level resources, name-injected and +// sorted by name. +func (s Service) ResourceList() []Resource { return resourcesOf(s.Resources) } + +// Resource looks up one (possibly dotted) resource by name with Name injected, +// or false if absent. Use this instead of indexing Resources directly. +func (s Service) Resource(name string) (Resource, bool) { + r, ok := s.Resources[name] + if !ok { + return Resource{}, false + } + r.Name = name + return r, true +} + +func resourcesOf(byName map[string]Resource) []Resource { + out := make([]Resource, 0, len(byName)) + for name, r := range byName { + r.Name = name + out = append(out, r) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Registry is the top-level metadata document. +type Registry struct { + Services []Service `json:"services"` + Version string `json:"version"` +} + +// Parse decodes the metadata JSON into the typed Registry. Returns a zero +// Registry for empty input. +func Parse(data []byte) (Registry, error) { + if len(data) == 0 { + return Registry{}, nil + } + var reg Registry + if err := json.Unmarshal(data, ®); err != nil { + return Registry{}, err + } + return reg, nil +} + +// FromMap decodes a single method spec from its map form into a typed Method. +// Convenience constructor for building typed values from map literals (tests). +func FromMap(method map[string]interface{}) Method { + b, err := json.Marshal(method) + if err != nil { + return Method{} + } + var m Method + _ = json.Unmarshal(b, &m) + return m +} + +// ServiceFromMap decodes a service spec from its map form into a typed Service. +// Convenience constructor for building typed values from map literals (tests). +func ServiceFromMap(svc map[string]interface{}) Service { + b, err := json.Marshal(svc) + if err != nil { + return Service{} + } + var s Service + _ = json.Unmarshal(b, &s) + return s +} diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go new file mode 100644 index 000000000..16ab5aeaa --- /dev/null +++ b/internal/meta/meta_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "reflect" + "testing" +) + +const sampleJSON = `{ + "version": "1.0.0", + "services": [ + { + "name": "im", + "servicePath": "/open-apis/im/v1", + "resources": { + "chat.members": { + "methods": { + "create": { + "httpMethod": "POST", + "risk": "high-risk-write", + "parameters": { + "member_id_type": {"type": "string", "location": "query", "options": [{"value": "open_id"}, {"value": "user_id"}]}, + "chat_id": {"type": "string", "location": "path", "required": true, "example": "oc_x"}, + "x_header": {"type": "string", "location": "header"} + }, + "requestBody": { + "id_list": {"type": "list", "required": true}, + "avatar": {"type": "file"} + } + } + } + } + } + } + ] +}` + +func loadSample(t *testing.T) (Resource, Method) { + t.Helper() + reg, err := Parse([]byte(sampleJSON)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + res := reg.Services[0].ResourceList() + if len(res) != 1 { + t.Fatalf("want 1 resource, got %d", len(res)) + } + methods := res[0].MethodList() + if len(methods) != 1 { + t.Fatalf("want 1 method, got %d", len(methods)) + } + return res[0], methods[0] +} + +func TestParse_TypedAndNameInjected(t *testing.T) { + res, m := loadSample(t) + if res.Name != "chat.members" { + t.Errorf("resource name = %q, want chat.members", res.Name) + } + if m.Name != "create" || m.HTTPMethod != "POST" || m.Risk != "high-risk-write" { + t.Errorf("method = %+v", m) + } +} + +func TestMethod_AccessorsSortedByName(t *testing.T) { + _, m := loadSample(t) + + // Params: path/query only (header dropped), sorted by name. + var params []string + for _, f := range m.Params() { + params = append(params, f.Name) + } + if want := []string{"chat_id", "member_id_type"}; !reflect.DeepEqual(params, want) { + t.Errorf("Params() = %v, want %v (sorted, header dropped)", params, want) + } + + if d := m.Data(); len(d) != 1 || d[0].Name != "id_list" { + t.Errorf("Data() = %+v, want [id_list]", d) + } + if f := m.Files(); len(f) != 1 || f[0].Name != "avatar" { + t.Errorf("Files() = %+v, want [avatar]", f) + } +} + +func TestField_FlagNameAndOptions(t *testing.T) { + _, m := loadSample(t) + by := make(map[string]Field) + for _, f := range m.Params() { + by[f.Name] = f + } + + if got := by["chat_id"].FlagName(); got != "chat-id" { + t.Errorf("FlagName = %q, want chat-id", got) + } + if !by["chat_id"].Required || by["chat_id"].Example != "oc_x" { + t.Errorf("chat_id required/example wrong: %+v", by["chat_id"]) + } + opts := by["member_id_type"].Options + if len(opts) != 2 || opts[0].Value != "open_id" || opts[1].Value != "user_id" { + t.Errorf("member_id_type options = %+v", opts) + } +} + +// TestParse_TolerantOptionValue guards against whole-catalog blanking: a single +// options[].value that arrives as a JSON number (not the usual quoted string) +// must NOT fail the entire registry unmarshal. Option.Value is `any`, so it +// parses and coerces like Enum instead of returning an empty Registry. +func TestParse_TolerantOptionValue(t *testing.T) { + data := []byte(`{"services":[{"name":"im","servicePath":"/x","resources":{ + "chat":{"methods":{"create":{"parameters":{ + "flag":{"type":"integer","location":"query","options":[{"value":0,"description":"off"},{"value":1,"description":"on"}]} + }}}}}}]}`) + reg, err := Parse(data) + if err != nil { + t.Fatalf("Parse failed on numeric option value (would blank the catalog): %v", err) + } + if len(reg.Services) != 1 { + t.Fatalf("expected 1 service, got %d (catalog blanked)", len(reg.Services)) + } + // The numeric option coerces into the typed enum as sorted int64 (not + // float64): the integer field's canonical type drives normalization. + m, _ := reg.Services[0].Resource("chat") + method, _ := m.Method("create") + by := map[string]Field{} + for _, f := range method.Params() { + by[f.Name] = f + } + if got := by["flag"].EnumValues(); !reflect.DeepEqual(got, []any{int64(0), int64(1)}) { + t.Errorf("numeric-valued enum did not coerce to sorted int64: %#v", got) + } +} + +func TestParse_Empty(t *testing.T) { + reg, err := Parse(nil) + if err != nil || len(reg.Services) != 0 { + t.Fatalf("Parse(nil) = %+v, %v", reg, err) + } +} diff --git a/internal/meta/normalize.go b/internal/meta/normalize.go new file mode 100644 index 000000000..a513a9b0c --- /dev/null +++ b/internal/meta/normalize.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "fmt" + "sort" + "strconv" +) + +// CanonicalType maps meta_data's non-standard type names to the standard +// JSON-Schema/type vocabulary used downstream (envelope render, flag kinds): +// "file" -> "string", "list" -> "array"; other types pass through unchanged. +func (f Field) CanonicalType() string { + switch f.Type { + case "file": + return "string" + case "list": + return "array" + default: + return f.Type + } +} + +// coerceLiteral converts a meta_data literal (default/enum/example) to the +// field's canonical type. Literals may arrive as strings (meta_data's usual +// form) OR already typed — a JSON number unmarshals to float64, a JSON bool to +// bool — so both must be normalized to the SAME Go type the canonical type +// implies (int64 for "integer", float64 for "number", bool for "boolean"). +// Otherwise enumLess, which type-asserts on that Go type, can't order the +// values. Returns (value, true) on success, (nil, false) when the literal +// cannot be represented in the declared type. +func coerceLiteral(canonicalType string, raw any) (any, bool) { + switch canonicalType { + case "integer": + switch v := raw.(type) { + case string: + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n, true + } + case float64: // JSON number; accept only when it's a whole value + if v == float64(int64(v)) { + return int64(v), true + } + case int64: + return v, true + case int: + return int64(v), true + } + return nil, false + case "number": + switch v := raw.(type) { + case string: + if n, err := strconv.ParseFloat(v, 64); err == nil { + return n, true + } + case float64: + return v, true + case int64: + return float64(v), true + case int: + return float64(v), true + } + return nil, false + case "boolean": + switch v := raw.(type) { + case string: + switch v { + case "true": + return true, true + case "false": + return false, true + } + case bool: + return v, true + } + return nil, false + default: // "string", "array", "" (objects), or unknown — pass through as-is + return raw, true + } +} + +// enumLess orders two coerced enum values for the canonical type, so integer +// enums end up [1 2 10] not lexicographic [1 10 2]. +func enumLess(canonicalType string, a, b any) bool { + switch canonicalType { + case "integer": + ai, _ := a.(int64) + bi, _ := b.(int64) + return ai < bi + case "number": + af, _ := a.(float64) + bf, _ := b.(float64) + return af < bf + case "boolean": + ab, _ := a.(bool) + bb, _ := b.(bool) + return !ab && bb + default: + as, _ := a.(string) + bs, _ := b.(string) + return as < bs + } +} + +// EnumOption is one allowed value paired with its human description. The +// description comes from options[].description and is empty for the bare `enum` +// form (which carries no descriptions). +type EnumOption struct { + Value any + Description string +} + +// EnumOptions returns the field's allowed values paired with their descriptions +// — from enum, or from options when enum is absent — coerced to the canonical +// type and ordered: numeric and boolean values are sorted; string values keep +// source order (which can encode priority). Uncoercible literals are dropped. +// Returns nil when the field declares no enum constraint. +func (f Field) EnumOptions() []EnumOption { + ct := f.CanonicalType() + var out []EnumOption + switch { + case len(f.Enum) > 0: + for _, e := range f.Enum { + if v, ok := coerceLiteral(ct, e); ok { + out = append(out, EnumOption{Value: v}) + } + } + case len(f.Options) > 0: + seen := make(map[string]bool) + for _, o := range f.Options { + key := fmt.Sprintf("%v", o.Value) + if seen[key] { + continue + } + seen[key] = true + if v, ok := coerceLiteral(ct, o.Value); ok { + out = append(out, EnumOption{Value: v, Description: o.Description}) + } + } + } + if len(out) > 0 && ct != "string" && ct != "" { + sort.SliceStable(out, func(i, j int) bool { return enumLess(ct, out[i].Value, out[j].Value) }) + } + return out +} + +// EnumValues returns the field's allowed values — the value projection of +// EnumOptions, in the same order. nil when the field declares no enum +// constraint. (Kept as the values-only accessor for the envelope and flag +// completion, which don't need descriptions.) +func (f Field) EnumValues() []any { + opts := f.EnumOptions() + if len(opts) == 0 { + return nil + } + out := make([]any, len(opts)) + for i, o := range opts { + out[i] = o.Value + } + return out +} + +// CoercedDefault returns Default coerced to the canonical type, or nil when the +// field has no default or the literal cannot be coerced. +func (f Field) CoercedDefault() any { return f.coerce(f.Default) } + +// CoercedExample returns Example coerced to the canonical type, or nil when the +// field has no example or the literal cannot be coerced. +func (f Field) CoercedExample() any { return f.coerce(f.Example) } + +func (f Field) coerce(raw any) any { + if raw == nil { + return nil + } + if v, ok := coerceLiteral(f.CanonicalType(), raw); ok { + return v + } + return nil +} diff --git a/internal/meta/normalize_test.go b/internal/meta/normalize_test.go new file mode 100644 index 000000000..0315c87de --- /dev/null +++ b/internal/meta/normalize_test.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "reflect" + "testing" +) + +func TestField_CanonicalType(t *testing.T) { + cases := map[string]string{ + "file": "string", // meta_data's non-standard "file" is a string with binary format + "list": "array", // "list" is meta_data's spelling of a JSON array + "integer": "integer", + "boolean": "boolean", + "string": "string", + "": "", + } + for in, want := range cases { + if got := (Field{Type: in}).CanonicalType(); got != want { + t.Errorf("CanonicalType(%q) = %q, want %q", in, got, want) + } + } +} + +func TestField_EnumValues(t *testing.T) { + // string enum keeps source order (order can encode priority) + if got := (Field{Type: "string", Enum: []any{"b", "a", "c"}}).EnumValues(); !reflect.DeepEqual(got, []any{"b", "a", "c"}) { + t.Errorf("string enum = %v, want source order [b a c]", got) + } + // integer enum: string-stored literals coerced to int64 and numerically sorted + if got := (Field{Type: "integer", Enum: []any{"10", "2", "1"}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(10)}) { + t.Errorf("integer enum = %v, want [1 2 10] coerced+sorted", got) + } + // options used when enum absent, deduped, source order for strings + if got := (Field{Type: "string", Options: []Option{{Value: "x"}, {Value: "x"}, {Value: "y"}}}).EnumValues(); !reflect.DeepEqual(got, []any{"x", "y"}) { + t.Errorf("options enum = %v, want [x y] deduped", got) + } + // uncoercible literal dropped + if got := (Field{Type: "integer", Enum: []any{"1", "nope", "2"}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2)}) { + t.Errorf("bad enum = %v, want [1 2] (nope dropped)", got) + } + // no enum/options -> nil + if got := (Field{Type: "string"}).EnumValues(); got != nil { + t.Errorf("empty enum = %v, want nil", got) + } +} + +func TestField_EnumOptions(t *testing.T) { + // options carry descriptions, kept paired with their (string) value in source order + fo := Field{Type: "string", Options: []Option{ + {Value: "open_id", Description: "以 open_id 标识"}, + {Value: "open_id", Description: "dup ignored"}, // dedup keeps first + {Value: "user_id", Description: "以 user_id 标识"}, + }} + got := fo.EnumOptions() + want := []EnumOption{ + {Value: "open_id", Description: "以 open_id 标识"}, + {Value: "user_id", Description: "以 user_id 标识"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("EnumOptions = %+v, want %+v", got, want) + } + + // integer enum (bare form): values coerced + numerically sorted, no descriptions + fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}} + gi := fi.EnumOptions() + if len(gi) != 3 || gi[0].Value != int64(1) || gi[2].Value != int64(10) || gi[0].Description != "" { + t.Errorf("EnumOptions(integer) = %+v, want [1 2 10] coerced+sorted, no desc", gi) + } + + // EnumValues stays the value projection of EnumOptions (golden-critical) + if !reflect.DeepEqual(fo.EnumValues(), []any{"open_id", "user_id"}) { + t.Errorf("EnumValues diverged from EnumOptions values: %v", fo.EnumValues()) + } + // unconstrained -> nil + if (Field{Type: "string"}).EnumOptions() != nil { + t.Error("EnumOptions should be nil when unconstrained") + } +} + +func TestField_Enum_NumberAndBoolean(t *testing.T) { + // number: string-stored floats coerced to float64 and numerically sorted + if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) { + t.Errorf("number enum = %v, want [1.5 2.5 10] coerced+sorted", got) + } + // number: uncoercible literal dropped + if got := (Field{Type: "number", Enum: []any{"1.5", "x", "2.5"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5}) { + t.Errorf("number enum with bad value = %v, want [1.5 2.5]", got) + } + // boolean: true/false coerced and sorted (false before true); invalid dropped + if got := (Field{Type: "boolean", Enum: []any{"true", "maybe", "false"}}).EnumValues(); !reflect.DeepEqual(got, []any{false, true}) { + t.Errorf("boolean enum = %v, want [false true]", got) + } +} + +func TestField_EnumOptions_NonStringValuesNormalized(t *testing.T) { + // JSON numbers/bools arrive already-typed (a number is float64, not a + // string) — e.g. options[].value: 0. They must still be normalized to the + // field's canonical type (int64 for "integer") and sorted numerically; + // leaving them as float64 both yields the wrong type and defeats enumLess, + // whose integer branch asserts int64 and would otherwise treat every value + // as zero (no sort). + if got := (Field{Type: "integer", Options: []Option{ + {Value: float64(10)}, {Value: float64(2)}, {Value: float64(1)}, + }}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(10)}) { + t.Errorf("integer options from float64 = %#v, want [int64(1) int64(2) int64(10)]", got) + } + // bare enum form, JSON numbers + if got := (Field{Type: "integer", Enum: []any{float64(3), float64(1), float64(2)}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(3)}) { + t.Errorf("integer enum from float64 = %#v, want [int64(1) int64(2) int64(3)]", got) + } + // number field: a whole-valued float stays float64 + if got := (Field{Type: "number", Enum: []any{float64(2), float64(1)}}).EnumValues(); !reflect.DeepEqual(got, []any{float64(1), float64(2)}) { + t.Errorf("number enum from float64 = %#v, want [float64(1) float64(2)]", got) + } + // boolean field: native bools coerce + sort (false < true) + if got := (Field{Type: "boolean", Enum: []any{true, false}}).EnumValues(); !reflect.DeepEqual(got, []any{false, true}) { + t.Errorf("boolean enum from bool = %#v, want [false true]", got) + } + // non-integral float under integer is uncoercible -> dropped (mirrors how "2.5" fails ParseInt) + if got := (Field{Type: "integer", Enum: []any{float64(1), float64(2.5), float64(3)}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(3)}) { + t.Errorf("integer enum with fractional float = %#v, want [int64(1) int64(3)]", got) + } +} + +func TestField_CoercedDefaultAndExample(t *testing.T) { + if got := (Field{Type: "integer", Default: "5"}).CoercedDefault(); got != int64(5) { + t.Errorf("CoercedDefault integer = %v (%T), want int64(5)", got, got) + } + if got := (Field{Type: "integer", Default: "bad"}).CoercedDefault(); got != nil { + t.Errorf("CoercedDefault uncoercible = %v, want nil", got) + } + if got := (Field{Type: "string"}).CoercedDefault(); got != nil { + t.Errorf("CoercedDefault absent = %v, want nil", got) + } + if got := (Field{Type: "boolean", Example: "true"}).CoercedExample(); got != true { + t.Errorf("CoercedExample boolean = %v, want true", got) + } +} diff --git a/internal/registry/catalog.go b/internal/registry/catalog.go new file mode 100644 index 000000000..30a0af8c8 --- /dev/null +++ b/internal/registry/catalog.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import "github.com/larksuite/cli/internal/apicatalog" + +// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free) +// metadata — deterministic across machines, for `lark-cli schema`, golden tests +// and schema lint. +func EmbeddedCatalog() apicatalog.Catalog { + return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped()) +} + +// RuntimeCatalog returns a navigation catalog over the merged (embedded + remote +// overlay) metadata — for service command registration and scope discovery, +// where overlay methods must be reachable. +func RuntimeCatalog() apicatalog.Catalog { + return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped()) +} diff --git a/internal/registry/helpers.go b/internal/registry/helpers.go index 39668f2ee..b10614b15 100644 --- a/internal/registry/helpers.go +++ b/internal/registry/helpers.go @@ -3,54 +3,17 @@ package registry -// GetStrFromMap extracts a string value from map[string]interface{}. -func GetStrFromMap(m map[string]interface{}, key string) string { - if m == nil { - return "" - } - if v, ok := m[key]; ok { - if s, ok := v.(string); ok { - return s - } - } - return "" -} +import "github.com/larksuite/cli/internal/meta" -// GetStrSliceFromMap extracts a []string value from map[string]interface{}. -// Returns nil if the key is missing or the value is not a string slice. -func GetStrSliceFromMap(m map[string]interface{}, key string) []string { - if m == nil { - return nil - } - raw, ok := m[key].([]interface{}) - if !ok { - return nil - } - result := make([]string, 0, len(raw)) - for _, v := range raw { - if s, ok := v.(string); ok { - result = append(result, s) - } - } - if len(result) == 0 { - return nil - } - return result -} - -// DeclaredScopesForMethod returns the scopes declared by a method's -// from_meta entry for the given identity. Prefers the explicit -// `requiredScopes` field when present; otherwise returns the single -// recommended scope from `scopes` (or the first scope as a final fallback). -// Returns nil when the method has no scope information. -func DeclaredScopesForMethod(method map[string]interface{}, identity string) []string { - if method == nil { - return nil - } - if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 { - out := make([]string, 0, len(requiredRaw)) - for _, v := range requiredRaw { - if s, ok := v.(string); ok && s != "" { +// DeclaredScopesForMethod returns the scopes declared by a method for the given +// identity. Prefers the explicit `requiredScopes` field when present; otherwise +// returns the single recommended scope from `scopes` (or the first scope as a +// final fallback). Returns nil when the method has no scope information. +func DeclaredScopesForMethod(m meta.Method, identity string) []string { + if len(m.RequiredScopes) > 0 { + out := make([]string, 0, len(m.RequiredScopes)) + for _, s := range m.RequiredScopes { + if s != "" { out = append(out, s) } } @@ -58,54 +21,11 @@ func DeclaredScopesForMethod(method map[string]interface{}, identity string) []s return out } } - rawScopes, _ := method["scopes"].([]interface{}) - if len(rawScopes) == 0 { + if len(m.Scopes) == 0 { return nil } - recommended := SelectRecommendedScope(rawScopes, identity) - if recommended == "" { - for _, raw := range rawScopes { - if s, ok := raw.(string); ok && s != "" { - recommended = s - break - } - } - } - if recommended == "" { - return nil - } - return []string{recommended} -} - -// SelectRecommendedScope selects the known scope with the highest priority score -// (higher = more recommended / least privilege). -// Scopes not in the priority table are skipped to avoid recommending invalid/unknown scopes. -func SelectRecommendedScope(scopes []interface{}, identity string) string { - priorities := LoadScopePriorities() - bestScore := -1 - bestScope := "" - for _, s := range scopes { - str, ok := s.(string) - if !ok { - continue - } - score, exists := priorities[str] - if !exists { - continue // skip unknown scopes - } - if score > bestScore { - bestScore = score - bestScope = str - } - } - if bestScope != "" { - return bestScope - } - // Fallback: if no scope is in the priority table, return the first one. - if len(scopes) > 0 { - if s, ok := scopes[0].(string); ok { - return s - } + if recommended := SelectRecommendedScopeFromStrings(m.Scopes, identity); recommended != "" { + return []string{recommended} } - return "" + return nil } diff --git a/internal/registry/loader.go b/internal/registry/loader.go index 93360c2da..813526f46 100644 --- a/internal/registry/loader.go +++ b/internal/registry/loader.go @@ -14,6 +14,7 @@ import ( "sync" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" ) //go:embed scope_priorities.json scope_overrides.json @@ -22,68 +23,42 @@ var registryFS embed.FS // embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in. var embeddedMetaJSON []byte -// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers -// that need to parse key order or other JSON-level structure not exposed by -// LoadFromMeta (which loses map insertion order). -func EmbeddedMetaJSON() []byte { - return embeddedMetaJSON -} - var ( - embeddedServicesMap map[string]map[string]interface{} // service name -> spec - embeddedServiceNames []string // sorted - embeddedParseOnce sync.Once + embeddedServices []meta.Service // parsed once, sorted by name (no overlay) + embeddedServicesByName map[string]meta.Service // same, keyed by name + embeddedVersion string // version from embedded meta_data.json + embeddedParseOnce sync.Once ) -// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map -// without touching mergedServices. Safe to call multiple times (sync.Once). -func parseEmbeddedServices() { +// parseEmbedded decodes the embedded meta_data.json into the typed model exactly +// once. It is the single parse of the embedded bytes: both the overlay-free +// envelope path (EmbeddedServicesTyped) and the merged command/scope path +// (loadEmbeddedIntoMerged) build from this result, so the JSON is never parsed +// twice and no map round-trip is needed downstream. +func parseEmbedded() { embeddedParseOnce.Do(func() { - embeddedServicesMap = make(map[string]map[string]interface{}) - if len(embeddedMetaJSON) == 0 { - return - } - var wrapper struct { - Services []map[string]interface{} `json:"services"` - } - if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil { - return - } - for _, svc := range wrapper.Services { - name, _ := svc["name"].(string) - if name == "" { - continue - } - embeddedServicesMap[name] = svc - } - embeddedServiceNames = make([]string, 0, len(embeddedServicesMap)) - for name := range embeddedServicesMap { - embeddedServiceNames = append(embeddedServiceNames, name) + reg, _ := meta.Parse(embeddedMetaJSON) + embeddedVersion = reg.Version + embeddedServices = reg.Services + sort.Slice(embeddedServices, func(i, j int) bool { return embeddedServices[i].Name < embeddedServices[j].Name }) + embeddedServicesByName = make(map[string]meta.Service, len(embeddedServices)) + for _, svc := range embeddedServices { + embeddedServicesByName[svc.Name] = svc } - sort.Strings(embeddedServiceNames) }) } -// EmbeddedSpec returns the embedded spec for one service, or nil if unknown. -// Bypasses remote overlay — used for deterministic envelope output. -func EmbeddedSpec(serviceName string) map[string]interface{} { - parseEmbeddedServices() - return embeddedServicesMap[serviceName] -} - -// EmbeddedServiceNames returns sorted embedded service names (no overlay). -// Returns a defensive copy — callers must not mutate the package-level slice. -func EmbeddedServiceNames() []string { - parseEmbeddedServices() - out := make([]string, len(embeddedServiceNames)) - copy(out, embeddedServiceNames) - return out +// EmbeddedServicesTyped returns the embedded services (no remote overlay) as the +// typed meta model, sorted by name. This is the overlay-free parse boundary the +// schema envelope builds from — deterministic across machines. +func EmbeddedServicesTyped() []meta.Service { + parseEmbedded() + return embeddedServices } var ( - mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec - mergedProjectList []string // sorted project names - embeddedVersion string // version from embedded meta_data.json + mergedServices = make(map[string]meta.Service) // project name → typed service (embedded + overlay) + mergedProjectList []string // sorted project names initOnce sync.Once ) @@ -106,8 +81,8 @@ func InitWithBrand(brand core.LarkBrand) { // 2. Remote overlay if remoteEnabled() && cacheWritable() { // Check if brand changed since last cache - meta, metaErr := loadCacheMeta() - brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand) + cm, metaErr := loadCacheMeta() + brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand) if !brandChanged { if cached, err := loadCachedMerged(); err == nil { @@ -117,7 +92,7 @@ func InitWithBrand(brand core.LarkBrand) { if len(mergedServices) == 0 || brandChanged { // No data at all or brand changed — must sync fetch doSyncFetch() - } else if shouldRefresh(meta) || metaErr != nil { + } else if shouldRefresh(cm) || metaErr != nil { // Have embedded/cached data; refresh in background if TTL expired or first run triggerBackgroundRefresh() } @@ -127,18 +102,13 @@ func InitWithBrand(brand core.LarkBrand) { }) } -// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates -// mergedServices. No-op if meta_data.json is not compiled in. +// loadEmbeddedIntoMerged seeds mergedServices from the embedded typed services +// (the same parse EmbeddedServicesTyped uses). No-op if no services compiled in. func loadEmbeddedIntoMerged() { - if len(embeddedMetaJSON) == 0 { - return + parseEmbedded() + for name, svc := range embeddedServicesByName { + mergedServices[name] = svc } - var reg MergedRegistry - if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil { - return - } - embeddedVersion = reg.Version - overlayMergedServices(®) } // rebuildProjectList rebuilds the sorted list of project names from mergedServices. @@ -150,83 +120,32 @@ func rebuildProjectList() { sort.Strings(mergedProjectList) } -var cachedAllScopes map[string][]string - -// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json -// for the given identity ("user" or "tenant"). Results are deduplicated and sorted. -func CollectAllScopesFromMeta(identity string) []string { - if cachedAllScopes == nil { - cachedAllScopes = make(map[string][]string) - } - if cached, ok := cachedAllScopes[identity]; ok { - return cached - } +var ( + servicesTyped []meta.Service + servicesTypedOnce sync.Once +) - scopeSet := make(map[string]bool) - for _, project := range ListFromMetaProjects() { - spec := LoadFromMeta(project) - if spec == nil { - continue - } - resources, ok := spec["resources"].(map[string]interface{}) - if !ok { - continue - } - for _, resSpec := range resources { - resMap, ok := resSpec.(map[string]interface{}) - if !ok { - continue - } - methods, ok := resMap["methods"].(map[string]interface{}) - if !ok { - continue - } - for _, methodSpec := range methods { - methodMap, ok := methodSpec.(map[string]interface{}) - if !ok { - continue - } - // Check if method supports the requested identity - if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { - supported := false - for _, t := range tokens { - if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { - supported = true - break - } - } - if !supported { - continue - } - } - // Collect scopes - scopes, ok := methodMap["scopes"].([]interface{}) - if !ok { - continue - } - for _, s := range scopes { - if str, ok := s.(string); ok { - scopeSet[str] = true - } - } - } +// ServicesTyped returns the merged registry (embedded + remote overlay) as typed +// meta.Services, sorted by name. The merged store is already typed, so this just +// projects it into a sorted slice — no map round-trip. This is the typed entry +// the command tree and scope computation build from. +func ServicesTyped() []meta.Service { + servicesTypedOnce.Do(func() { + Init() + servicesTyped = make([]meta.Service, 0, len(mergedProjectList)) + for _, name := range mergedProjectList { + servicesTyped = append(servicesTyped, mergedServices[name]) } - } - - result := make([]string, 0, len(scopeSet)) - for s := range scopeSet { - result = append(result, s) - } - sort.Strings(result) - cachedAllScopes[identity] = result - return result + }) + return servicesTyped } -// LoadFromMeta loads a service schema by project name. -// It returns data from the merged registry (embedded + cached remote overlay). -func LoadFromMeta(project string) map[string]interface{} { +// ServiceTyped returns one merged service (embedded + overlay) by name, or false +// if unknown. +func ServiceTyped(name string) (meta.Service, bool) { Init() - return mergedServices[project] + svc, ok := mergedServices[name] + return svc, ok } // ListFromMetaProjects lists available service project names (sorted). diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index a2482a8f6..f1fba1afc 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -65,8 +65,7 @@ func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) { } t.Logf("%s=%d, %s=%d", scopeA, scoreA, scopeB, scoreB) - scopes := []interface{}{scopeB, scopeA} - result := SelectRecommendedScope(scopes, "user") + result := bestScope([]string{scopeB, scopeA}, priorities) // Should pick the higher-scored one (higher = more recommended) if scoreA > scoreB { @@ -81,11 +80,11 @@ func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) { } func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) { - scopes := []interface{}{ + scopes := []string{ "zzz_unknown:scope:a", "zzz_unknown:scope:b", } - result := SelectRecommendedScope(scopes, "user") + result := bestScope(scopes, LoadScopePriorities()) // All unknown scopes get DefaultScopeScore; first one with that score wins if result != "zzz_unknown:scope:a" { t.Errorf("expected zzz_unknown:scope:a, got %s", result) @@ -93,13 +92,10 @@ func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) { } func TestSelectRecommendedScope_Empty(t *testing.T) { - result := SelectRecommendedScope(nil, "user") - if result != "" { + if result := bestScope(nil, LoadScopePriorities()); result != "" { t.Errorf("expected empty string, got %s", result) } - - result = SelectRecommendedScope([]interface{}{}, "user") - if result != "" { + if result := bestScope([]string{}, LoadScopePriorities()); result != "" { t.Errorf("expected empty string, got %s", result) } } @@ -330,27 +326,6 @@ func TestFilterAutoApproveScopes_Empty(t *testing.T) { // --- Helper functions --- -func TestGetStrFromMap(t *testing.T) { - m := map[string]interface{}{ - "key1": "value1", - "key2": 42, - "key3": nil, - } - - if v := GetStrFromMap(m, "key1"); v != "value1" { - t.Errorf("expected value1, got %s", v) - } - if v := GetStrFromMap(m, "key2"); v != "" { - t.Errorf("expected empty for non-string value, got %s", v) - } - if v := GetStrFromMap(m, "missing"); v != "" { - t.Errorf("expected empty for missing key, got %s", v) - } - if v := GetStrFromMap(nil, "key"); v != "" { - t.Errorf("expected empty for nil map, got %s", v) - } -} - func TestGetRegistryDir(t *testing.T) { dir := GetRegistryDir() if dir == "" { diff --git a/internal/registry/remote.go b/internal/registry/remote.go index b229f641f..a2410fcc7 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -34,10 +35,12 @@ type CacheMeta struct { Brand string `json:"brand,omitempty"` } -// MergedRegistry is the top-level structure of remote_meta.json. +// MergedRegistry is the top-level structure of remote_meta.json. Services are +// decoded straight into the typed meta model so embedded, cached, and remote +// data share one representation (no map intermediary, no re-parse). type MergedRegistry struct { - Version string `json:"version"` - Services []map[string]interface{} `json:"services"` + Version string `json:"version"` + Services []meta.Service `json:"services"` } // remoteResponse is the envelope returned by the remote API. @@ -124,22 +127,22 @@ func cacheWritable() bool { // --- cache I/O --- func loadCacheMeta() (CacheMeta, error) { - var meta CacheMeta + var cm CacheMeta data, err := vfs.ReadFile(cacheMetaPath()) if err != nil { - return meta, err + return cm, err } - if err = json.Unmarshal(data, &meta); err != nil { - return meta, err + if err = json.Unmarshal(data, &cm); err != nil { + return cm, err } - return meta, nil + return cm, nil } -func saveCacheMeta(meta CacheMeta) error { +func saveCacheMeta(cm CacheMeta) error { if err := vfs.MkdirAll(cacheDir(), 0700); err != nil { return err } - data, err := json.Marshal(meta) + data, err := json.Marshal(cm) if err != nil { return err } @@ -162,14 +165,14 @@ func loadCachedMerged() (*MergedRegistry, error) { return ®, nil } -func saveCachedMerged(data []byte, meta CacheMeta) error { +func saveCachedMerged(data []byte, cm CacheMeta) error { if err := vfs.MkdirAll(cacheDir(), 0700); err != nil { return err } if err := validate.AtomicWrite(cachePath(), data, 0644); err != nil { return err } - return saveCacheMeta(meta) + return saveCacheMeta(cm) } // --- HTTP fetch --- @@ -244,12 +247,12 @@ func doSyncFetch() { }) return } - meta := CacheMeta{ + cm := CacheMeta{ LastCheckAt: time.Now().Unix(), Version: reg.Version, Brand: string(configuredBrand), } - _ = saveCachedMerged(data, meta) + _ = saveCachedMerged(data, cm) overlayMergedServices(reg) } @@ -272,22 +275,22 @@ func triggerBackgroundRefresh() { func doBackgroundRefresh() { defer func() { _ = recover() }() - meta, _ := loadCacheMeta() - version := meta.Version + cm, _ := loadCacheMeta() + version := cm.Version if version == "" { version = embeddedVersion } data, reg, err := fetchRemoteMerged(version) if err != nil { // On error, update last_check_at to avoid retrying every invocation - meta.LastCheckAt = time.Now().Unix() - _ = saveCacheMeta(meta) + cm.LastCheckAt = time.Now().Unix() + _ = saveCacheMeta(cm) return } if reg == nil { // Version unchanged — just update check time - meta.LastCheckAt = time.Now().Unix() - _ = saveCacheMeta(meta) + cm.LastCheckAt = time.Now().Unix() + _ = saveCacheMeta(cm) return } newMeta := CacheMeta{ @@ -299,21 +302,20 @@ func doBackgroundRefresh() { } // shouldRefresh returns true if the cache TTL has expired. -func shouldRefresh(meta CacheMeta) bool { - if meta.LastCheckAt == 0 { +func shouldRefresh(cm CacheMeta) bool { + if cm.LastCheckAt == 0 { return true } - return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL() + return time.Since(time.Unix(cm.LastCheckAt, 0)) > metaTTL() } // overlayMergedServices merges remote services into the in-memory map. // Remote entries override embedded entries with the same name. func overlayMergedServices(reg *MergedRegistry) { for _, svc := range reg.Services { - name, ok := svc["name"].(string) - if !ok || name == "" { + if svc.Name == "" { continue } - mergedServices[name] = svc + mergedServices[svc.Name] = svc } } diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go index 0595ce9b9..3dfe5346b 100644 --- a/internal/registry/remote_test.go +++ b/internal/registry/remote_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" ) // waitBackgroundRefresh blocks until any in-flight background refresh started by @@ -30,7 +31,10 @@ func resetInit() { // reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant). waitBackgroundRefresh() initOnce = sync.Once{} - mergedServices = make(map[string]map[string]interface{}) + embeddedParseOnce = sync.Once{} + servicesTypedOnce = sync.Once{} + servicesTyped = nil + mergedServices = make(map[string]meta.Service) mergedProjectList = nil embeddedVersion = "" cachedAllScopes = nil @@ -71,13 +75,12 @@ func hasEmbeddedServices() bool { func testRegistry(name string) MergedRegistry { return MergedRegistry{ Version: "test-1.0", - Services: []map[string]interface{}{ + Services: []meta.Service{ { - "name": name, - "version": "v1", - "title": name + " API", - "servicePath": "/open-apis/" + name + "/v1", - "resources": map[string]interface{}{}, + Name: name, + Version: "v1", + Title: name + " API", + ServicePath: "/open-apis/" + name + "/v1", }, }, } @@ -131,7 +134,7 @@ func TestColdStart_NoEmbedded_SyncFetch(t *testing.T) { Init() - if spec := LoadFromMeta("remote_calendar"); spec == nil { + if _, ok := ServiceTyped("remote_calendar"); !ok { t.Fatal("expected remote_calendar from sync fetch") } } @@ -150,7 +153,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) { Init() // "fake_remote_svc" should not be loaded when remote is off - if spec := LoadFromMeta("fake_remote_svc"); spec != nil { + if _, ok := ServiceTyped("fake_remote_svc"); ok { t.Error("expected fake_remote_svc to NOT be loaded when remote is off") } } @@ -181,12 +184,12 @@ func TestCacheHit_WithinTTL(t *testing.T) { Init() // custom_svc should be loaded from cache overlay - if spec := LoadFromMeta("custom_svc"); spec == nil { + if _, ok := ServiceTyped("custom_svc"); !ok { t.Error("expected custom_svc from cache overlay") } // Embedded projects should still be present (if compiled in) if hasEmbeddedServices() { - if spec := LoadFromMeta("calendar"); spec == nil { + if _, ok := ServiceTyped("calendar"); !ok { t.Error("expected calendar from embedded data") } } @@ -227,7 +230,7 @@ func TestNetworkError_SilentDegradation(t *testing.T) { if len(projects) == 0 { t.Fatal("expected projects after network error") } - if spec := LoadFromMeta("cached_svc"); spec == nil { + if _, ok := ServiceTyped("cached_svc"); !ok { t.Fatal("expected cached_svc after network error") } @@ -304,19 +307,19 @@ func TestMetaTTL(t *testing.T) { func TestOverlayMergedServices(t *testing.T) { resetInit() - mergedServices = make(map[string]map[string]interface{}) - mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"} + mergedServices = make(map[string]meta.Service) + mergedServices["existing"] = meta.Service{Name: "existing", Version: "v1"} reg := &MergedRegistry{ - Services: []map[string]interface{}{ - {"name": "existing", "version": "v2"}, - {"name": "brand_new", "version": "v1"}, + Services: []meta.Service{ + {Name: "existing", Version: "v2"}, + {Name: "brand_new", Version: "v1"}, }, } overlayMergedServices(reg) // existing should be overridden - if v := mergedServices["existing"]["version"].(string); v != "v2" { + if v := mergedServices["existing"].Version; v != "v2" { t.Errorf("expected existing to be overridden to v2, got %s", v) } // brand_new should be added @@ -333,18 +336,18 @@ func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) { const leakedExisting = "test_isolation_existing_sentinel" const leakedOverlay = "test_isolation_overlay_sentinel" - mergedServices = map[string]map[string]interface{}{ - leakedExisting: {"name": leakedExisting, "version": "v1"}, + mergedServices = map[string]meta.Service{ + leakedExisting: {Name: leakedExisting, Version: "v1"}, } - overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}}) + overlayMergedServices(&MergedRegistry{Services: []meta.Service{{Name: leakedOverlay, Version: "v1"}}}) resetInit() Init() - if spec := LoadFromMeta(leakedExisting); spec != nil { + if _, ok := ServiceTyped(leakedExisting); ok { t.Fatalf("polluted service %q survived resetInit", leakedExisting) } - if spec := LoadFromMeta(leakedOverlay); spec != nil { + if _, ok := ServiceTyped(leakedOverlay); ok { t.Fatalf("polluted service %q survived resetInit", leakedOverlay) } } @@ -484,7 +487,7 @@ func TestBrandSwitchInvalidatesCache(t *testing.T) { // The old feishu_svc should NOT be loaded from stale cache // The new lark_svc from sync fetch should be available - if spec := LoadFromMeta("lark_svc"); spec == nil { + if _, ok := ServiceTyped("lark_svc"); !ok { t.Error("expected lark_svc after brand switch sync fetch") } } diff --git a/internal/registry/scope_hint.go b/internal/registry/scope_hint.go index 1431ac210..5e7ecbf1c 100644 --- a/internal/registry/scope_hint.go +++ b/internal/registry/scope_hint.go @@ -44,22 +44,12 @@ func ExtractRequiredScopes(detail interface{}) []string { return scopes } -// SelectRecommendedScopeFromStrings is a string-typed convenience wrapper -// around SelectRecommendedScope. When no scope is recognized by the priority -// table, it falls back to the first input scope so callers always have -// something to surface to users. -func SelectRecommendedScopeFromStrings(scopes []string, identity string) string { - if len(scopes) == 0 { - return "" - } - ifaces := make([]interface{}, len(scopes)) - for i, s := range scopes { - ifaces[i] = s - } - if recommended := SelectRecommendedScope(ifaces, identity); recommended != "" { - return recommended - } - return scopes[0] +// SelectRecommendedScopeFromStrings returns the highest-priority (least-privilege) +// scope to surface to users, or "" for no scopes. Unknown scopes score +// DefaultScopeScore, so an all-unknown list yields the first entry. Priority is +// identity-independent; the parameter is kept for call-site clarity. +func SelectRecommendedScopeFromStrings(scopes []string, _ string) string { + return bestScope(scopes, LoadScopePriorities()) } // BuildConsoleScopeURL returns the developer-console "apply scope" URL for the diff --git a/internal/registry/scopes.go b/internal/registry/scopes.go index 22678b6fe..0a2b47f77 100644 --- a/internal/registry/scopes.go +++ b/internal/registry/scopes.go @@ -6,16 +6,63 @@ package registry import ( "sort" "strings" + + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" ) -// IdentityToAccessToken maps the --identity flag value to the corresponding -// accessTokens value used in from_meta JSON files. Bot identity uses -// tenant_access_token, so "bot" maps to "tenant". -func IdentityToAccessToken(identity string) string { - if identity == "bot" { - return "tenant" +// methodsForProjects walks the runtime catalog once and returns the methods in +// the given projects that are reachable by the identity. Catalog navigation is +// owned by apicatalog; the collectors below only apply scope policy. +func methodsForProjects(projects []string, identity string) []apicatalog.MethodRef { + want := make(map[string]bool, len(projects)) + for _, p := range projects { + want[p] = true + } + wantToken := meta.TokenForIdentity(identity) + supported := func(m meta.Method) bool { return m.SupportsToken(wantToken) } + // Walk only the requested services (in catalog name order) instead of every + // service's methods then discarding the rest. + var out []apicatalog.MethodRef + for _, svc := range RuntimeCatalog().Services() { + if want[svc.Name] { + out = append(out, apicatalog.ServiceMethods(svc, supported)...) + } + } + return out +} + +// bestScope returns the highest-priority scope from scopes (minimum privilege), +// or "" when scopes is empty. +func bestScope(scopes []string, priorities map[string]int) string { + best := "" + bestScore := -1 + for _, s := range scopes { + score := DefaultScopeScore + if v, ok := priorities[s]; ok { + score = v + } + if score > bestScore { + bestScore = score + best = s + } + } + return best +} + +// FilterForStrictMode returns a method filter enforcing the strict-mode forced +// identity, or nil when strict mode is inactive (no filtering). The +// token/identity vocabulary (meta.TokenForIdentity) and the "no accessTokens = +// permissive" predicate (meta.Method.SupportsToken) both live in meta, so this +// only composes them — schema completion/render and service commands never +// re-derive identity semantics. +func FilterForStrictMode(mode core.StrictMode) apicatalog.MethodFilter { + if !mode.IsActive() { + return nil } - return identity + token := meta.TokenForIdentity(string(mode.ForcedIdentity())) + return func(m meta.Method) bool { return m.SupportsToken(token) } } // FilterScopes filters scopes by domain and permission level. @@ -76,71 +123,45 @@ func FilterScopes(allScopes []string, domains []string, permissions []string) [] return result } +var cachedAllScopes map[string][]string + +// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json +// for the given identity ("user" or "tenant"). Results are deduplicated and sorted. +func CollectAllScopesFromMeta(identity string) []string { + if cachedAllScopes == nil { + cachedAllScopes = make(map[string][]string) + } + if cached, ok := cachedAllScopes[identity]; ok { + return cached + } + + wantToken := meta.TokenForIdentity(identity) + supported := func(m meta.Method) bool { return m.SupportsToken(wantToken) } + scopeSet := make(map[string]bool) + for _, ref := range RuntimeCatalog().WalkMethods(supported) { + for _, s := range ref.Method.Scopes { + scopeSet[s] = true + } + } + + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + cachedAllScopes[identity] = result + return result +} + // CollectScopesForProjects collects the recommended scope for each API method // in the specified from_meta projects. For each method, only the scope with // the highest priority score is selected. func CollectScopesForProjects(projects []string, identity string) []string { priorities := LoadScopePriorities() scopeSet := make(map[string]bool) - for _, project := range projects { - spec := LoadFromMeta(project) - if spec == nil { - continue - } - resources, ok := spec["resources"].(map[string]interface{}) - if !ok { - continue - } - for _, resSpec := range resources { - resMap, ok := resSpec.(map[string]interface{}) - if !ok { - continue - } - methods, ok := resMap["methods"].(map[string]interface{}) - if !ok { - continue - } - for _, methodSpec := range methods { - methodMap, ok := methodSpec.(map[string]interface{}) - if !ok { - continue - } - if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { - supported := false - for _, t := range tokens { - if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { - supported = true - break - } - } - if !supported { - continue - } - } - scopes, ok := methodMap["scopes"].([]interface{}) - if !ok || len(scopes) == 0 { - continue - } - bestScope := "" - bestScore := -1 - for _, s := range scopes { - str, ok := s.(string) - if !ok { - continue - } - score := DefaultScopeScore - if v, exists := priorities[str]; exists { - score = v - } - if score > bestScore { - bestScore = score - bestScope = str - } - } - if bestScope != "" { - scopeSet[bestScope] = true - } - } + for _, ref := range methodsForProjects(projects, identity) { + if best := bestScope(ref.Method.Scopes, priorities); best != "" { + scopeSet[best] = true } } @@ -165,78 +186,25 @@ func CollectScopesWithSources(projects []string, identity string) ([]string, map scopeSet := make(map[string]bool) sources := make(map[string]*ScopeSource) - for _, project := range projects { - spec := LoadFromMeta(project) - if spec == nil { + for _, ref := range methodsForProjects(projects, identity) { + m := ref.Method + best := bestScope(m.Scopes, priorities) + if best == "" { continue } - resources, ok := spec["resources"].(map[string]interface{}) - if !ok { - continue + scopeSet[best] = true + if sources[best] == nil { + sources[best] = &ScopeSource{} } - for resName, resSpec := range resources { - resMap, ok := resSpec.(map[string]interface{}) - if !ok { - continue - } - methods, ok := resMap["methods"].(map[string]interface{}) - if !ok { - continue - } - for methodName, methodSpec := range methods { - methodMap, ok := methodSpec.(map[string]interface{}) - if !ok { - continue - } - if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { - supported := false - for _, t := range tokens { - if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { - supported = true - break - } - } - if !supported { - continue - } - } - scopes, ok := methodMap["scopes"].([]interface{}) - if !ok || len(scopes) == 0 { - continue - } - bestScope := "" - bestScore := -1 - for _, s := range scopes { - str, ok := s.(string) - if !ok { - continue - } - score := DefaultScopeScore - if v, exists := priorities[str]; exists { - score = v - } - if score > bestScore { - bestScore = score - bestScope = str - } - } - if bestScope != "" { - scopeSet[bestScope] = true - if sources[bestScope] == nil { - sources[bestScope] = &ScopeSource{} - } - methodID := GetStrFromMap(methodMap, "id") - if methodID == "" { - methodID = project + "." + resName + "." + methodName - } - httpMethod := GetStrFromMap(methodMap, "httpMethod") - if httpMethod == "" { - httpMethod = "?" - } - sources[bestScope].APIs = append(sources[bestScope].APIs, httpMethod+" "+methodID) - } - } + methodID := m.ID + if methodID == "" { + methodID = ref.ServiceName() + "." + ref.ResourceName() + "." + ref.MethodName() + } + httpMethod := m.HTTPMethod + if httpMethod == "" { + httpMethod = "?" } + sources[best].APIs = append(sources[best].APIs, httpMethod+" "+methodID) } // Sort API lists for stable output @@ -267,92 +235,27 @@ type CommandEntry struct { // - If the method has a "requiredScopes" field, all of those scopes are needed (conjunction). // - Otherwise, only the highest-priority scope from "scopes" is shown (minimum privilege). func CollectCommandScopes(projects []string, identity string) []CommandEntry { - priorities := LoadScopePriorities() var entries []CommandEntry - for _, project := range projects { - spec := LoadFromMeta(project) - if spec == nil { + for _, ref := range methodsForProjects(projects, identity) { + m := ref.Method + if len(m.Scopes) == 0 { continue } - resources, ok := spec["resources"].(map[string]interface{}) - if !ok { + + // Effective-scope policy (requiredScopes conjunction, else recommended) + // lives once in DeclaredScopesForMethod. + effectiveScopes := DeclaredScopesForMethod(m, identity) + if len(effectiveScopes) == 0 { continue } - for resName, resSpec := range resources { - resMap, ok := resSpec.(map[string]interface{}) - if !ok { - continue - } - methods, ok := resMap["methods"].(map[string]interface{}) - if !ok { - continue - } - for methodName, methodSpec := range methods { - methodMap, ok := methodSpec.(map[string]interface{}) - if !ok { - continue - } - if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { - supported := false - for _, t := range tokens { - if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { - supported = true - break - } - } - if !supported { - continue - } - } - rawScopes, ok := methodMap["scopes"].([]interface{}) - if !ok || len(rawScopes) == 0 { - continue - } - - // Check for requiredScopes (conjunction — all needed) - var effectiveScopes []string - if reqRaw, ok := methodMap["requiredScopes"].([]interface{}); ok && len(reqRaw) > 0 { - for _, s := range reqRaw { - if str, ok := s.(string); ok { - effectiveScopes = append(effectiveScopes, str) - } - } - } else { - // Pick the single best scope (minimum privilege) - bestScope := "" - bestScore := -1 - for _, s := range rawScopes { - str, ok := s.(string) - if !ok { - continue - } - score := DefaultScopeScore - if v, exists := priorities[str]; exists { - score = v - } - if score > bestScore { - bestScore = score - bestScope = str - } - } - if bestScope != "" { - effectiveScopes = []string{bestScope} - } - } - if len(effectiveScopes) == 0 { - continue - } - httpMethod := GetStrFromMap(methodMap, "httpMethod") - entries = append(entries, CommandEntry{ - Command: resName + " " + methodName, - Type: "api", - Scopes: effectiveScopes, - HTTPMethod: httpMethod, - }) - } - } + entries = append(entries, CommandEntry{ + Command: ref.ResourceName() + " " + ref.MethodName(), + Type: "api", + Scopes: effectiveScopes, + HTTPMethod: m.HTTPMethod, + }) } sort.Slice(entries, func(i, j int) bool { diff --git a/internal/schema/assembler.go b/internal/schema/assembler.go index 59f014805..b6f579c5c 100644 --- a/internal/schema/assembler.go +++ b/internal/schema/assembler.go @@ -4,474 +4,48 @@ package schema import ( - "bytes" - "encoding/json" "sort" "strconv" - "sync" - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" ) -// MethodKeyOrder records the natural meta_data.json key order for one method's -// parameters / requestBody / responseBody. Nested object key orders are stored -// under NestedKeys, keyed by dotted path from the method root -// (e.g. "responseBody.items.properties"). -type MethodKeyOrder struct { - Parameters []string - RequestBody []string - ResponseBody []string - NestedKeys map[string][]string -} - -var ( - keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order - keyOrderInitOnce sync.Once -) - -// lookupKeyOrder returns the key-order record for service.resourcePath.method, -// or nil if the method is not in the embedded data (e.g. remote-cached). -func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder { - keyOrderInitOnce.Do(buildKeyOrderIndex) - if keyOrderIndex == nil { - return nil - } - dotted := dottedPath(service, resourcePath, method) - return keyOrderIndex[dotted] -} - -func dottedPath(service string, resourcePath []string, method string) string { - var buf bytes.Buffer - buf.WriteString(service) - for _, r := range resourcePath { - buf.WriteByte('.') - buf.WriteString(r) - } - buf.WriteByte('.') - buf.WriteString(method) - return buf.String() -} - -// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init, -// walking services -> resources -> methods -> {parameters,requestBody,responseBody} -// and recording each map's key insertion order via json.Decoder.Token(). -func buildKeyOrderIndex() { - raw := registry.EmbeddedMetaJSON() - if len(raw) == 0 { - return - } - keyOrderIndex = make(map[string]*MethodKeyOrder) - - dec := json.NewDecoder(bytes.NewReader(raw)) - // Top-level: { "services": [...], "version": "..." } - if !expectDelim(dec, '{') { - return - } - for dec.More() { - key, _ := readKey(dec) - if key != "services" { - skipValue(dec) - continue - } - if !expectDelim(dec, '[') { - return - } - for dec.More() { - parseService(dec) - } - // closing ] - _, _ = dec.Token() - } -} - -// parseService consumes one service object inside services[]. -// meta_data.json may emit "resources" before "name", so we first capture both -// raw fields, then walk resources with the resolved service name. -func parseService(dec *json.Decoder) { - if !expectDelim(dec, '{') { - return - } - var serviceName string - var resourcesRaw json.RawMessage - for dec.More() { - key, _ := readKey(dec) - switch key { - case "name": - tok, _ := dec.Token() - if s, ok := tok.(string); ok { - serviceName = s - } - case "resources": - if err := dec.Decode(&resourcesRaw); err != nil { - skipValue(dec) - } - default: - skipValue(dec) - } - } - _, _ = dec.Token() // closing } - if serviceName != "" && len(resourcesRaw) > 0 { - subDec := json.NewDecoder(bytes.NewReader(resourcesRaw)) - parseResources(subDec, serviceName, nil) - } -} - -// parseResources walks a resources map (resName -> resource object). -// resourcePath is the accumulated path of parent resources (for nested resources). -func parseResources(dec *json.Decoder, service string, resourcePath []string) { - if !expectDelim(dec, '{') { - return - } - for dec.More() { - resName, _ := readKey(dec) - parseResourceObj(dec, service, append(resourcePath, resName)) - } - _, _ = dec.Token() -} - -// parseResourceObj consumes one resource value: { methods: {...}, ... } and may -// recurse into nested resources via "resources" key if present. -func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) { - if !expectDelim(dec, '{') { - return - } - for dec.More() { - key, _ := readKey(dec) - switch key { - case "methods": - parseMethods(dec, service, resourcePath) - case "resources": - parseResources(dec, service, resourcePath) - default: - skipValue(dec) - } - } - _, _ = dec.Token() -} - -// parseMethods consumes the methods map (methodName -> method object). -func parseMethods(dec *json.Decoder, service string, resourcePath []string) { - if !expectDelim(dec, '{') { - return - } - for dec.More() { - methodName, _ := readKey(dec) - mko := parseMethod(dec) - dotted := dottedPath(service, resourcePath, methodName) - keyOrderIndex[dotted] = mko - } - _, _ = dec.Token() -} - -// parseMethod consumes one method object and records key orders. -func parseMethod(dec *json.Decoder) *MethodKeyOrder { - mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)} - if !expectDelim(dec, '{') { - return mko - } - for dec.More() { - key, _ := readKey(dec) - switch key { - case "parameters": - mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys) - case "requestBody": - mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys) - case "responseBody": - mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys) - default: - skipValue(dec) - } - } - _, _ = dec.Token() - return mko -} - -// recordObjectKeysRecursive consumes an object and records the top-level key -// order. It also recurses into each child's "properties" submap, recording -// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys -// in order. -func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string { - if !expectDelim(dec, '{') { - return nil - } - var order []string - for dec.More() { - key, _ := readKey(dec) - order = append(order, key) - // Each child value is itself an object; we want its nested "properties" order if present. - consumeFieldRecursive(dec, prefix+"."+key, nestedKeys) - } - _, _ = dec.Token() - if prefix != "" && len(order) > 0 { - nestedKeys[prefix] = order - } - return order -} - -// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and, -// if it contains "properties": {...}, recursively records that submap's order. -func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) { - tok, err := dec.Token() - if err != nil { - return - } - delim, ok := tok.(json.Delim) - if !ok || delim != '{' { - // Not an object — skip the rest of the value - skipValueAfterToken(dec, tok) - return - } - for dec.More() { - fieldKey, _ := readKey(dec) - if fieldKey == "properties" { - recordObjectKeysRecursive(dec, path+".properties", nestedKeys) - } else { - skipValue(dec) - } - } - _, _ = dec.Token() -} - -// --- json.Decoder helpers --- - -func expectDelim(dec *json.Decoder, want json.Delim) bool { - tok, err := dec.Token() - if err != nil { - return false - } - delim, ok := tok.(json.Delim) - return ok && delim == want -} - -func readKey(dec *json.Decoder) (string, error) { - tok, err := dec.Token() - if err != nil { - return "", err - } - s, _ := tok.(string) - return s, nil -} - -// skipValue consumes the next complete value (scalar, object, or array). -func skipValue(dec *json.Decoder) { - tok, err := dec.Token() - if err != nil { - return - } - skipValueAfterToken(dec, tok) -} - -func skipValueAfterToken(dec *json.Decoder, tok json.Token) { - delim, ok := tok.(json.Delim) - if !ok { - return - } - // We started inside a container of type `delim` ({ or [) and must eat - // tokens until that container closes, tracking nested containers of any - // kind. depth counts how many open containers we are currently inside. - _ = delim - depth := 1 - for depth > 0 { - t, err := dec.Token() - if err != nil { - return - } - if d, ok := t.(json.Delim); ok { - switch d { - case '{', '[': - depth++ - case '}', ']': - depth-- - } - } - } -} - -// coerceLiteral converts a meta_data literal (default / enum / example) to -// the JSON Schema type declared by the field (integer/number/boolean/string). -// meta_data stores every literal as a string, so without coercion an -// `integer` field would emit string literals and fail any standard validator. -// Already-typed values pass through unchanged. Returns (value, true) on -// success, or (nil, false) when the literal cannot be coerced (caller should -// drop it). -func coerceLiteral(fieldType string, raw interface{}) (interface{}, bool) { - s, isStr := raw.(string) - if !isStr { - // Already typed (e.g. meta_data emitted a JSON number/bool directly). - return raw, true - } - switch fieldType { - case "integer": - if v, err := strconv.ParseInt(s, 10, 64); err == nil { - return v, true - } - return nil, false - case "number": - if v, err := strconv.ParseFloat(s, 64); err == nil { - return v, true - } - return nil, false - case "boolean": - switch s { - case "true": - return true, true - case "false": - return false, true - } - return nil, false - default: // "string", "" (nested objects), or unknown - return s, true - } -} - -// sortEnum sorts an enum slice in-place using a comparator appropriate for -// the declared JSON Schema type, so integer enums end up [1, 2, 10] rather -// than the lexicographic [1, 10, 2]. -func sortEnum(fieldType string, vals []interface{}) { - sort.SliceStable(vals, func(i, j int) bool { - switch fieldType { - case "integer": - ai, _ := vals[i].(int64) - bi, _ := vals[j].(int64) - return ai < bi - case "number": - af, _ := vals[i].(float64) - bf, _ := vals[j].(float64) - return af < bf - case "boolean": - ab, _ := vals[i].(bool) - bb, _ := vals[j].(bool) - return !ab && bb // false < true - default: - as, _ := vals[i].(string) - bs, _ := vals[j].(string) - return as < bs - } - }) -} - -// convertProperty recursively converts one meta_data field map into a Property. -// nestedPath is the dotted lookup key into the current method's NestedKeys map -// (e.g. "responseBody.items.properties"). Empty path = top-level, no nested -// lookup needed. -func convertProperty(field map[string]interface{}, nestedPath string) Property { +// Convert renders a meta.Field as a JSON-Schema Property. meta owns the value +// normalization (canonical type, literal coercion, enum ordering); this adds +// only the JSON-Schema-specific shape: the "file" binary format, numeric +// bounds, nested object/array properties, and the array-items fallback. +func Convert(f meta.Field) Property { var p Property - rawType, _ := field["type"].(string) - switch rawType { - case "file": - p.Type = "string" + p.Type = f.CanonicalType() + if f.Type == "file" { p.Format = "binary" - case "list": - // meta_data uses non-standard "list" on a couple of fields; - // translate to JSON Schema "array" so validators accept it. - p.Type = "array" - default: - p.Type = rawType } + p.Description = f.Description + p.Default = f.CoercedDefault() + p.Example = f.CoercedExample() + p.Minimum = parseBound(f.Min) + p.Maximum = parseBound(f.Max) + p.Enum, p.EnumDescriptions = enumSchema(f.EnumOptions()) - if s, ok := field["description"].(string); ok { - p.Description = s - } - if v, ok := field["default"]; ok { - // Coerce default literal to match the declared JSON Schema type so - // validators do not reject e.g. {type:"integer", default:"500"}. - // When coercion fails (e.g. default:"" on an integer field, which - // meta_data uses to mean "no default"), omit the field entirely - // instead of emitting a type-mismatched default — the result is a - // missing `default` key rather than a contract violation. - if coerced, ok := coerceLiteral(p.Type, v); ok { - p.Default = coerced - } - } - if v, ok := field["example"]; ok { - // meta_data stores examples as strings even when the field is integer/ - // boolean/number; coerce to the declared type so downstream validators - // accept the envelope. Drop on coerce failure (same policy as default). - if coerced, ok := coerceLiteral(p.Type, v); ok { - p.Example = coerced - } - } - - // min / max are stored as strings in meta_data; parse on best-effort. - if minStr, ok := field["min"].(string); ok && minStr != "" { - if v, err := strconv.ParseFloat(minStr, 64); err == nil { - p.Minimum = &v - } - } - if maxStr, ok := field["max"].(string); ok && maxStr != "" { - if v, err := strconv.ParseFloat(maxStr, 64); err == nil { - p.Maximum = &v - } - } - - // enum: prefer existing "enum" array; else extract from options[].value. - // Values are typed per p.Type so integer fields get integer enums, etc. - // (JSON Schema 2020-12 requires enum value types to match the declared - // type — meta_data stores everything as strings.) - if enumRaw, ok := field["enum"].([]interface{}); ok && len(enumRaw) > 0 { - for _, e := range enumRaw { - if v, ok := coerceLiteral(p.Type, e); ok { - p.Enum = append(p.Enum, v) - } - } - // Numeric/boolean enums get sorted (no inherent meaning in meta_data - // order); string enums keep meta_data order, which sometimes carries - // semantic priority (e.g. image_type ["message","avatar"]). - if p.Type != "string" && p.Type != "" { - sortEnum(p.Type, p.Enum) - } - } else if optsRaw, ok := field["options"].([]interface{}); ok && len(optsRaw) > 0 { - seen := make(map[string]bool) - for _, o := range optsRaw { - om, ok := o.(map[string]interface{}) - if !ok { - continue - } - raw, ok := om["value"].(string) - if !ok || seen[raw] { - continue - } - seen[raw] = true - if v, ok := coerceLiteral(p.Type, raw); ok { - p.Enum = append(p.Enum, v) - } - } - // Same policy as the `enum` branch: numeric/boolean enums get sorted - // (no semantic meaning in source order); string enums keep meta_data - // order, which may carry semantic priority. - if p.Type != "string" && p.Type != "" { - sortEnum(p.Type, p.Enum) - } - } - - // nested properties: recurse - if propsRaw, ok := field["properties"].(map[string]interface{}); ok && len(propsRaw) > 0 { - nested, nestedRequired := buildOrderedProps(propsRaw, nestedPath) + if children := f.Children(); len(children) > 0 { + props, required := propsOf(children), requiredOf(children) if p.Type == "array" { // meta_data quirk: array element schema is wrapped in "properties". - // Unfold into Items: { type: "object", properties: } - p.Items = &Property{ - Type: "object", - Properties: nested, - Required: nestedRequired, - } - // Property.Properties stays nil for arrays + p.Items = &Property{Type: "object", Properties: props, Required: required} } else { if p.Type == "" { p.Type = "object" // infer } - p.Properties = nested - p.Required = nestedRequired + p.Properties = props + p.Required = required } } - // array items fallback: emit `items: {}` (any schema) for every array that - // meta_data does not describe an element shape for — whether it arrived as - // "list" or natively as "array". Without this, typeless arrays (e.g. arrays - // of bare ID strings) violate the L1 lint rule and are not JSON Schema valid - // for consumers that require `items`. + // Every array needs an items schema to be valid for consumers that require + // one, even when meta_data describes no element shape. if p.Type == "array" && p.Items == nil { p.Items = &Property{} } @@ -479,396 +53,174 @@ func convertProperty(field map[string]interface{}, nestedPath string) Property { return p } -// buildOrderedProps converts a map[string]interface{} of field specs into an -// OrderedProps plus the alphabetized list of child keys marked `required:true` -// in meta_data. Callers attach that list to the enclosing object's `required`, -// so nested objects faithfully report their call contract (top-level required -// is handled separately by buildInputSchema). -func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedProps, []string) { - op := &OrderedProps{Map: make(map[string]Property, len(raw))} - - var required []string - keys := orderedKeys(raw, nestedPath) - for _, k := range keys { - fieldRaw, _ := raw[k].(map[string]interface{}) - op.Order = append(op.Order, k) - op.Map[k] = convertProperty(fieldRaw, nestedPath+"."+k+".properties") - if req, _ := fieldRaw["required"].(bool); req { - required = append(required, k) +// enumSchema splits coerced enum options into the parallel enum / enumDescriptions +// arrays for the envelope. enumDescriptions is nil unless at least one value +// carries a description (so the bare-enum form stays values-only), keeping the +// two arrays index-aligned for AI consumers. +func enumSchema(opts []meta.EnumOption) (values []interface{}, descriptions []string) { + if len(opts) == 0 { + return nil, nil + } + values = make([]interface{}, len(opts)) + descs := make([]string, len(opts)) + hasDesc := false + for i, o := range opts { + values[i] = o.Value + descs[i] = o.Description + if o.Description != "" { + hasDesc = true } } - sort.Strings(required) - return op, required + if hasDesc { + descriptions = descs + } + return values, descriptions } -// currentMethodOrder is the per-method key-order context used by orderedKeys. -// It is set inside AssembleEnvelope (under assembleMu) and reset on return. -var currentMethodOrder *MethodKeyOrder - -// parseAffordance lifts the affordance overlay from a method's raw meta_data.json -// entry into a typed *Affordance. Returns nil when the field is absent, malformed, -// or carries no populated subfields. -// -// Affordance is authored in larksuite-cli-registry's registry-config.yaml under -// overrides...affordance and flows through gen-registry.py's -// deep_merge into the embedded meta_data.json. -func parseAffordance(raw interface{}) *Affordance { - if raw == nil { +// parseBound parses a meta_data numeric bound (min/max, stored as a string) into +// a float pointer, or nil when absent or unparseable. +func parseBound(s string) *float64 { + if s == "" { return nil } - b, err := json.Marshal(raw) - if err != nil { - return nil - } - var a Affordance - if err := json.Unmarshal(b, &a); err != nil { - return nil + if v, err := strconv.ParseFloat(s, 64); err == nil { + return &v } - if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 { - return nil - } - return &a + return nil } -// convertAccessTokens translates from_meta accessTokens (uses "tenant") into -// CLI --as form (uses "bot"). The result is deduped and sorted alphabetically. -// Unknown tokens are dropped. Returns an empty slice for nil/empty input. -func convertAccessTokens(raw []interface{}) []string { - seen := make(map[string]bool) - for _, t := range raw { - s, ok := t.(string) - if !ok { - continue - } - switch s { - case "tenant": - seen["bot"] = true - case "user": - seen["user"] = true - } - } - out := make([]string, 0, len(seen)) - for k := range seen { - out = append(out, k) +// propsOf renders fields as an ordered JSON-Schema property map. meta's field +// accessors return fields sorted by name, so the property order is alphabetical. +func propsOf(fields []meta.Field) *OrderedProps { + op := &OrderedProps{} + for _, f := range fields { + op.Set(f.Name, Convert(f)) } - sort.Strings(out) - return out + return op } -// buildMeta produces the _meta extension namespace. -func buildMeta(method map[string]interface{}) *Meta { - m := &Meta{ - EnvelopeVersion: "1.0", - RequiredScopes: []string{}, // never nil for stable JSON - } - - if scopesRaw, ok := method["scopes"].([]interface{}); ok { - for _, s := range scopesRaw { - if str, ok := s.(string); ok { - m.Scopes = append(m.Scopes, str) - } - } - } - if rsRaw, ok := method["requiredScopes"].([]interface{}); ok { - for _, s := range rsRaw { - if str, ok := s.(string); ok { - m.RequiredScopes = append(m.RequiredScopes, str) - } +// requiredOf returns the alphabetized names of the required fields. +func requiredOf(fields []meta.Field) []string { + var required []string + for _, f := range fields { + if f.Required { + required = append(required, f.Name) } } - - atRaw, _ := method["accessTokens"].([]interface{}) - m.AccessTokens = convertAccessTokens(atRaw) - - m.Danger, _ = method["danger"].(bool) - - if risk, _ := method["risk"].(string); risk != "" { - m.Risk = risk - } else { - m.Risk = cmdutil.RiskRead - } - - if docURL, _ := method["docUrl"].(string); docURL != "" { - m.DocURL = docURL - } - - m.Affordance = parseAffordance(method["affordance"]) - return m + sort.Strings(required) + return required } -// buildInputSchema produces the inputSchema for one API method. -// -// Top-level shape: -// -// { type: object, -// required: [<"params" if any param required>, <"data" if any body required>], -// properties: { -// params: { type: object, required: [...], properties: { ...path/query fields } }, // only if method has parameters -// data: { type: object, required: [...], properties: { ...body fields } }, // only if method has requestBody -// yes: { type: boolean, default: false, ... } // only when risk == "high-risk-write" -// } } -// -// The params / data wrapping mirrors the CLI's actual flag layout: -// path+query → --params JSON, body → --data JSON, file → --file. AI consumers -// can pluck inputSchema.properties.params and pass it verbatim to --params. -// -// Caller must set currentMethodOrder for property-order preservation. -func buildInputSchema(method map[string]interface{}) *InputSchema { +// buildInputSchema produces the inputSchema sections — params (path+query → +// --params), data (non-file body → --data), file (file body → --file) — plus a +// `yes` confirmation gate for high-risk-write methods. +func buildInputSchema(m meta.Method) *InputSchema { is := &InputSchema{ Type: "object", Required: []string{}, // never nil — stable envelope shape - Properties: &OrderedProps{Map: make(map[string]Property)}, + Properties: &OrderedProps{}, } - // Build the "params" sub-object from method.parameters (path + query). - paramsRaw, _ := method["parameters"].(map[string]interface{}) - paramsProps := &OrderedProps{Map: make(map[string]Property)} - var paramsRequired []string - for _, k := range orderedKeys(paramsRaw, "parameters") { - field, _ := paramsRaw[k].(map[string]interface{}) - prop := convertProperty(field, "parameters."+k+".properties") - paramsProps.Order = append(paramsProps.Order, k) - paramsProps.Map[k] = prop - if req, _ := field["required"].(bool); req { - paramsRequired = append(paramsRequired, k) - } - } - if len(paramsProps.Order) > 0 { - sort.Strings(paramsRequired) - is.Properties.Order = append(is.Properties.Order, "params") - is.Properties.Map["params"] = Property{ - Type: "object", - Required: paramsRequired, - Properties: paramsProps, - } - if len(paramsRequired) > 0 { - is.Required = append(is.Required, "params") - } - } + addInputObject(is, "params", "", m.Params()) + addInputObject(is, "data", "", m.Data()) + addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", m.Files()) - // Split method.requestBody into two buckets: - // - data: non-file body fields → corresponds to CLI --data JSON - // - file: type:file body fields → corresponds to CLI --file = - // File fields are kept *out* of `data` so the schema mirrors the actual - // CLI flag dispatch: --file owns one wire format (multipart upload), - // --data owns the rest (JSON body). - bodyRaw, _ := method["requestBody"].(map[string]interface{}) - dataProps := &OrderedProps{Map: make(map[string]Property)} - fileProps := &OrderedProps{Map: make(map[string]Property)} - var dataRequired []string - var fileRequired []string - for _, k := range orderedKeys(bodyRaw, "requestBody") { - field, _ := bodyRaw[k].(map[string]interface{}) - prop := convertProperty(field, "requestBody."+k+".properties") - isFile := false - if t, _ := field["type"].(string); t == "file" { - isFile = true - } - if isFile { - fileProps.Order = append(fileProps.Order, k) - fileProps.Map[k] = prop - if req, _ := field["required"].(bool); req { - fileRequired = append(fileRequired, k) - } - } else { - dataProps.Order = append(dataProps.Order, k) - dataProps.Map[k] = prop - if req, _ := field["required"].(bool); req { - dataRequired = append(dataRequired, k) - } - } - } - if len(dataProps.Order) > 0 { - sort.Strings(dataRequired) - is.Properties.Order = append(is.Properties.Order, "data") - is.Properties.Map["data"] = Property{ - Type: "object", - Required: dataRequired, - Properties: dataProps, - } - if len(dataRequired) > 0 { - is.Required = append(is.Required, "data") - } - } - if len(fileProps.Order) > 0 { - sort.Strings(fileRequired) - is.Properties.Order = append(is.Properties.Order, "file") - is.Properties.Map["file"] = Property{ - Type: "object", - Description: "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", - Required: fileRequired, - Properties: fileProps, - } - if len(fileRequired) > 0 { - is.Required = append(is.Required, "file") - } - } - - // high-risk-write injects a top-level `yes` confirmation flag — sibling - // of params/data. It is a CLI gate (consumed by lark-cli, not sent to - // the backend), not an API field. - if risk, _ := method["risk"].(string); risk == cmdutil.RiskHighRiskWrite { - is.Properties.Order = append(is.Properties.Order, "yes") + if m.Risk == core.RiskHighRiskWrite { falseVal := false - is.Properties.Map["yes"] = Property{ + is.Properties.Set("yes", Property{ Type: "boolean", Default: falseVal, Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.", - } - // yes is intentionally NOT added to top-level Required; the gate is - // enforced semantically (yes==true) by the CLI, not structurally. + }) } - sort.Strings(is.Required) // alphabetical + sort.Strings(is.Required) return is } -// buildOutputSchema produces the outputSchema for one API method. -func buildOutputSchema(method map[string]interface{}) *OutputSchema { - os := &OutputSchema{ - Type: "object", - Properties: &OrderedProps{Map: make(map[string]Property)}, +// addInputObject adds one named sub-object section (params/data/file) to the +// input schema when it has fields: its Properties come from the fields, its +// Required lists the mandatory keys, and the section itself is required at top +// level when any field is required. Empty sections are skipped. +func addInputObject(is *InputSchema, name, description string, fields []meta.Field) { + if len(fields) == 0 { + return } - respRaw, _ := method["responseBody"].(map[string]interface{}) - for _, k := range orderedKeys(respRaw, "responseBody") { - field, _ := respRaw[k].(map[string]interface{}) - os.Properties.Order = append(os.Properties.Order, k) - os.Properties.Map[k] = convertProperty(field, "responseBody."+k+".properties") + req := requiredOf(fields) + is.Properties.Set(name, Property{ + Type: "object", + Description: description, + Required: req, + Properties: propsOf(fields), + }) + if len(req) > 0 { + is.Required = append(is.Required, name) } - return os } -// assembleMu serializes AssembleEnvelope calls so that the package-level -// currentMethodOrder pointer is safe for concurrent callers. -var assembleMu sync.Mutex - -// AssembleEnvelope is the main entry point: takes a service / resource path / -// method name plus its meta_data spec, and produces a fully assembled MCP -// envelope. Output is fully determined by inputs (same arguments → same -// envelope), but assembly briefly publishes the per-method key-order context -// through the package-level currentMethodOrder so orderedKeys can reach it -// without threading it through every helper. assembleMu serializes that -// publish, which is why concurrent callers are still safe — they queue -// rather than run in parallel. -// -// If parallelism becomes a bottleneck, replace currentMethodOrder with an -// assembler struct or pass *MethodKeyOrder explicitly down the call chain. -func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope { - assembleMu.Lock() - defer assembleMu.Unlock() - currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName) - defer func() { currentMethodOrder = nil }() +// buildOutputSchema produces the outputSchema from the response-body fields. +func buildOutputSchema(m meta.Method) *OutputSchema { + return &OutputSchema{Type: "object", Properties: propsOf(m.Response())} +} - name := serviceName - for _, r := range resourcePath { - name += " " + r +// buildMeta produces the _meta extension namespace. +func buildMeta(m meta.Method) *Meta { + out := &Meta{ + EnvelopeVersion: "1.0", + RequiredScopes: []string{}, // never nil for stable JSON + Scopes: m.Scopes, + AccessTokens: m.Identities(), + Danger: m.Danger, } - name += " " + methodName - - desc, _ := method["description"].(string) - - return Envelope{ - Name: name, - Description: desc, - InputSchema: buildInputSchema(method), - OutputSchema: buildOutputSchema(method), - Meta: buildMeta(method), + if a, ok := m.ParsedAffordance(); ok { + out.Affordance = &a } -} - -// MethodFilter is an optional predicate used by AssembleService and -// AssembleAll to filter methods (e.g. by access token for strict mode). -// Pass nil to include all methods. -type MethodFilter func(method map[string]interface{}) bool - -// AssembleService assembles all methods under one service into a sorted -// envelope slice (sorted by Envelope.Name ascending). -func AssembleService(serviceName string, spec map[string]interface{}, filter MethodFilter) []Envelope { - if spec == nil { - return nil + if len(m.RequiredScopes) > 0 { + out.RequiredScopes = m.RequiredScopes + } + if m.Risk != "" { + out.Risk = m.Risk + } else { + out.Risk = core.RiskRead + } + if m.DocURL != "" { + out.DocURL = m.DocURL } - resources, _ := spec["resources"].(map[string]interface{}) - var out []Envelope - walkMethods(resources, nil, func(resourcePath []string, methodName string, method map[string]interface{}) { - if filter != nil && !filter(method) { - return - } - out = append(out, AssembleEnvelope(serviceName, resourcePath, methodName, method)) - }) - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } -// AssembleAll assembles every embedded service into one big sorted slice. -// Uses embedded data only (bypasses remote overlay) so envelope output is -// deterministic across machines (CI vs dev vs different user brands). -func AssembleAll(filter MethodFilter) []Envelope { +// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry +// callers use, since apicatalog.MethodRef is the metadata navigation currency. +func EnvelopeOf(ref apicatalog.MethodRef) Envelope { + return assemble(ref.Service.Name, ref.ResourcePath, ref.Method) +} + +// Envelopes renders the given method refs into envelopes, sorted by name. The +// caller supplies the refs (from apicatalog navigation), so this package owns +// only rendering — never metadata source selection or traversal. +func Envelopes(refs []apicatalog.MethodRef) []Envelope { var out []Envelope - for _, svc := range registry.EmbeddedServiceNames() { - spec := registry.EmbeddedSpec(svc) - out = append(out, AssembleService(svc, spec, filter)...) + for _, ref := range refs { + out = append(out, EnvelopeOf(ref)) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } -// walkMethods recursively walks resources -> methods, calling visit for each -// terminal method. It supports nested resources via the optional "resources" -// key inside a resource value (matches meta_data.json structure). -func walkMethods(resources map[string]interface{}, parentPath []string, - visit func(resourcePath []string, methodName string, method map[string]interface{})) { - for resName, resRaw := range resources { - resMap, ok := resRaw.(map[string]interface{}) - if !ok { - continue - } - curPath := append(append([]string(nil), parentPath...), resName) - if methods, ok := resMap["methods"].(map[string]interface{}); ok { - for mName, mRaw := range methods { - if m, ok := mRaw.(map[string]interface{}); ok { - visit(curPath, mName, m) - } - } - } - if nested, ok := resMap["resources"].(map[string]interface{}); ok { - walkMethods(nested, curPath, visit) - } +// assemble builds the envelope from a method's navigation context. The method +// name comes from m.Name, injected by the typed accessors. +func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope { + name := serviceName + for _, r := range resourcePath { + name += " " + r } -} + name += " " + m.Name -// orderedKeys returns the keys of raw in their meta_data natural order if -// the current per-method key-order context has them recorded; otherwise -// alphabetical fallback. -func orderedKeys(raw map[string]interface{}, nestedPath string) []string { - if currentMethodOrder != nil && nestedPath != "" { - if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok { - // Filter to keys that actually exist in raw (defensive) - out := make([]string, 0, len(order)) - seen := make(map[string]bool) - for _, k := range order { - if _, ok := raw[k]; ok { - out = append(out, k) - seen[k] = true - } - } - // Append any keys present in raw but missing from order (defensive), - // alphabetically for determinism. - var extra []string - for k := range raw { - if !seen[k] { - extra = append(extra, k) - } - } - sort.Strings(extra) - out = append(out, extra...) - return out - } - } - // Fallback: alphabetical - keys := make([]string, 0, len(raw)) - for k := range raw { - keys = append(keys, k) + return Envelope{ + Name: name, + Description: m.Description, + InputSchema: buildInputSchema(m), + OutputSchema: buildOutputSchema(m), + Meta: buildMeta(m), } - sort.Strings(keys) - return keys } diff --git a/internal/schema/assembler_test.go b/internal/schema/assembler_test.go index 6fa0e8bf2..c7bd2a0c4 100644 --- a/internal/schema/assembler_test.go +++ b/internal/schema/assembler_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/registry" ) @@ -35,58 +37,6 @@ func TestMain(m *testing.M) { os.Exit(code) } -func TestKeyOrderIndex_ImReactionsList(t *testing.T) { - // We only assert key-set membership, not absolute order — the upstream - // meta_data API does not guarantee a stable JSON key sequence across - // fetches, so hard-coding the order makes CI flaky. Order preservation - // from input to output is tested separately in TestBuildInputSchema_*. - order := lookupKeyOrder("im", []string{"reactions"}, "list") - if order == nil { - t.Fatal("expected key order for im.reactions.list, got nil") - } - wantParams := map[string]bool{ - "message_id": true, "reaction_type": true, "page_token": true, - "page_size": true, "user_id_type": true, - } - if got, want := len(order.Parameters), len(wantParams); got != want { - t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters) - } - for _, k := range order.Parameters { - if !wantParams[k] { - t.Errorf("unexpected parameter key %q", k) - } - } - // im.reactions.list 是 GET,没有 requestBody - if len(order.RequestBody) != 0 { - t.Errorf("expected empty RequestBody, got %v", order.RequestBody) - } -} - -func TestKeyOrderIndex_ImImagesCreate(t *testing.T) { - // Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList. - order := lookupKeyOrder("im", []string{"images"}, "create") - if order == nil { - t.Fatal("expected key order for im.images.create, got nil") - } - wantBody := map[string]bool{"image_type": true, "image": true} - if got, want := len(order.RequestBody), len(wantBody); got != want { - t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody) - } - for _, k := range order.RequestBody { - if !wantBody[k] { - t.Errorf("unexpected requestBody key %q", k) - } - } -} - -func TestKeyOrderIndex_UnknownPath(t *testing.T) { - // 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底 - order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar") - if order != nil { - t.Errorf("expected nil for unknown path, got %+v", order) - } -} - func TestConvertProperty_BasicTypes(t *testing.T) { tests := []struct { name string @@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) { func TestBuildInputSchema_ReactionsList(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - mko := lookupKeyOrder("im", []string{"reactions"}, "list") - currentMethodOrder = mko - defer func() { currentMethodOrder = nil }() is := buildInputSchema(method) @@ -313,16 +260,13 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) { if !reflect.DeepEqual(params.Required, []string{"message_id"}) { t.Errorf("params.Required = %v, want [message_id]", params.Required) } - if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) { - t.Errorf("params.properties order = %v, want (from key index) %v", - params.Properties.Order, mko.Parameters) + if want := []string{"message_id", "page_size", "page_token", "reaction_type", "user_id_type"}; !reflect.DeepEqual(params.Properties.Order, want) { + t.Errorf("params.properties order = %v, want %v (alphabetical)", params.Properties.Order, want) } } func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"images"}, "create") - currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create") - defer func() { currentMethodOrder = nil }() is := buildInputSchema(method) @@ -382,10 +326,8 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) { }, }, } - currentMethodOrder = nil - defer func() { currentMethodOrder = nil }() - is := buildInputSchema(method) + is := buildInputSchema(meta.FromMap(method)) // yes lives at inputSchema.properties.yes (sibling of params/data) yes, ok := is.Properties.Map["yes"] @@ -413,9 +355,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) { func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - mko := lookupKeyOrder("im", []string{"reactions"}, "list") - currentMethodOrder = mko - defer func() { currentMethodOrder = nil }() is := buildInputSchema(method) if _, ok := is.Properties.Map["yes"]; ok { @@ -425,9 +364,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) { func TestBuildOutputSchema_ReactionsList(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - mko := lookupKeyOrder("im", []string{"reactions"}, "list") - currentMethodOrder = mko - defer func() { currentMethodOrder = nil }() os := buildOutputSchema(method) @@ -450,31 +386,6 @@ func TestBuildOutputSchema_ReactionsList(t *testing.T) { } } -func TestConvertAccessTokens(t *testing.T) { - tests := []struct { - name string - input []interface{} - want []string - }{ - {"tenant only", []interface{}{"tenant"}, []string{"bot"}}, - {"user only", []interface{}{"user"}, []string{"user"}}, - {"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}}, - {"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}}, - {"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}}, - {"empty", []interface{}{}, []string{}}, - {"nil", nil, []string{}}, - {"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := convertAccessTokens(tt.input) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - func TestBuildMeta_FullFields(t *testing.T) { // Synthesized method to avoid runtime variance from remote-cache overlay // (which strips `risk` from merged services). All other field semantics @@ -489,7 +400,7 @@ func TestBuildMeta_FullFields(t *testing.T) { "accessTokens": []interface{}{"tenant"}, "docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create", } - m := buildMeta(method) + m := buildMeta(meta.FromMap(method)) if m.EnvelopeVersion != "1.0" { t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion) @@ -526,7 +437,7 @@ func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) { "accessTokens": []interface{}{"user"}, // no risk field } - m := buildMeta(method) + m := buildMeta(meta.FromMap(method)) if m.Risk != "read" { t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk) } @@ -540,58 +451,26 @@ func TestBuildMeta_RequiredScopesPresent(t *testing.T) { } } -func TestParseAffordance_NilOrEmpty(t *testing.T) { - cases := []struct { - name string - raw interface{} - }{ - {"nil", nil}, - {"empty object", map[string]interface{}{}}, - {"all-five-empty-arrays", map[string]interface{}{ - "use_when": []interface{}{}, - "do_not_use_when": []interface{}{}, - "prerequisites": []interface{}{}, - "examples": []interface{}{}, - "related": []interface{}{}, - }}, - {"malformed (string)", "not an object"}, - {"malformed (number)", 42}, - {"malformed (nested type mismatch)", map[string]interface{}{ - "examples": "should be a list, not a string", - }}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := parseAffordance(c.raw); got != nil { - t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got) - } - }) +func TestConvert_EnumDescriptions(t *testing.T) { + // options carrying descriptions -> enum + parallel enumDescriptions + withDesc := Convert(meta.Field{Type: "string", Options: []meta.Option{ + {Value: "open_id", Description: "A"}, + {Value: "user_id", Description: "B"}, + }}) + if !reflect.DeepEqual(withDesc.Enum, []interface{}{"open_id", "user_id"}) { + t.Errorf("Enum = %v", withDesc.Enum) } -} - -func TestParseAffordance_FullPopulated(t *testing.T) { - raw := map[string]interface{}{ - "use_when": []interface{}{"需要拿到当前用户的主日历 ID"}, - "do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"}, - "prerequisites": []interface{}{"user 身份登录"}, - "examples": []interface{}{ - map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"}, - }, - "related": []interface{}{"calendars.list"}, + if !reflect.DeepEqual(withDesc.EnumDescriptions, []string{"A", "B"}) { + t.Errorf("EnumDescriptions = %v, want [A B] aligned with enum", withDesc.EnumDescriptions) } - a := parseAffordance(raw) - if a == nil { - t.Fatal("parseAffordance returned nil, want populated") - } - if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" { - t.Errorf("UseWhen = %v", a.UseWhen) - } - if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || - a.Examples[0].Command != "lark-cli calendar calendars primary" { - t.Errorf("Examples = %+v", a.Examples) + + // bare enum form (no descriptions) -> enumDescriptions omitted (nil) + bare := Convert(meta.Field{Type: "string", Enum: []any{"x", "y"}}) + if !reflect.DeepEqual(bare.Enum, []interface{}{"x", "y"}) { + t.Errorf("bare Enum = %v", bare.Enum) } - if len(a.Related) != 1 || a.Related[0] != "calendars.list" { - t.Errorf("Related = %v", a.Related) + if bare.EnumDescriptions != nil { + t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions) } } @@ -604,7 +483,7 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) { "use_when": []interface{}{"trigger"}, }, } - m := buildMeta(method) + m := buildMeta(meta.FromMap(method)) if m.Affordance == nil { t.Fatal("Affordance should be populated from method[\"affordance\"]") } @@ -620,7 +499,7 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) { "risk": "read", // no docUrl } - m := buildMeta(method) + m := buildMeta(meta.FromMap(method)) if m.DocURL != "" { t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL) } @@ -634,8 +513,7 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) { func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) { // 装配器对空 responseBody 应生成 properties = {} (不 nil) method := map[string]interface{}{} - currentMethodOrder = nil - os := buildOutputSchema(method) + os := buildOutputSchema(meta.FromMap(method)) if os.Type != "object" { t.Errorf("Type = %q, want \"object\"", os.Type) } @@ -647,9 +525,16 @@ func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) { } } +// synthEnvelope renders an envelope for a synthetic (service, resourcePath, method) +// via the public ref entry, so these unit tests build the same MethodRef the +// command layer feeds Envelope. +func synthEnvelope(serviceName string, resourcePath []string, m meta.Method) Envelope { + return EnvelopeOf(apicatalog.MethodRef{Service: meta.Service{Name: serviceName}, ResourcePath: resourcePath, Method: m}) +} + func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - env := AssembleEnvelope("im", []string{"reactions"}, "list", method) + env := synthEnvelope("im", []string{"reactions"}, method) if env.Name != "im reactions list" { t.Errorf("Name = %q, want \"im reactions list\"", env.Name) @@ -671,7 +556,7 @@ func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) { // overlay strips `bots` from the loaded method map on this environment; // the assertion is about name joining, not method specifics. method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create") - env := AssembleEnvelope("im", []string{"chat.members"}, "create", method) + env := synthEnvelope("im", []string{"chat.members"}, method) // chat.members resourcePath stays as one element in the slice with a dot; // name should split it to "im chat.members create" — we keep the dot as-is // inside the resource segment to round-trip with completion logic. @@ -683,8 +568,8 @@ func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) { func TestAssembleEnvelope_JSONIsStable(t *testing.T) { // Assemble twice; JSON output must be byte-identical (determinism). method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - a := AssembleEnvelope("im", []string{"reactions"}, "list", method) - b := AssembleEnvelope("im", []string{"reactions"}, "list", method) + a := synthEnvelope("im", []string{"reactions"}, method) + b := synthEnvelope("im", []string{"reactions"}, method) ja, _ := json.MarshalIndent(a, "", " ") jb, _ := json.MarshalIndent(b, "", " ") if string(ja) != string(jb) { @@ -693,8 +578,8 @@ func TestAssembleEnvelope_JSONIsStable(t *testing.T) { } func TestAssembleService_Im(t *testing.T) { - spec := registry.LoadFromMeta("im") - envs := AssembleService("im", spec, nil) + svc, _ := registry.ServiceTyped("im") + envs := Envelopes(apicatalog.ServiceMethods(svc, nil)) if len(envs) == 0 { t.Fatal("expected non-empty envelopes for service im") } @@ -713,17 +598,16 @@ func TestAssembleService_Im(t *testing.T) { } func TestAssembleService_FilterByAccessToken(t *testing.T) { - spec := registry.LoadFromMeta("im") + svc, _ := registry.ServiceTyped("im") // Filter to bot-only (--as bot, which corresponds to "tenant") - envs := AssembleService("im", spec, func(method map[string]interface{}) bool { - tokens, _ := method["accessTokens"].([]interface{}) - for _, t := range tokens { - if s, _ := t.(string); s == "tenant" { + envs := Envelopes(apicatalog.ServiceMethods(svc, func(m meta.Method) bool { + for _, t := range m.AccessTokens { + if t == "tenant" { return true } } return false - }) + })) // Every envelope's _meta.access_tokens must contain "bot" for _, e := range envs { found := false @@ -740,11 +624,11 @@ func TestAssembleService_FilterByAccessToken(t *testing.T) { } func TestAssembleAll_AtLeast193(t *testing.T) { - envs := AssembleAll(nil) - // Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the - // embedded meta_data.json directly, so the count is stable across machines. + envs := Envelopes(registry.EmbeddedCatalog().WalkMethods(nil)) + // Envelope assembly is overlay-independent: it walks the embedded + // meta_data.json directly, so the count is stable across machines. if len(envs) < 193 { - t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs)) + t.Errorf("envelope count = %d, expected >= 193", len(envs)) } // Spot check: im reactions list should be present found := false @@ -759,24 +643,32 @@ func TestAssembleAll_AtLeast193(t *testing.T) { } } -// loadMethodFromRegistry is a test helper that pulls one method's spec from the -// real embedded meta_data.json via the registry package. -func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} { +// loadMethodFromRegistry is a test helper that pulls one method from the real +// embedded meta_data.json via the registry's typed accessor, with Name set. +func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) meta.Method { t.Helper() - spec := registry.LoadFromMeta(service) - if spec == nil { + svc, ok := registry.ServiceTyped(service) + if !ok { t.Fatalf("service %q not found in registry", service) } - resources, _ := spec["resources"].(map[string]interface{}) resKey := strings.Join(resourcePath, ".") - res, ok := resources[resKey].(map[string]interface{}) + res, ok := svc.Resources[resKey] if !ok { t.Fatalf("resource %q.%s not found", service, resKey) } - methods, _ := res["methods"].(map[string]interface{}) - m, ok := methods[methodName].(map[string]interface{}) + m, ok := res.Methods[methodName] if !ok { t.Fatalf("method %q.%s.%s not found", service, resKey, methodName) } + m.Name = methodName return m } + +// convertProperty is a test helper: it decodes a single field-spec map into a +// meta.Field and renders its Property (the conversion the assembler does). +func convertProperty(fieldMap map[string]interface{}, _ string) Property { + b, _ := json.Marshal(fieldMap) + var f meta.Field + _ = json.Unmarshal(b, &f) + return Convert(f) +} diff --git a/internal/schema/lint.go b/internal/schema/lint.go index 2af3baef1..7600527c5 100644 --- a/internal/schema/lint.go +++ b/internal/schema/lint.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" ) var validJSONSchemaTypes = map[string]bool{ @@ -81,7 +81,7 @@ func lintEnvelope(env Envelope) []error { } // ---- L3: cross-field self-consistency ---- - dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite + dangerExpected := env.Meta.Risk == core.RiskWrite || env.Meta.Risk == core.RiskHighRiskWrite if env.Meta.Danger != dangerExpected { errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk)) } @@ -92,7 +92,7 @@ func lintEnvelope(env Envelope) []error { if env.InputSchema != nil && env.InputSchema.Properties != nil { _, hasYes = env.InputSchema.Properties.Map["yes"] } - wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite + wantYes := env.Meta.Risk == core.RiskHighRiskWrite if hasYes != wantYes { errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk)) } @@ -125,6 +125,9 @@ func walkForL2(props *OrderedProps, errs *[]error) { if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum { *errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum)) } + if n := len(p.EnumDescriptions); n > 0 && n != len(p.Enum) { + *errs = append(*errs, fmt.Errorf("L2: field %q enumDescriptions length (%d) != enum length (%d)", k, n, len(p.Enum))) + } if len(p.Required) > 0 && p.Properties != nil { for _, r := range p.Required { if _, ok := p.Properties.Map[r]; !ok { diff --git a/internal/schema/lint_test.go b/internal/schema/lint_test.go index 265c4c775..14774f6de 100644 --- a/internal/schema/lint_test.go +++ b/internal/schema/lint_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/registry" ) @@ -147,6 +148,18 @@ func TestLintEnvelope_L2_TypeChecks(t *testing.T) { }, wantSub: "minimum", }, + { + name: "enumDescriptions length must match enum", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"k"} + e.InputSchema.Properties.Map["k"] = Property{ + Type: "string", + Enum: []interface{}{"a", "b", "c"}, + EnumDescriptions: []string{"only one"}, // misaligned with 3 enum values + } + }, + wantSub: "enumDescriptions", + }, { // Regression guard: walkForL2 must recurse into the params/data // sub-objects introduced by the 4-bucket inputSchema, not only the @@ -334,9 +347,8 @@ func TestAllEnvelopesPass(t *testing.T) { knownEnvelopes := map[string]bool{} // Use embedded data only so the gate is deterministic across machines // (matches Task 17b: envelope assembly is overlay-independent). - for _, svc := range registry.EmbeddedServiceNames() { - spec := registry.EmbeddedSpec(svc) - envs := AssembleService(svc, spec, nil) + for _, svc := range registry.EmbeddedServicesTyped() { + envs := Envelopes(apicatalog.ServiceMethods(svc, nil)) for _, env := range envs { errs := lintEnvelope(env) if len(errs) == 0 { @@ -366,7 +378,7 @@ func TestAllEnvelopesPass(t *testing.T) { } // L4 coverage report (warn-only via t.Logf) - all := AssembleAll(nil) + all := Envelopes(registry.EmbeddedCatalog().WalkMethods(nil)) c := measureCoverage(all) for metric, rate := range c { baseline := coverageBaseline[metric] diff --git a/internal/schema/path.go b/internal/schema/path.go deleted file mode 100644 index a29b34136..000000000 --- a/internal/schema/path.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package schema - -import "strings" - -// ParsePath normalizes the positional arguments of `lark-cli schema` into a -// slice of path segments. It accepts two equivalent forms: -// -// lark-cli schema im.messages.reply -> single arg, split on "." -// lark-cli schema im messages reply -> multiple args, used as-is -// lark-cli schema "im chat.members bots" is NOT a supported form; quote -// arguments individually if your shell needs it. Nested resources keep their -// internal dots (e.g. "chat.members"). -// -// Returns nil for zero args (bare invocation). -func ParsePath(args []string) []string { - switch len(args) { - case 0: - return nil - case 1: - if strings.Contains(args[0], ".") { - return strings.Split(args[0], ".") - } - return []string{args[0]} - default: - return args - } -} diff --git a/internal/schema/types.go b/internal/schema/types.go index c8b1232c8..084ca4691 100644 --- a/internal/schema/types.go +++ b/internal/schema/types.go @@ -8,6 +8,8 @@ import ( "encoding/json" "fmt" "sort" + + "github.com/larksuite/cli/internal/meta" ) // Envelope is the MCP Tool spec contract for a single API method command. @@ -45,42 +47,32 @@ type Property struct { Type string `json:"type,omitempty"` Description string `json:"description,omitempty"` Enum []interface{} `json:"enum,omitempty"` - Default interface{} `json:"default,omitempty"` - Example interface{} `json:"example,omitempty"` - Minimum *float64 `json:"minimum,omitempty"` - Maximum *float64 `json:"maximum,omitempty"` - Format string `json:"format,omitempty"` - Required []string `json:"required,omitempty"` - Properties *OrderedProps `json:"properties,omitempty"` - Items *Property `json:"items,omitempty"` + // EnumDescriptions, when present, is parallel to Enum: the human meaning of + // each allowed value, in the same order. Omitted when no value carries a + // description. This is the widely-recognized JSON-Schema extension (VS Code, + // OpenAPI tooling) that lets an AI consumer learn what each enum value means + // without a second lookup. + EnumDescriptions []string `json:"enumDescriptions,omitempty"` + Default interface{} `json:"default,omitempty"` + Example interface{} `json:"example,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Format string `json:"format,omitempty"` + Required []string `json:"required,omitempty"` + Properties *OrderedProps `json:"properties,omitempty"` + Items *Property `json:"items,omitempty"` } // Meta is the Lark-specific extension namespace. type Meta struct { - EnvelopeVersion string `json:"envelope_version"` - Scopes []string `json:"scopes"` - RequiredScopes []string `json:"required_scopes"` - AccessTokens []string `json:"access_tokens"` - Danger bool `json:"danger"` - Risk string `json:"risk"` - DocURL string `json:"doc_url,omitempty"` - Affordance *Affordance `json:"affordance,omitempty"` -} - -// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded). -type Affordance struct { - UseWhen []string `json:"use_when,omitempty"` - DoNotUseWhen []string `json:"do_not_use_when,omitempty"` - Prerequisites []string `json:"prerequisites,omitempty"` - Examples []AffordanceCase `json:"examples,omitempty"` - Related []string `json:"related,omitempty"` -} - -// AffordanceCase is one example entry: a one-line description plus a -// ready-to-run lark-cli command string. -type AffordanceCase struct { - Description string `json:"description"` - Command string `json:"command"` + EnvelopeVersion string `json:"envelope_version"` + Scopes []string `json:"scopes"` + RequiredScopes []string `json:"required_scopes"` + AccessTokens []string `json:"access_tokens"` + Danger bool `json:"danger"` + Risk string `json:"risk"` + DocURL string `json:"doc_url,omitempty"` + Affordance *meta.Affordance `json:"affordance,omitempty"` } // OrderedProps is map[string]Property with preserved key order on MarshalJSON. @@ -91,6 +83,20 @@ type OrderedProps struct { Map map[string]Property } +// Set adds or replaces a property, recording first-seen keys in Order so JSON +// output preserves insertion order. Re-setting an existing key updates its +// value without reordering. Centralizing mutation here keeps Order and Map from +// drifting out of sync. +func (o *OrderedProps) Set(key string, p Property) { + if o.Map == nil { + o.Map = make(map[string]Property) + } + if _, exists := o.Map[key]; !exists { + o.Order = append(o.Order, key) + } + o.Map[key] = p +} + // MarshalJSON emits keys in Order, not alphabetical. If Order is empty but // Map has entries, fall back to alphabetical key order over Map so callers // that only populated Map (no explicit ordering) still see their fields. diff --git a/internal/schema/types_test.go b/internal/schema/types_test.go index ab1ae6c4e..ab69e4765 100644 --- a/internal/schema/types_test.go +++ b/internal/schema/types_test.go @@ -8,6 +8,21 @@ import ( "testing" ) +func TestOrderedProps_Set(t *testing.T) { + op := &OrderedProps{} + op.Set("b", Property{Type: "string"}) + op.Set("a", Property{Type: "integer"}) + op.Set("b", Property{Type: "boolean"}) // re-set: updates value, keeps position + + wantOrder := []string{"b", "a"} + if len(op.Order) != len(wantOrder) || op.Order[0] != "b" || op.Order[1] != "a" { + t.Errorf("Order = %v, want %v (insertion order, no duplicate on re-set)", op.Order, wantOrder) + } + if op.Map["b"].Type != "boolean" { + t.Errorf("re-set value = %q, want boolean", op.Map["b"].Type) + } +} + // OrderedProps 在测试里验证:MarshalJSON 按 Order 切片顺序输出 key,跳过 Go map 默认字母序。 func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) { op := &OrderedProps{