Skip to content

Commit ae8417f

Browse files
committed
feat(base): emit typed error envelopes across the base domain
1 parent 33de28f commit ae8417f

36 files changed

Lines changed: 513 additions & 404 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,20 @@ linters:
6565
- forbidigo
6666
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
6767
# Add a path when its migration is complete.
68-
- 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/)
68+
- 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/base/)
6969
text: errs-typed-only
7070
linters:
7171
- forbidigo
7272
# errs-no-bare-wrap enforced on paths fully migrated to typed final
7373
# errors. Scoped separately from errs-typed-only because cmd/auth/,
7474
# cmd/config/ still have residual fmt.Errorf and must not be caught.
75-
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
75+
- path-except: (shortcuts/drive/|shortcuts/base/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
7676
text: errs-no-bare-wrap
7777
linters:
7878
- forbidigo
7979
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
8080
# still used by other domains until their later migration phase.
81-
- path-except: (shortcuts/drive/)
81+
- path-except: (shortcuts/drive/|shortcuts/base/)
8282
text: errs-no-legacy-helper
8383
linters:
8484
- forbidigo

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
// call sites must return a typed errs.* error instead. Future domains opt in by
1717
// appending their path prefix here.
1818
var migratedEnvelopePaths = []string{
19+
"shortcuts/base/",
1920
"shortcuts/drive/",
2021
}
2122

shortcuts/base/base_advperm_disable.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{
3131
},
3232
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
3333
if strings.TrimSpace(runtime.Str("base-token")) == "" {
34-
return common.FlagErrorf("--base-token must not be blank")
34+
return baseFlagErrorf("--base-token must not be blank")
3535
}
3636
return nil
3737
},
@@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
5555
return err
5656
}
5757

58-
return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed")
58+
return handleRoleAPIResponse(runtime, apiResp, "disable advanced permissions failed")
5959
},
6060
}

shortcuts/base/base_advperm_enable.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ var BaseAdvpermEnable = common.Shortcut{
3030
},
3131
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
3232
if strings.TrimSpace(runtime.Str("base-token")) == "" {
33-
return common.FlagErrorf("--base-token must not be blank")
33+
return baseFlagErrorf("--base-token must not be blank")
3434
}
3535
return nil
3636
},
@@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
5454
return err
5555
}
5656

57-
return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed")
57+
return handleRoleAPIResponse(runtime, apiResp, "enable advanced permissions failed")
5858
},
5959
}

shortcuts/base/base_advperm_test.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,7 @@ func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) {
196196
},
197197
})
198198
args := []string{"+advperm-enable", "--base-token", "app_x"}
199-
if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
200-
t.Fatalf("err=%v", err)
201-
}
199+
assertProblemCode(t, runShortcut(t, BaseAdvpermEnable, args, factory, stdout), 190001, "bad request")
202200
}
203201

204202
func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
@@ -226,7 +224,5 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
226224
},
227225
})
228226
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
229-
if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
230-
t.Fatalf("err=%v", err)
231-
}
227+
assertProblemCode(t, runShortcut(t, BaseAdvpermDisable, args, factory, stdout), 190002, "permission denied")
232228
}

shortcuts/base/base_data_query.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ var BaseDataQuery = common.Shortcut{
3232
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
3333
dec.UseNumber()
3434
if err := dec.Decode(&dsl); err != nil {
35-
return common.FlagErrorf("--dsl invalid JSON: %v", err)
35+
return baseFlagErrorf("--dsl invalid JSON: %v", err)
3636
}
3737
_, hasDim := dsl["dimensions"]
3838
_, hasMeas := dsl["measures"]
3939
if !hasDim && !hasMeas {
40-
return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
40+
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
4141
}
4242
return nil
4343
},

shortcuts/base/base_errors.go

Lines changed: 154 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
package base
55

66
import (
7+
"errors"
8+
"fmt"
79
"strings"
810

9-
"github.com/larksuite/cli/internal/output"
11+
"github.com/larksuite/cli/errs"
12+
"github.com/larksuite/cli/extension/fileio"
13+
"github.com/larksuite/cli/internal/errclass"
1014
"github.com/larksuite/cli/internal/util"
1115
)
1216

@@ -24,74 +28,182 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
2428
// structured ErrAPI, with server-provided message/hint promoted to the top level.
2529
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
2630
if err != nil {
27-
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
31+
return nil, baseAPIBoundaryError(err, action)
2832
}
2933

30-
resultMap, _ := result.(map[string]interface{})
34+
resultMap, ok := result.(map[string]interface{})
35+
if !ok || resultMap == nil {
36+
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API returned a malformed response envelope", action)
37+
}
38+
if _, exists := resultMap["code"]; !exists {
39+
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response is missing code", action)
40+
}
3141
code, _ := util.ToFloat64(resultMap["code"])
3242
if code == 0 {
3343
return resultMap["data"], nil
3444
}
3545

36-
larkCode := int(code)
37-
msg := extractDataErrorMessage(resultMap)
38-
if strings.TrimSpace(msg) == "" {
39-
msg, _ = resultMap["msg"].(string)
46+
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{})
47+
}
48+
49+
func baseFlagErrorf(format string, args ...any) error {
50+
msg := fmt.Sprintf(format, args...)
51+
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
52+
if param := firstFlagParam(msg); param != "" {
53+
err = err.WithParam(param)
54+
}
55+
if cause := firstErrorArg(args); cause != nil {
56+
err = err.WithCause(cause)
4057
}
58+
return err
59+
}
4160

