mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.
Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift
Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.
Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.
At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.
First PR in the feat/error-contract-* series.
469 lines
15 KiB
Go
469 lines
15 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).
|
|
//
|
|
// SDK Do() failures are normalised through WrapDoAPIError so every caller
|
|
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without each
|
|
// one remembering to wrap. In stage 1 that wire shape is still the legacy
|
|
// *output.ExitError envelope (network / api_error) — the stage-4 framework
|
|
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
|
|
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
|
|
// Errors that arrive already-classified (legacy *output.ExitError from
|
|
// resolveAccessToken's missing-credential paths, or a typed *errs.* from
|
|
// future stages) flow through unchanged.
|
|
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 {
|
|
// WrapDoAPIError is idempotent on already-classified errors:
|
|
// the *output.ExitError that resolveAccessToken returns for missing
|
|
// tokens (via output.ErrAuth) passes through with its auth category
|
|
// and exit 3 intact, and any future typed *errs.* error from the
|
|
// credential chain survives the same way. Only stray untyped errors
|
|
// (raw fmt.Errorf) get the transport-or-internal fallback.
|
|
return nil, WrapDoAPIError(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...)
|
|
resp, err := c.SDK.Do(ctx, req, opts...)
|
|
if err != nil {
|
|
return nil, WrapDoAPIError(err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// 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 {
|
|
// See DoSDKRequest comment on the same wrap pattern; the typed
|
|
// auth-error pass-through plus untyped fallback applies equally to
|
|
// streaming requests.
|
|
return nil, WrapDoAPIError(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).
|
|
//
|
|
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
|
|
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
|
|
// see an *output.ExitError envelope (api_error for malformed JSON, network
|
|
// for everything else) instead of a bare fmt.Errorf. Without this, an empty
|
|
// or malformed page body would surface to the root handler as a plain-text
|
|
// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4
|
|
// framework-boundary migration will flip this wrapper to typed
|
|
// *errs.InternalError / *errs.NetworkError.
|
|
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
|
resp, err := c.DoAPI(ctx, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, parseErr := ParseJSONResponse(resp)
|
|
if parseErr != nil {
|
|
return nil, WrapJSONResponseParseError(parseErr, resp.RawBody)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// CheckResponse inspects a Lark API response for business-level errors (non-zero code).
|
|
//
|
|
// Deprecated: legacy *output.ExitError wire shape via output.ErrAPI /
|
|
// ClassifyLarkError (type "api_error" / "permission" / etc). Preserved so
|
|
// existing callers keep emitting the same envelope until per-domain
|
|
// migration to typed errors. The identity parameter is reserved for the
|
|
// stage-2 typed path; stage-1 ignores it.
|
|
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) 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"])
|
|
}
|