mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
* feat: add strict mode identity filter, profile management and credential extension Port changes from feat/strict-mode-identity-filter_3 branch: - Add strict mode for identity filtering and configuration - Add profile management commands (add/list/remove/rename/use) - Add credential extension framework (registry, env provider) - Add VFS abstraction layer - Refactor factory default and client options - Update shortcuts to use new credential and validation patterns Change-Id: I8c104c6b147e1901d94aefcefe35a174932c742b Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: go mod tidy Change-Id: I0f610ccea6bc874248e84c24770944a3071dcc57 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fix test failures from credential provider migration - Remove unused TAT stub registrations in api and service tests (CredentialProvider manages tokens, SDK no longer calls TAT endpoint) - Update strict mode integration test: +chat-create now supports user identity, so it should succeed under strict mode user Change-Id: Iab51c2e12a97995e0b95dcd71df212d2d1f76570 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: migrate remaining os calls to internal/vfs Replace direct os.Stat/Open/MkdirAll/OpenFile/Remove/ReadDir/UserHomeDir with vfs equivalents in shortcuts/minutes, shortcuts/drive, and internal/keychain. Add ReadDir to the vfs interface and OsFs implementation. Change-Id: I8f97e5fb3e1731b4684d276644fcb10fae823067 * fix: resolve gofmt and goimports formatting issues Change-Id: If61578631f5698f7ca2d9a946ca59753651463fb * feat: add Flag.Input support for @file and stdin input sources Add framework-level support for reading flag values from files (@path) or stdin (-), solving the fundamental problem of passing complex text (markdown, multi-line content) via CLI arguments where shell escaping breaks content. Closes #239, fixes #163. - Add File/Stdin constants and Input field to Flag struct - Add resolveInputFlags() in runner pipeline (pre-Validate) - Support @@ escape for literal @ prefix - Guard against multiple stdin consumers - Auto-append "(supports @file, - for stdin)" to help text - Apply to: docs +create/+update --markdown, im +messages-send/+reply --text/--markdown/--content, task +comment --content, drive +add-comment --content Change-Id: I305a326d972417542aeadd70f37b74ea456461ef Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fix pre-existing test failures in task, minutes, and registry - task/minutes: remove unused tenant_access_token httpmock stubs (TestFactory's testDefaultToken provides tokens directly, so the HTTP stub was never consumed and failed verification) - registry: fix hasEmbeddedData() to check for actual services instead of just byte length (meta_data_default.json has empty services array) Change-Id: Ic7b5fc7f9de09137a7254fe1ddf47d24ade40587 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: suppress nilerr lint for intentional nil returns Both cases intentionally return nil on error for graceful degradation: - profile list: show friendly message when config is not initialized - service: skip scope check when token resolution fails Change-Id: I7285c37277c9b0361a421ab00359244c2cd150b3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review feedback - runner.go: fail fast when Input is used on non-string flags - remote_test.go: rename hasEmbeddedData → hasEmbeddedServices - profile/list.go: add omitempty to optional JSON fields - service.go: surface context cancellation errors in scope check Change-Id: I7072d41f8c711b4b37c542e32dfd8150f42b13c0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: tighten credential resolution and profile flows Change-Id: I83f6d424540eab9b1708944b9b6e26e8477cc60d * refactor: centralize identity hint resolution Change-Id: I38d5f98160b92adb62dc929ae73697ae5b3d64f8 * fix: surface unverified extension identities Change-Id: Ia86d9bd19add9010176339ec4cc89deb033f5b4f * fix: honor runtime credential sources in config views Change-Id: I40b2ffedc5c1db5e08e86b9472ea2b84fa02bb29 * fix: prefer runtime values in config show commands Change-Id: I5663a53e147577f0f1f533f67d12bea504e6b839 * Revert "fix: prefer runtime values in config show commands" This reverts commit4f9db3a227. * Revert "fix: honor runtime credential sources in config views" This reverts commitb3bfd526c5. * fix: harden profile flows and credential boundaries Change-Id: Ica61cd2730a639f71516cb1b237a639cb6511f7a * fix: optimize profile and config inspection for agents Change-Id: I19c368102f19654952638180ab947788a6971563 * refactor: unify credential env contracts Change-Id: I0ff2c0a650ea53589a0626333e8f6e628ef10a54 * docs: expand AGENTS guidance Change-Id: I289027dfd364c92205012feef6f05037066c035b * fix: resolve regression bugs found during PR #252 review - im: fix double SafeInputPath in resolveLocalMedia → uploadImageToIM/ uploadFileToIM chain that rejected all local image/file uploads - credential: stop writing plain-text warnings to stderr, preserving JSON envelope contract for AI agent consumers - profile add: reject duplicate app-id to prevent keychain credential collisions across profiles - profile rename: exclude self when checking name uniqueness so renaming to own appId works correctly - config: replace bare fmt.Errorf with output.Errorf in save-failure paths (default_as, strict_mode ×2, profile add) - factory: remove unused resolveDefaultAs method (lint) Change-Id: I6aa0d064414016f367f1edb08dd0604adf7bf13d Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove flaky TestColdStart_UsesEmbedded (race in registry) The test triggers a data race: resetInit() writes package globals while a background goroutine from a previous test may still be reading them. The embedded-data path is covered by other tests. Change-Id: I7a0c3bf85a9fb337b9279c9053697f40a0c0a0d4 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: type-strengthen Brand and DefaultAs across credential chain Replace raw string fields with typed enums for compile-time safety: - extension/credential: add Brand and Identity named types - internal/core: AppConfig.DefaultAs and CliConfig.DefaultAs → Identity - internal/credential: Account.DefaultAs and IdentityHint.DefaultAs → core.Identity The full data flow is now typed end-to-end: extcred.Brand → core.LarkBrand (named-type cast) extcred.Identity → core.Identity (named-type cast) No string intermediaries, no implicit conversions. Change-Id: I715b3b3f033fcb624010f1af9619e3562740ef08 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix gofmt alignment in extension/credential/types.go Change-Id: Ibfac0703a5a28f3c6ba4a47bf40696028d0f3b90 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove file/stdin input support from task comment content flag Change-Id: If49704ca4612465a23bd30b755d6e72a35fc2349 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(cmdutil): remove dead code autoDetectIdentity autoDetectIdentity() is only called from tests, never from production code. Remove it along with its 3 test cases to reduce surface area before the upcoming ctx propagation refactor. Change-Id: I35a188860f17656f3e1fe9874f87f284985ae196 * refactor(cmdutil): add ctx parameter to resolveIdentityHint Private method resolveIdentityHint now accepts context.Context and passes it to CredentialProvider.ResolveIdentityHint instead of using context.Background(). The caller (ResolveAs) still uses context.Background() temporarily until its own signature is updated. Change-Id: I14634a4e0dc1d657d56936ba61a7b7a206da8ac4 * refactor(cmdutil): add ctx parameter to ResolveStrictMode ResolveStrictMode now accepts context.Context and passes it to CredentialProvider.ResolveAccount instead of using context.Background(). Callers in cobra RunE pass cmd.Context(); callers outside RunE (cmd/root.go startup, tests) use context.Background() explicitly. Change-Id: I31be48e548ac5ac5640a65f3bfdde4a53ed1dc7e * refactor(cmdutil): add ctx parameter to CheckStrictMode CheckStrictMode now accepts context.Context and forwards it to ResolveStrictMode. Callers pass cmd.Context() (cobra RunE) or opts.Ctx (APIOptions/ServiceMethodOptions). Change-Id: I47888519d4cae8c94054771c32aff075565a8cdc * refactor(cmdutil): add ctx parameter to ResolveAs ResolveAs now accepts context.Context as first parameter and forwards it to ResolveStrictMode and resolveIdentityHint. This completes the ctx propagation chain: all Factory methods that call CredentialProvider now receive ctx from cobra cmd.Context(). No more context.Background() calls remain in factory.go for credential provider operations. Change-Id: I6d10b6350e3b149470660de3e7855614314e8b29 * test: fix gofmt in cmdutil factory tests Change-Id: I4a87d5a815b959f14cc4371b73dee4aae106932f * fix: remove file/stdin input support from im send/reply and drive comment The Input (file/stdin) feature is not yet ready for these flags: - im send/reply: --content, --text, --markdown - drive add-comment: --content Retained only in doc create/update where markdown from file is essential. Change-Id: I582b6349528fccb639ad9edc84650cca3b68535c Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: liushiyao <liushiyao.1206@bytedance.com>
429 lines
13 KiB
Go
429 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/util"
|
|
)
|
|
|
|
// RawApiRequest describes a raw API request.
|
|
type RawApiRequest struct {
|
|
Method string
|
|
URL string
|
|
Params map[string]interface{}
|
|
Data interface{}
|
|
As core.Identity
|
|
ExtraOpts []larkcore.RequestOptionFunc // additional SDK request options (e.g. security headers)
|
|
}
|
|
|
|
// APIClient wraps lark.Client for all Lark Open API calls.
|
|
type APIClient struct {
|
|
Config *core.CliConfig
|
|
SDK *lark.Client // All Lark API calls go through SDK
|
|
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
|
|
ErrOut io.Writer // debug/progress output
|
|
Credential *credential.CredentialProvider
|
|
}
|
|
|
|
func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (string, error) {
|
|
result, err := c.Credential.ResolveToken(ctx, credential.NewTokenSpec(as, c.Config.AppID))
|
|
if err != nil {
|
|
var unavailableErr *credential.TokenUnavailableError
|
|
if errors.As(err, &unavailableErr) {
|
|
return "", output.ErrAuth("no access token available for %s", as)
|
|
}
|
|
return "", err
|
|
}
|
|
if result.Token == "" {
|
|
return "", output.ErrAuth("no access token available for %s", as)
|
|
}
|
|
return result.Token, nil
|
|
}
|
|
|
|
// buildApiReq converts a RawApiRequest into SDK types and collects
|
|
// request-specific options (ExtraOpts, URL-based headers).
|
|
// Auth is handled separately by DoSDKRequest.
|
|
func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []larkcore.RequestOptionFunc) {
|
|
queryParams := make(larkcore.QueryParams)
|
|
for k, v := range request.Params {
|
|
switch val := v.(type) {
|
|
case []string:
|
|
queryParams[k] = val
|
|
case []interface{}:
|
|
for _, item := range val {
|
|
queryParams.Add(k, fmt.Sprintf("%v", item))
|
|
}
|
|
default:
|
|
queryParams.Set(k, fmt.Sprintf("%v", v))
|
|
}
|
|
}
|
|
|
|
apiReq := &larkcore.ApiReq{
|
|
HttpMethod: strings.ToUpper(request.Method),
|
|
ApiPath: request.URL,
|
|
Body: request.Data,
|
|
QueryParams: queryParams,
|
|
}
|
|
|
|
var opts []larkcore.RequestOptionFunc
|
|
opts = append(opts, request.ExtraOpts...)
|
|
return apiReq, opts
|
|
}
|
|
|
|
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
|
|
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
|
|
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
|
|
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
|
var opts []larkcore.RequestOptionFunc
|
|
|
|
token, err := c.resolveAccessToken(ctx, as)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if as.IsBot() {
|
|
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
|
|
opts = append(opts, larkcore.WithTenantAccessToken(token))
|
|
} else {
|
|
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}
|
|
opts = append(opts, larkcore.WithUserAccessToken(token))
|
|
}
|
|
|
|
opts = append(opts, extraOpts...)
|
|
return c.SDK.Do(ctx, req, opts...)
|
|
}
|
|
|
|
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
|
|
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
|
|
// a live *http.Response whose Body is an io.Reader for streaming consumption.
|
|
// Auth is resolved via Credential (same as DoSDKRequest). Security headers and
|
|
// any extra headers from opts are applied automatically.
|
|
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
|
|
// closed, and returned as an output.ErrNetwork — callers only receive successful responses.
|
|
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
|
|
cfg := buildConfig(opts)
|
|
|
|
// Resolve auth
|
|
token, err := c.resolveAccessToken(ctx, as)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build URL
|
|
requestURL, err := buildStreamURL(c.Config.Brand, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build body
|
|
bodyReader, contentType, err := buildStreamBody(req.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Timeout — use context deadline only; httpClient.Timeout would cut off
|
|
// healthy streaming responses because it includes body read time.
|
|
httpClient := *c.HTTP
|
|
httpClient.Timeout = 0
|
|
cancel := func() {}
|
|
requestCtx := ctx
|
|
if cfg.timeout > 0 {
|
|
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
|
requestCtx, cancel = context.WithTimeout(ctx, cfg.timeout)
|
|
}
|
|
}
|
|
|
|
// Build request
|
|
httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, output.ErrNetwork("stream request failed: %s", err)
|
|
}
|
|
|
|
// Apply headers from opts
|
|
for k, vs := range cfg.headers {
|
|
for _, v := range vs {
|
|
httpReq.Header.Add(k, v)
|
|
}
|
|
}
|
|
|
|
if contentType != "" {
|
|
httpReq.Header.Set("Content-Type", contentType)
|
|
}
|
|
httpReq.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, output.ErrNetwork("stream request failed: %s", err)
|
|
}
|
|
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
|
|
|
// Handle HTTP errors internally
|
|
if resp.StatusCode >= 400 {
|
|
defer resp.Body.Close()
|
|
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
msg := strings.TrimSpace(string(errBody))
|
|
if msg != "" {
|
|
return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
|
|
}
|
|
return nil, output.ErrNetwork("HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
type cancelOnCloseBody struct {
|
|
io.ReadCloser
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (r *cancelOnCloseBody) Close() error {
|
|
err := r.ReadCloser.Close()
|
|
if r.cancel != nil {
|
|
r.cancel()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) {
|
|
requestURL := req.ApiPath
|
|
if !strings.HasPrefix(requestURL, "http://") && !strings.HasPrefix(requestURL, "https://") {
|
|
var pathSegs []string
|
|
for _, segment := range strings.Split(req.ApiPath, "/") {
|
|
if !strings.HasPrefix(segment, ":") {
|
|
pathSegs = append(pathSegs, segment)
|
|
continue
|
|
}
|
|
pathKey := strings.TrimPrefix(segment, ":")
|
|
pathValue, ok := req.PathParams[pathKey]
|
|
if !ok {
|
|
return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath)
|
|
}
|
|
if pathValue == "" {
|
|
return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath)
|
|
}
|
|
pathSegs = append(pathSegs, url.PathEscape(pathValue))
|
|
}
|
|
endpoints := core.ResolveEndpoints(brand)
|
|
requestURL = strings.TrimRight(endpoints.Open, "/") + strings.Join(pathSegs, "/")
|
|
}
|
|
if query := req.QueryParams.Encode(); query != "" {
|
|
requestURL += "?" + query
|
|
}
|
|
return requestURL, nil
|
|
}
|
|
|
|
func buildStreamBody(body interface{}) (io.Reader, string, error) {
|
|
switch typed := body.(type) {
|
|
case nil:
|
|
return nil, "", nil
|
|
case io.Reader:
|
|
return typed, "", nil
|
|
case []byte:
|
|
return bytes.NewReader(typed), "", nil
|
|
case string:
|
|
return strings.NewReader(typed), "text/plain; charset=utf-8", nil
|
|
default:
|
|
payload, err := json.Marshal(typed)
|
|
if err != nil {
|
|
return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err)
|
|
}
|
|
return bytes.NewReader(payload), "application/json", nil
|
|
}
|
|
}
|
|
|
|
// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp.
|
|
// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable
|
|
// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and
|
|
// any endpoint whose Content-Type may not be JSON.
|
|
func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore.ApiResp, error) {
|
|
apiReq, extraOpts := c.buildApiReq(request)
|
|
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
|
|
}
|
|
|
|
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse.
|
|
// Use DoAPI directly when the response may not be JSON (e.g. file downloads).
|
|
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
|
resp, err := c.DoAPI(ctx, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseJSONResponse(resp)
|
|
}
|
|
|
|
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
|
|
// it calls onResult if non-nil. It always accumulates and returns all raw page results.
|
|
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{})) ([]interface{}, error) {
|
|
var allResults []interface{}
|
|
var pageToken string
|
|
page := 0
|
|
pageDelay := opts.PageDelay
|
|
if pageDelay == 0 {
|
|
pageDelay = 200
|
|
}
|
|
|
|
for {
|
|
page++
|
|
params := make(map[string]interface{})
|
|
for k, v := range request.Params {
|
|
params[k] = v
|
|
}
|
|
if pageToken != "" {
|
|
params["page_token"] = pageToken
|
|
}
|
|
|
|
fmt.Fprintf(c.ErrOut, "[page %d] fetching...\n", page)
|
|
result, err := c.CallAPI(ctx, RawApiRequest{
|
|
Method: request.Method,
|
|
URL: request.URL,
|
|
Params: params,
|
|
Data: request.Data,
|
|
As: request.As,
|
|
ExtraOpts: request.ExtraOpts,
|
|
})
|
|
if err != nil {
|
|
if page == 1 {
|
|
return nil, err
|
|
}
|
|
fmt.Fprintf(c.ErrOut, "[page %d] error, stopping pagination\n", page)
|
|
break
|
|
}
|
|
|
|
if resultMap, ok := result.(map[string]interface{}); ok {
|
|
code, _ := util.ToFloat64(resultMap["code"])
|
|
if code != 0 {
|
|
allResults = append(allResults, result)
|
|
if page == 1 {
|
|
return allResults, nil
|
|
}
|
|
fmt.Fprintf(c.ErrOut, "[page %d] API error (code=%.0f), stopping pagination\n", page, code)
|
|
break
|
|
}
|
|
}
|
|
|
|
if onResult != nil {
|
|
onResult(result)
|
|
}
|
|
allResults = append(allResults, result)
|
|
|
|
pageToken = ""
|
|
if resultMap, ok := result.(map[string]interface{}); ok {
|
|
if data, ok := resultMap["data"].(map[string]interface{}); ok {
|
|
hasMore, _ := data["has_more"].(bool)
|
|
if hasMore {
|
|
if pt, ok := data["page_token"].(string); ok && pt != "" {
|
|
pageToken = pt
|
|
} else if pt, ok := data["next_page_token"].(string); ok && pt != "" {
|
|
pageToken = pt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if pageToken == "" {
|
|
break
|
|
}
|
|
|
|
if opts.PageLimit > 0 && page >= opts.PageLimit {
|
|
fmt.Fprintf(c.ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", opts.PageLimit)
|
|
break
|
|
}
|
|
|
|
if pageDelay > 0 {
|
|
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
|
|
}
|
|
}
|
|
return allResults, nil
|
|
}
|
|
|
|
// PaginateAll fetches all pages and returns a single merged result.
|
|
// Use this for formats that need the complete dataset (e.g. JSON).
|
|
func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts PaginationOptions) (interface{}, error) {
|
|
results, err := c.paginateLoop(ctx, request, opts, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(results) == 0 {
|
|
return map[string]interface{}{}, nil
|
|
}
|
|
if len(results) == 1 {
|
|
return results[0], nil
|
|
}
|
|
return mergePagedResults(c.ErrOut, results), nil
|
|
}
|
|
|
|
// StreamPages fetches all pages and streams each page's list items via onItems.
|
|
// Returns the last page result (for error checking), whether any list items were found,
|
|
// and any network error. Use this for streaming formats (ndjson, table, csv).
|
|
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}), opts PaginationOptions) (result interface{}, hasItems bool, err error) {
|
|
totalItems := 0
|
|
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) {
|
|
resultMap, ok := r.(map[string]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
data, ok := resultMap["data"].(map[string]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
arrayField := output.FindArrayField(data)
|
|
if arrayField == "" {
|
|
return
|
|
}
|
|
items, ok := data[arrayField].([]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
totalItems += len(items)
|
|
onItems(items)
|
|
hasItems = true
|
|
})
|
|
if loopErr != nil {
|
|
return nil, false, loopErr
|
|
}
|
|
|
|
if hasItems {
|
|
fmt.Fprintf(c.ErrOut, "[pagination] streamed %d pages, %d total items\n", len(results), totalItems)
|
|
}
|
|
|
|
if len(results) > 0 {
|
|
return results[len(results)-1], hasItems, nil
|
|
}
|
|
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
|
|
}
|
|
|
|
// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code).
|
|
// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint.
|
|
// Returns nil if result is not a map, map is nil, or code is 0.
|
|
func CheckLarkResponse(result interface{}) error {
|
|
resultMap, ok := result.(map[string]interface{})
|
|
if !ok || resultMap == nil {
|
|
return nil
|
|
}
|
|
code, _ := util.ToFloat64(resultMap["code"])
|
|
if code == 0 {
|
|
return nil
|
|
}
|
|
larkCode := int(code)
|
|
msg, _ := resultMap["msg"].(string)
|
|
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
|
|
}
|