mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
* refactor: add output emitter contract and differential harness Introduce a leaf Emitter in internal/output that composes the existing output primitives (content-safety scan, envelope, jq, format rendering, notice) behind a single command-scoped port. The emitter is unwired: no production caller is migrated, so CLI output stays byte-for-byte unchanged. A differential test harness drives the real legacy entry points (RuntimeContext.Out/OutRaw/OutFormat/..., WriteSuccessEnvelope and the pagination formatter) and asserts byte-identical stdout/stderr plus typed errors, locking behavior before later slices migrate callers. * refactor: tighten emitter API and cover pagination with real tests - split Emitter.Success/PartialFailure and drop EmitOptions.OK so a missing ok flag can no longer silently emit ok:false - give StreamPage its own StreamOptions (format + pretty) instead of reusing EmitOptions, making "jq needs aggregation" a compile-time fact - pin the Emitter jq-error contract (returns error, writes no stderr); the caller adapter re-emits the legacy stderr line on migration - add in-package tests driving the real apiPaginate/servicePaginate over a mock transport: multi-page aggregation, empty-result fallback, MarkRaw handling, and the business-error raw-response red line * test: use standard TestFactory harness for pagination tests Replace the hand-rolled RoundTripper + APIClient construction in the apiPaginate/servicePaginate tests with cmdutil.TestFactory and its httpmock.Registry, and isolate LARKSUITE_CLI_CONFIG_DIR to t.TempDir(), matching the repo's standard HTTP-mocked test convention. Assertions and coverage (multi-page aggregation, empty-result fallback, MarkRaw, and the business-error raw-response red line) are unchanged. * refactor: route success output through the single Emitter port Migrate the success-output surfaces onto internal/output's Emitter, byte-for-byte identical (proven by frozen golden diffs and the real paginate/HandleResponse tests): - RuntimeContext.Out/OutRaw/OutFormat/OutFormatRaw/OutPartialFailure now build an Emitter and call Success/PartialFailure; emit and outFormat are removed. An adapter maps the returned error back to the legacy outputErrOnce / jq-error stderr / exit-code behavior. - WriteSuccessEnvelope degrades to a thin Emitter.Success delegate; its 8 callers are unchanged. - apiPaginate/servicePaginate stream pages via Emitter.StreamPage; the aggregate and business-error raw-response branches are untouched. - HandleResponse routes its non-JSON structured-response branch through Emitter.Success. Frozen golden fixtures replace the runtime legacy oracles so the differential harness cannot go self-referential after migration. * fix: keep _notice on struct payloads in Emitter's unknown-format fallback printLegacyDataJSON now normalizes via toGeneric first (matching FormatValue), so a struct / named-map payload retains its injected _notice on the unknown-format -> JSON fallback rather than dropping it silently. Add a regression test that fails against the pre-fix path. * refactor: make the Emitter own write failures and stop mutating inputs Route every Emitter stdout path through a render-to-buffer-then-copy helper so a marshal/render failure leaves stdout empty and surfaces a typed internal error (with cause), and a stdout write failure is propagated instead of silently swallowed. Leaf writers gain error-returning Write* cores; the legacy Print*/FormatValue wrappers keep their exact behavior for unmigrated callers. - handleEmitterError now captures every error, not only the jq/safety branches; flip OutRaw's write-error test to assert propagation. - Clone the map before injecting _notice so a caller's payload is never mutated and an existing _notice is never overwritten. - Preserve jq's own typed error (validation/api) on a bad expression or runtime failure; only wrap genuine stdout write failures. - Split tests: normative emitter_contract_test.go vs frozen emitter_legacy_compat_test.go (base SHA recorded, self-update env vars removed). * fix: satisfy license-header and forbidigo lint on the emitter changes - Move the base-SHA note below the copyright header in the renamed legacy-compat test so the license-header check sees a valid header at the top. - Route the leaf wrappers' marshal/format stderr messages through a single legacyStderrf helper (one //nolint:forbidigo) instead of bare os.Stderr, preserving exact legacy behavior for unmigrated direct callers while passing forbidigo; drop the now-unused os imports. * fix: stop legacy CSV wrappers reporting write failures to stderr Align FormatAsCSV/FormatAsCSVPaginated and FormatValue/FormatPage's CSV branch with the other leaf wrappers: report only marshal failures, swallow write failures. Previously they emitted a 'csv write error' for the (empty) line and the JSON-fallback write failures that the pre-refactor code ignored, and mislabeled a JSON write failure as a CSV one. Failure-path only; success output is unchanged (golden double-diff still byte-for-byte).
393 lines
14 KiB
Go
393 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"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
|
|
JqExpr string
|
|
DryRun bool
|
|
File string
|
|
}
|
|
|
|
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 {
|
|
return NewCmdApiWithContext(context.Background(), f, runF)
|
|
}
|
|
|
|
func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
|
opts := &APIOptions{Factory: f}
|
|
var asStr string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "api <method> <path>",
|
|
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
|
|
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
|
|
|
|
Prefer the typed domain command when one exists — it validates parameters,
|
|
shows the Risk level, gates destructive calls behind --yes, and carries usage
|
|
guidance that this raw command does not. If a domain command covers your task
|
|
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
|
|
|
|
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
|
|
newer/preview APIs), where you already have the HTTP path from the Lark docs.
|
|
|
|
Examples:
|
|
lark-cli api GET /open-apis/calendar/v4/calendars
|
|
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
|
|
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 (supports - for stdin, @file for file input)")
|
|
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
|
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
|
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().Bool("json", false, "shorthand for --format json")
|
|
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
|
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
|
|
|
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
|
|
}
|
|
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
|
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
|
})
|
|
cmdutil.SetRisk(cmd, "write")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// buildAPIRequest validates flags and builds a RawApiRequest.
|
|
// When dryRun is true and a file is provided, file reading is skipped and
|
|
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
|
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
|
stdin := opts.Factory.IOStreams.In
|
|
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
|
|
|
if opts.Method == "" {
|
|
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"HTTP method must not be empty").
|
|
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
|
|
WithParam("<method>")
|
|
}
|
|
|
|
// Validate --file mutual exclusions first.
|
|
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
|
return client.RawApiRequest{}, nil, err
|
|
}
|
|
|
|
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
|
|
if opts.Params == "-" && opts.Data == "-" {
|
|
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"--params and --data cannot both read from stdin (-)").
|
|
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
|
|
WithParams(
|
|
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
|
|
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
|
|
)
|
|
}
|
|
|
|
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
|
if err != nil {
|
|
return client.RawApiRequest{}, nil, err
|
|
}
|
|
if opts.PageSize > 0 {
|
|
params["page_size"] = opts.PageSize
|
|
}
|
|
|
|
request := client.RawApiRequest{
|
|
Method: opts.Method,
|
|
URL: normalisePath(opts.Path),
|
|
Params: params,
|
|
As: opts.As,
|
|
}
|
|
|
|
if opts.File != "" {
|
|
// File upload path: build formdata.
|
|
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, "file")
|
|
|
|
// Parse --data as JSON map for form fields (not as body).
|
|
var dataFields any
|
|
if opts.Data != "" {
|
|
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
|
if err != nil {
|
|
return client.RawApiRequest{}, nil, err
|
|
}
|
|
if _, ok := dataFields.(map[string]any); !ok {
|
|
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"--data must be a JSON object when used with --file").
|
|
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
|
|
WithParam("--data")
|
|
}
|
|
}
|
|
|
|
if opts.DryRun {
|
|
return request, &cmdutil.FileUploadMeta{
|
|
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
|
|
}, nil
|
|
}
|
|
|
|
fd, err := cmdutil.BuildFormdata(
|
|
fileIO,
|
|
fieldName, filePath, isStdin, stdin, dataFields,
|
|
)
|
|
if err != nil {
|
|
return client.RawApiRequest{}, nil, err
|
|
}
|
|
request.Data = fd
|
|
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
|
} else {
|
|
// Normal path: JSON body.
|
|
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
|
if err != nil {
|
|
return client.RawApiRequest{}, nil, err
|
|
}
|
|
request.Data = data
|
|
if opts.Output != "" {
|
|
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
|
}
|
|
}
|
|
|
|
return request, nil, nil
|
|
}
|
|
|
|
func apiRun(opts *APIOptions) error {
|
|
f := opts.Factory
|
|
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
|
|
|
|
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.PageAll && opts.Output != "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"--output and --page-all are mutually exclusive").
|
|
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
|
|
WithParams(
|
|
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
|
|
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
|
|
)
|
|
}
|
|
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
|
return err
|
|
}
|
|
|
|
request, fileMeta, err := buildAPIRequest(opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
config, err := f.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.DryRun {
|
|
if fileMeta != nil {
|
|
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
|
|
}
|
|
return apiDryRun(f, request, config, opts)
|
|
}
|
|
// 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, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
|
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
|
}
|
|
|
|
resp, err := ac.DoAPI(opts.Ctx, request)
|
|
if err != nil {
|
|
// MarkRaw tells the dispatcher to skip the legacy enrichPermissionError
|
|
// pass on *output.ExitError values. Typed *errs.* errors that flow
|
|
// through here keep their canonical message / hint from BuildAPIError;
|
|
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
|
|
return errs.MarkRaw(err)
|
|
}
|
|
err = client.HandleResponse(resp, client.ResponseOptions{
|
|
OutputPath: opts.Output,
|
|
Format: format,
|
|
JqExpr: opts.JqExpr,
|
|
Out: out,
|
|
ErrOut: f.IOStreams.ErrOut,
|
|
FileIO: f.ResolveFileIO(opts.Ctx),
|
|
CommandPath: opts.Cmd.CommandPath(),
|
|
Identity: opts.As,
|
|
// CheckResponse routes through errclass.BuildAPIError for known Lark
|
|
// codes (typed PermissionError / AuthenticationError / ...). For
|
|
// unknown codes it falls back to *errs.APIError. The Brand+AppID on
|
|
// the client populate identity-aware fields (ConsoleURL etc.).
|
|
CheckError: ac.CheckResponse,
|
|
})
|
|
// MarkRaw: see comment above on the DoAPI path. Skips legacy
|
|
// *ExitError enrichment; typed errors flow through unchanged.
|
|
if err != nil {
|
|
return errs.MarkRaw(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
|
|
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
|
|
}
|
|
|
|
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
|
|
return cmdutil.DryRunOutputOptions{
|
|
Format: opts.Format,
|
|
JqExpr: opts.JqExpr,
|
|
CommandPath: opts.Cmd.CommandPath(),
|
|
Identity: opts.As,
|
|
Out: f.IOStreams.Out,
|
|
ErrOut: f.IOStreams.ErrOut,
|
|
}
|
|
}
|
|
|
|
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
|
if pagOpts.Identity == "" {
|
|
pagOpts.Identity = request.As
|
|
}
|
|
// When jq is set, always aggregate all pages then filter.
|
|
if jqExpr != "" {
|
|
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
|
if err != nil {
|
|
return errs.MarkRaw(err)
|
|
}
|
|
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
|
output.FormatValue(out, result, output.FormatJSON)
|
|
return errs.MarkRaw(apiErr)
|
|
}
|
|
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
|
CommandPath: commandPath,
|
|
Identity: string(pagOpts.Identity),
|
|
JqExpr: jqExpr,
|
|
Out: out,
|
|
ErrOut: errOut,
|
|
})
|
|
}
|
|
|
|
switch format {
|
|
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
|
emitter := output.NewEmitter(output.EmitterConfig{
|
|
Out: out,
|
|
ErrOut: errOut,
|
|
CommandPath: commandPath,
|
|
Identity: string(pagOpts.Identity),
|
|
NoticeProvider: output.GetNotice,
|
|
})
|
|
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
|
// Streaming formats intentionally emit each page after that page has
|
|
// passed safety scanning. A later page may still fail, so callers
|
|
// must use the exit code to distinguish complete vs partial output.
|
|
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
|
}, pagOpts)
|
|
if err != nil {
|
|
return errs.MarkRaw(err)
|
|
}
|
|
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
|
return errs.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)
|
|
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
|
CommandPath: commandPath,
|
|
Identity: string(pagOpts.Identity),
|
|
Out: out,
|
|
ErrOut: errOut,
|
|
})
|
|
}
|
|
return nil
|
|
default:
|
|
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
|
if err != nil {
|
|
return errs.MarkRaw(err)
|
|
}
|
|
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
|
output.FormatValue(out, result, output.FormatJSON)
|
|
return errs.MarkRaw(apiErr)
|
|
}
|
|
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
|
CommandPath: commandPath,
|
|
Identity: string(pagOpts.Identity),
|
|
Out: out,
|
|
ErrOut: errOut,
|
|
})
|
|
}
|
|
}
|