// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package api import ( "context" "encoding/json" "fmt" "io" "regexp" "strings" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/spf13/cobra" ) // APIOptions holds all inputs for the api command. type APIOptions struct { Factory *cmdutil.Factory Cmd *cobra.Command Ctx context.Context // Positional args Method string Path string // Flags Params string Data string As core.Identity Output string PageAll bool PageSize int PageLimit int PageDelay int Format string DryRun bool } func parseJsonOpt(input, label string) (map[string]interface{}, error) { if input == "" { return nil, nil } var result map[string]interface{} if err := json.Unmarshal([]byte(input), &result); err != nil { return nil, output.ErrValidation("%s invalid format, expected JSON object", label) } return result, nil } var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`) func normalisePath(raw string) string { if matches := urlPrefixRe.FindStringSubmatch(raw); len(matches) > 1 { raw = matches[1] } else if !strings.HasPrefix(raw, "/open-apis/") { raw = "/open-apis/" + strings.TrimPrefix(raw, "/") } return validate.StripQueryFragment(raw) } // NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook). func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command { opts := &APIOptions{Factory: f} var asStr string cmd := &cobra.Command{ Use: "api ", Short: "Generic Lark API requests", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { opts.Method = strings.ToUpper(args[0]) opts.Path = args[1] opts.Cmd = cmd opts.Ctx = cmd.Context() opts.As = core.Identity(asStr) if runF != nil { return runF(opts) } return apiRun(opts) }, } cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON") cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON") cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)") cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)") cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, cobra.ShellCompDirectiveNoFileComp } return nil, cobra.ShellCompDirectiveNoFileComp } _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp }) _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp }) return cmd } // buildAPIRequest validates flags and builds a RawApiRequest. func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) { params, err := parseJsonOpt(opts.Params, "--params") if err != nil { return client.RawApiRequest{}, err } if params == nil { params = map[string]interface{}{} } var data interface{} if opts.Data != "" { data, err = parseJsonOpt(opts.Data, "--data") if err != nil { return client.RawApiRequest{}, err } } if opts.PageSize > 0 { params["page_size"] = opts.PageSize } request := client.RawApiRequest{ Method: opts.Method, URL: normalisePath(opts.Path), Params: params, Data: data, As: opts.As, } // WithFileDownload tells the SDK to skip CodeError parsing on 200 OK. if opts.Output != "" { request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload()) } return request, nil } func apiRun(opts *APIOptions) error { f := opts.Factory opts.As = f.ResolveAs(opts.Cmd, opts.As) if opts.PageAll && opts.Output != "" { return output.ErrValidation("--output and --page-all are mutually exclusive") } request, err := buildAPIRequest(opts) if err != nil { return err } config, err := f.ResolveConfig(opts.As) if err != nil { return err } if opts.DryRun { return apiDryRun(f, request, config, opts.Format) } // Identity info is now included in the JSON envelope; skip stderr printing. // cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected) ac, err := f.NewAPIClientWithConfig(config) if err != nil { return err } out := f.IOStreams.Out format, formatOK := output.ParseFormat(opts.Format) if !formatOK { fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format) } if opts.PageAll { return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut, client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}) } resp, err := ac.DoAPI(opts.Ctx, request) if err != nil { return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) } err = client.HandleResponse(resp, client.ResponseOptions{ OutputPath: opts.Output, Format: format, Out: out, ErrOut: f.IOStreams.ErrOut, }) // MarkRaw tells root error handler that the API response was already written // to stdout, so it should skip the stderr error envelope. Only apply when // HandleResponse actually wrote output (i.e. returned a business/API error // after printing JSON to stdout). Non-JSON HTTP errors (e.g. 404 text/plain) // produce no stdout output and need the envelope. if err != nil && client.IsJSONContentType(resp.Header.Get("Content-Type")) { return output.MarkRaw(err) } return err } func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error { return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format) } func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error { switch format { case output.FormatNDJSON, output.FormatTable, output.FormatCSV: pf := output.NewPaginatedFormatter(out, format) result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) { pf.FormatPage(items) }, pagOpts) if err != nil { return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) } if apiErr := client.CheckLarkResponse(result); apiErr != nil { output.FormatValue(out, result, output.FormatJSON) return output.MarkRaw(apiErr) } if !hasItems { fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format) output.FormatValue(out, result, output.FormatJSON) } return nil default: result, err := ac.PaginateAll(ctx, request, pagOpts) if err != nil { return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) } if apiErr := client.CheckLarkResponse(result); apiErr != nil { output.FormatValue(out, result, output.FormatJSON) return output.MarkRaw(apiErr) } output.FormatValue(out, result, format) return nil } }