Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/|shortcuts/base/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
# it bans are still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/|shortcuts/mail/)
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/base/)
text: errs-no-legacy-helper
linters:
- forbidigo
Expand Down
1 change: 1 addition & 0 deletions lint/errscontract/rule_no_legacy_common_helper_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
// legacy validation/save helpers are forbidden; callers must use the typed
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"shortcuts/base/",
"shortcuts/drive/",
"shortcuts/mail/",
}
Expand Down
1 change: 1 addition & 0 deletions lint/errscontract/rule_no_legacy_envelope_literal.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
// call sites must return a typed errs.* error instead. Future domains opt in by
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/drive/",
"shortcuts/mail/",
}
Expand Down
4 changes: 2 additions & 2 deletions shortcuts/base/base_advperm_disable.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},
Expand All @@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
return err
}

return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed")
return handleRoleAPIResponse(runtime, apiResp, "disable advanced permissions failed")
},
}
4 changes: 2 additions & 2 deletions shortcuts/base/base_advperm_enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ var BaseAdvpermEnable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},
Expand All @@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
return err
}

return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed")
return handleRoleAPIResponse(runtime, apiResp, "enable advanced permissions failed")
},
}
8 changes: 2 additions & 6 deletions shortcuts/base/base_advperm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,7 @@ func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) {
},
})
args := []string{"+advperm-enable", "--base-token", "app_x"}
if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseAdvpermEnable, args, factory, stdout), 190001, "bad request")
}

func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
Expand Down Expand Up @@ -226,7 +224,5 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
},
})
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseAdvpermDisable, args, factory, stdout), 190002, "permission denied")
}
8 changes: 4 additions & 4 deletions shortcuts/base/base_block_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,24 @@

func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
return baseFlagErrorf("--name must not be blank")
}
if strings.TrimSpace(runtime.Str("type")) == "" {
return common.FlagErrorf("--type must not be blank")
return baseFlagErrorf("--type must not be blank")

Check warning on line 61 in shortcuts/base/base_block_ops.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_block_ops.go#L61

Added line #L61 was not covered by tests
}
return nil
}

func validateBaseBlockMove(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
return baseFlagErrorf("--before-id and --after-id are mutually exclusive")
}
return nil
}

func validateBaseBlockRename(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
return baseFlagErrorf("--name must not be blank")
}
return nil
}
Expand Down
4 changes: 2 additions & 2 deletions shortcuts/base/base_data_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
dec.UseNumber()
if err := dec.Decode(&dsl); err != nil {
return common.FlagErrorf("--dsl invalid JSON: %v", err)
return baseFlagErrorf("--dsl invalid JSON: %v", err)

Check warning on line 35 in shortcuts/base/base_data_query.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_data_query.go#L35

Added line #L35 was not covered by tests
}
_, hasDim := dsl["dimensions"]
_, hasMeas := dsl["measures"]
if !hasDim && !hasMeas {
return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")

Check warning on line 40 in shortcuts/base/base_data_query.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_data_query.go#L40

Added line #L40 was not covered by tests
}
return nil
},
Expand Down
212 changes: 169 additions & 43 deletions shortcuts/base/base_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
package base

import (
"errors"
"fmt"
"strings"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util"
)

Expand All @@ -24,74 +28,196 @@
// structured ErrAPI, with server-provided message/hint promoted to the top level.
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
return nil, baseAPIBoundaryError(err, action)
}

resultMap, _ := result.(map[string]interface{})
code, _ := util.ToFloat64(resultMap["code"])
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API returned a malformed response envelope", action)

Check warning on line 36 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L36

Added line #L36 was not covered by tests
}
if _, exists := resultMap["code"]; !exists {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response is missing code", action)

Check warning on line 39 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L39

Added line #L39 was not covered by tests
}
code, numeric := util.ToFloat64(resultMap["code"])
if !numeric {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response code is not numeric", action)
}
if code == 0 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return resultMap["data"], nil
}

larkCode := int(code)
msg := extractDataErrorMessage(resultMap)
if strings.TrimSpace(msg) == "" {
msg, _ = resultMap["msg"].(string)
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{})
}

// baseFlagErrorf marks flag-usage failures; it shares baseValidationErrorf's
// typed envelope and exists so call sites read as flag rejections.
func baseFlagErrorf(format string, args ...any) error {
return baseValidationErrorf(format, args...)
}

func baseValidationErrorf(format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
if params := flagParams(msg); len(params) > 0 {
err = err.WithParam(params[0].Name).WithParams(params...)
}
if cause := firstErrorArg(args); cause != nil {
err = err.WithCause(cause)
}
return err
}