42-
detail := extractErrorDetail(resultMap)
43-
apiErr := output.ErrAPI(larkCode, msg, detail)
44-
hint := extractErrorHint(resultMap)
45-
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
46-
apiErr.Detail.Hint = hint
61+
func baseValidationErrorf(format string, args ...any) error {
62+
msg := fmt.Sprintf(format, args...)
63+
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
64+
if param := firstFlagParam(msg); param != "" {
65+
err = err.WithParam(param)
4766
}
48-
if apiErr.Detail != nil {
49-
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
67+
if cause := firstErrorArg(args); cause != nil {
68+
err = err.WithCause(cause)
5069
}
51-
return nil, apiErr
70+
return err
5271
}
5372

54-
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
55-
detailMap, ok := detail.(map[string]interface{})
56-
if !ok {
57-
return nil
73+
func firstFlagParam(msg string) string {
74+
idx := strings.Index(msg, "--")
75+
if idx < 0 {
76+
return ""
77+
}
78+
end := idx + 2
79+
for end < len(msg) {
80+
ch := msg[end]
81+
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
82+
end++
83+
continue
84+
}
85+
break
5886
}
59-
for key, value := range detailMap {
60-
if value == nil {
61-
delete(detailMap, key)
87+
if end == idx+2 {
88+
return ""
89+
}
90+
return msg[idx:end]
91+
}
92+
93+
func firstErrorArg(args []any) error {
94+
for _, arg := range args {
95+
if err, ok := arg.(error); ok {
96+
return err
6297
}
6398
}
64-
if len(detailMap) == 0 {
99+
return nil
100+
}
101+
102+
func baseInputStatError(err error) error {
103+
if err == nil {
65104
return nil
66105
}
67-
return detailMap
106+
if errors.Is(err, fileio.ErrPathValidation) {
107+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
108+
}
109+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
68110
}
69111

70-
func extractErrorDetail(resultMap map[string]interface{}) interface{} {
71-
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
72-
return detail
112+
func baseSaveError(err error) error {
113+
if err == nil {
114+
return nil
73115
}
74-
data, _ := resultMap["data"].(map[string]interface{})
75-
if detail, ok := nonNilMapValue(data, "error"); ok {
76-
return detail
116+
var me *fileio.MkdirError
117+
switch {
118+
case errors.Is(err, fileio.ErrPathValidation):
119+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err)
120+
case errors.As(err, &me):
121+
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
122+
default:
123+
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
77124
}
78-
return nil
79125
}
80126

81-
func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
82-
if src == nil {
83-
return nil, false
127+
func baseAPIBoundaryError(err error, action string) error {
128+
if _, ok := errs.ProblemOf(err); ok {
129+
return err
84130
}
85-
value, ok := src[key]
86-
if !ok {
87-
return nil, false
131+
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "%s: %s", action, err).WithCause(err)
132+
}
133+
134+
func baseUploadAttachmentError(filePath string, err error) error {
135+
if p, ok := errs.ProblemOf(err); ok {
136+
p.Message = fmt.Sprintf("failed to upload attachment %s: %s", filePath, p.Message)
137+
return err
88138
}
89-
switch value.(type) {
90-
case nil:
91-
return nil, false
92-
default:
93-
return value, true
139+
return errs.NewInternalError(errs.SubtypeSDKError, "failed to upload attachment %s: %s", filePath, err).WithCause(err)
140+
}
141+
142+
func baseAPIErrorFromResult(resultMap map[string]interface{}, cc errclass.ClassifyContext) error {
143+
if resultMap == nil {
144+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a malformed response envelope")
145+
}
146+
if msg := extractDataErrorMessage(resultMap); msg != "" {
147+
resultMap["msg"] = msg
148+
}
149+
hint := extractErrorHint(resultMap)
150+
if logID := extractBaseErrorLogID(resultMap); logID != "" {
151+
resultMap["log_id"] = logID
152+
}
153+
err := errclass.BuildAPIError(resultMap, cc)
154+
if err == nil {
155+
return nil
156+
}
157+
if p, ok := errs.ProblemOf(err); ok && hint != "" {
158+
p.Hint = hint
159+
}
160+
return err
161+
}
162+
163+
func enrichBaseAPIErrorFromBody(err error, body []byte, cc errclass.ClassifyContext) error {
164+
if _, ok := errs.ProblemOf(err); !ok {
165+
return err
166+
}
167+
result, parseErr := decodeBaseV3Response(body)
168+
if parseErr != nil {
169+
return err
170+
}
171+
enriched := baseAPIErrorFromResult(result, cc)
172+
if enriched == nil {
173+
return err
174+
}
175+
src, _ := errs.ProblemOf(enriched)
176+
dst, _ := errs.ProblemOf(err)
177+
if src != nil && dst != nil {
178+
dst.Message = src.Message
179+
dst.Hint = src.Hint
180+
dst.LogID = src.LogID
181+
}
182+
return err
183+
}
184+
185+
func extractBaseErrorLogID(resultMap map[string]interface{}) string {
186+
for _, key := range []string{"log_id", "logid"} {
187+
if logID, _ := resultMap[key].(string); strings.TrimSpace(logID) != "" {
188+
return strings.TrimSpace(logID)
189+
}
190+
}
191+
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
192+
for _, key := range []string{"log_id", "logid"} {
193+
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
194+
return strings.TrimSpace(logID)
195+
}
196+
}
94197
}
198+
data, _ := resultMap["data"].(map[string]interface{})
199+
if detail, ok := data["error"].(map[string]interface{}); ok {
200+
for _, key := range []string{"log_id", "logid"} {
201+
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
202+
return strings.TrimSpace(logID)
203+
}
204+
}
205+
}
206+
return ""
95207
}
96208

97209
func extractErrorHint(resultMap map[string]interface{}) string {

0 commit comments

Comments
 (0)