detail := extractErrorDetail(resultMap)
apiErr := output.ErrAPI(larkCode, msg, detail)
hint := extractErrorHint(resultMap)
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
apiErr.Detail.Hint = hint
func flagParams(msg string) []errs.InvalidParam {
reason := msg
seen := map[string]bool{}
params := []errs.InvalidParam{}
for start := strings.Index(msg, "--"); start >= 0; start = strings.Index(msg, "--") {
end := start + 2
for end < len(msg) {
ch := msg[end]
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
end++
continue
}
break
}
if end > start+2 {
name := msg[start:end]
if !seen[name] {
seen[name] = true
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
}
}
msg = msg[end:]
}
if apiErr.Detail != nil {
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
return params
}

func firstErrorArg(args []any) error {
for _, arg := range args {
if err, ok := arg.(error); ok {
return err
}
}
return nil, apiErr
return nil
}

// baseMissingFileIOError reports a broken runtime wiring: a command that needs
// local file access was constructed without a FileIO provider. The user cannot
// fix this by changing flags, so it classifies as internal, not validation.
func baseMissingFileIOError(format string, args ...any) error {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...)
}

func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
detailMap, ok := detail.(map[string]interface{})
if !ok {
func baseInputStatError(err error) error {
if err == nil {
return nil
}
for key, value := range detailMap {
if value == nil {
delete(detailMap, key)
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)

Check warning on line 117 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L117

Added line #L117 was not covered by tests
}
if len(detailMap) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
}

func baseSaveError(err error) error {
if err == nil {

Check warning on line 123 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L122-L123

Added lines #L122 - L123 were not covered by tests
return nil
}
return detailMap
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)

Check warning on line 133 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L126-L133

Added lines #L126 - L133 were not covered by tests
}
}

func extractErrorDetail(resultMap map[string]interface{}) interface{} {
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
return detail
func baseAPIBoundaryError(err error, action string) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := nonNilMapValue(data, "error"); ok {
return detail
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "%s: %s", action, err).WithCause(err)
}

func baseUploadAttachmentError(filePath string, err error) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = fmt.Sprintf("failed to upload attachment %s: %s", filePath, p.Message)
return err

Check warning on line 147 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L146-L147

Added lines #L146 - L147 were not covered by tests
}
return nil
return errs.NewInternalError(errs.SubtypeSDKError, "failed to upload attachment %s: %s", filePath, err).WithCause(err)
}

func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
if src == nil {
return nil, false
func baseAPIErrorFromResult(resultMap map[string]interface{}, cc errclass.ClassifyContext) error {
if resultMap == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a malformed response envelope")

Check warning on line 154 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L154

Added line #L154 was not covered by tests
}
value, ok := src[key]
if !ok {
return nil, false
if msg := extractDataErrorMessage(resultMap); msg != "" {
resultMap["msg"] = msg
}
switch value.(type) {
case nil:
return nil, false
default:
return value, true
hint := extractErrorHint(resultMap)
if logID := extractBaseErrorLogID(resultMap); logID != "" {
resultMap["log_id"] = logID
}
err := errclass.BuildAPIError(resultMap, cc)
if err == nil {
return nil

Check warning on line 165 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L165

Added line #L165 was not covered by tests
}
if p, ok := errs.ProblemOf(err); ok && hint != "" {
p.Hint = hint
}
return err
}

func enrichBaseAPIErrorFromBody(err error, body []byte, cc errclass.ClassifyContext) error {
if _, ok := errs.ProblemOf(err); !ok {
return err

Check warning on line 175 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L175

Added line #L175 was not covered by tests
}
result, parseErr := decodeBaseV3Response(body)
if parseErr != nil {
return err

Check warning on line 179 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L179

Added line #L179 was not covered by tests
}
enriched := baseAPIErrorFromResult(result, cc)
if enriched == nil {
return err

Check warning on line 183 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L183

Added line #L183 was not covered by tests
}
src, _ := errs.ProblemOf(enriched)
dst, _ := errs.ProblemOf(err)
if src != nil && dst != nil {
dst.Message = src.Message
dst.Hint = src.Hint
// A body without log_id must not erase a header-derived LogID
// already carried by err.
if src.LogID != "" {
dst.LogID = src.LogID
}
}
return err
}

func extractBaseErrorLogID(resultMap map[string]interface{}) string {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := resultMap[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)

Check warning on line 202 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L202

Added line #L202 was not covered by tests
}
}
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)

Check warning on line 208 in shortcuts/base/base_errors.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/base_errors.go#L206-L208

Added lines #L206 - L208 were not covered by tests
}
}
}
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := data["error"].(map[string]interface{}); ok {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
}
return ""
}

func extractErrorHint(resultMap map[string]interface{}) string {
Expand Down
Loading
Loading