Skip to content

Commit 0ded2f8

Browse files
committed
feat(contact): return typed error envelopes across contact shortcuts
1 parent 8c3cba1 commit 0ded2f8

10 files changed

Lines changed: 342 additions & 89 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,20 @@ linters:
7373
- forbidigo
7474
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
7575
# Add a path when its migration is complete.
76-
- 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/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/)
76+
- 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/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/)
7777
text: errs-typed-only
7878
linters:
7979
- forbidigo
8080
# errs-no-bare-wrap enforced on paths fully migrated to typed final
8181
# errors. Scoped separately from errs-typed-only because cmd/auth/,
8282
# cmd/config/ still have residual fmt.Errorf and must not be caught.
83-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
83+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
8484
text: errs-no-bare-wrap
8585
linters:
8686
- forbidigo
8787
# errs-no-legacy-helper enforced on domains whose shared validation/save
8888
# helpers have migrated to typed final errors.
89-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/)
89+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo

lint/errscontract/rule_no_legacy_common_helper_call.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
var migratedCommonHelperPaths = []string{
1818
"shortcuts/base/",
1919
"shortcuts/calendar/",
20+
"shortcuts/contact/",
2021
"shortcuts/drive/",
2122
"shortcuts/mail/",
2223
"shortcuts/okr/",

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
var migratedEnvelopePaths = []string{
1919
"shortcuts/base/",
2020
"shortcuts/calendar/",
21+
"shortcuts/contact/",
2122
"shortcuts/drive/",
2223
"shortcuts/mail/",
2324
"shortcuts/okr/",
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package contact
5+
6+
import (
7+
"fmt"
8+
"net/http"
9+
"strings"
10+
11+
"github.com/larksuite/cli/errs"
12+
)
13+
14+
const contactFanoutRetryHint = "retry the command; if it persists, narrow --queries to a single term to isolate the failing input"
15+
16+
func contactInvalidResponseError(format string, args ...any) *errs.InternalError {
17+
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
18+
}
19+
20+
func contactFanoutErrorSummary(err error) string {
21+
if p, ok := errs.ProblemOf(err); ok {
22+
if p.Code >= 100 && p.Code < 600 {
23+
prefix := fmt.Sprintf("HTTP %d:", p.Code)
24+
body := strings.TrimSpace(strings.TrimPrefix(p.Message, prefix))
25+
msg := fmt.Sprintf("HTTP %d %s", p.Code, http.StatusText(p.Code))
26+
if body != "" {
27+
msg = fmt.Sprintf("%s: %s", msg, contactTruncateError(body, 200))
28+
}
29+
return msg
30+
}
31+
if p.Code != 0 {
32+
return fmt.Sprintf("API %d: %s", p.Code, p.Message)
33+
}
34+
return p.Message
35+
}
36+
return err.Error()
37+
}
38+
39+
func contactFanoutAllFailedError(err error, msg string) error {
40+
if p, ok := errs.ProblemOf(err); ok {
41+
// ProblemOf returns the embedded Problem pointer, so this preserves the
42+
// concrete typed error, cause chain, and category-specific metadata.
43+
p.Message = msg
44+
return err
45+
}
46+
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithHint(contactFanoutRetryHint).WithCause(err)
47+
}
48+
49+
func contactTruncateError(s string, maxRunes int) string {
50+
r := []rune(s)
51+
if len(r) <= maxRunes {
52+
return s
53+
}
54+
return string(r[:maxRunes]) + "..."
55+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package contact
5+
6+
import (
7+
"strings"
8+
"testing"
9+
10+
"github.com/larksuite/cli/errs"
11+
)
12+
13+
func TestContactFanoutErrorSummary_HTTPStatus(t *testing.T) {
14+
err := errs.NewNetworkError(errs.SubtypeNetworkServer, `HTTP 503: {"reason":"upstream_unavailable"}`).
15+
WithCode(503).
16+
WithRetryable()
17+
18+
got := contactFanoutErrorSummary(err)
19+
if !strings.HasPrefix(got, "HTTP 503 Service Unavailable: ") {
20+
t.Fatalf("summary: got %q", got)
21+
}
22+
if !strings.Contains(got, "upstream_unavailable") {
23+
t.Fatalf("summary should include truncated body details, got %q", got)
24+
}
25+
}
26+
27+
func TestContactFanoutAllFailedError_PreservesTypedProblem(t *testing.T) {
28+
err := errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").
29+
WithCode(99991663).
30+
WithLogID("log-contact-1").
31+
WithRetryable()
32+
33+
got := contactFanoutAllFailedError(err, "all 2 queries failed; first: API 99991663: rate limit (query=\"alice\")")
34+
p, ok := errs.ProblemOf(got)
35+
if !ok {
36+
t.Fatalf("expected typed problem, got %T", got)
37+
}
38+
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeRateLimit {
39+
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
40+
}
41+
if p.Code != 99991663 || p.LogID != "log-contact-1" || !p.Retryable {
42+
t.Fatalf("problem metadata not preserved: %+v", p)
43+
}
44+
if !strings.Contains(p.Message, "all 2 queries failed") {
45+
t.Fatalf("problem message not decorated: %q", p.Message)
46+
}
47+
}
48+
49+
func TestContactFanoutAllFailedError_UntypedGetsActionableHint(t *testing.T) {
50+
got := contactFanoutAllFailedError(nil, "all 2 queries failed; first: internal error (query=\"alice\")")
51+
p, ok := errs.ProblemOf(got)
52+
if !ok {
53+
t.Fatalf("expected typed problem, got %T", got)
54+
}
55+
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeUnknown {
56+
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
57+
}
58+
if !strings.Contains(p.Hint, "narrow --queries") {
59+
t.Fatalf("hint should guide recovery, got %q", p.Hint)
60+
}
61+
}

shortcuts/contact/contact_get_user.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ var ContactGetUser = common.Shortcut{
2828
},
2929
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
3030
if runtime.Str("user-id") == "" && runtime.IsBot() {
31-
return common.FlagErrorf("bot identity cannot get current user info, specify --user-id")
31+
return common.ValidationErrorf("bot identity cannot get current user info, specify --user-id").
32+
WithParam("--user-id")
3233
}
3334
return nil
3435
},
@@ -63,7 +64,7 @@ var ContactGetUser = common.Shortcut{
6364

6465
if userId == "" {
6566
// Current user
66-
data, err := runtime.CallAPI("GET", "/open-apis/authen/v1/user_info", nil, nil)
67+
data, err := runtime.CallAPITyped("GET", "/open-apis/authen/v1/user_info", nil, nil)
6768
if err != nil {
6869
return err
6970
}
@@ -87,7 +88,7 @@ var ContactGetUser = common.Shortcut{
8788

8889
if runtime.IsBot() {
8990
// Bot identity: GET /contact/v3/users/:user_id (full profile)
90-
data, err := runtime.CallAPI("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
91+
data, err := runtime.CallAPITyped("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
9192
map[string]interface{}{"user_id_type": userIdType}, nil)
9293
if err != nil {
9394
return err
@@ -110,7 +111,7 @@ var ContactGetUser = common.Shortcut{
110111
}
111112

112113
// User identity: POST /contact/v3/users/basic_batch (lightweight)
113-
data, err := runtime.CallAPI("POST", "/open-apis/contact/v3/users/basic_batch",
114+
data, err := runtime.CallAPITyped("POST", "/open-apis/contact/v3/users/basic_batch",
114115
map[string]interface{}{"user_id_type": userIdType},
115116
map[string]interface{}{"user_ids": []string{userId}})
116117
if err != nil {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package contact
5+
6+
import (
7+
"bytes"
8+
"errors"
9+
"testing"
10+
11+
"github.com/larksuite/cli/errs"
12+
"github.com/larksuite/cli/internal/cmdutil"
13+
"github.com/larksuite/cli/internal/httpmock"
14+
)
15+
16+
func TestGetUser_BotCurrentUserValidationTyped(t *testing.T) {
17+
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
18+
19+
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--as", "bot"}, f, stdout)
20+
if err == nil {
21+
t.Fatalf("expected validation error")
22+
}
23+
var validation *errs.ValidationError
24+
if !errors.As(err, &validation) {
25+
t.Fatalf("expected validation error, got %T: %v", err, err)
26+
}
27+
if validation.Param != "--user-id" {
28+
t.Fatalf("param: got %q, want --user-id", validation.Param)
29+
}
30+
}
31+
32+
func TestGetUser_CurrentUserAPIFailureTyped(t *testing.T) {
33+
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
34+
reg.Register(&httpmock.Stub{
35+
Method: "GET",
36+
URL: "/open-apis/authen/v1/user_info",
37+
Body: map[string]interface{}{"code": 123456, "msg": "upstream rejected contact request"},
38+
})
39+
40+
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--as", "user"}, f, stdout)
41+
if err == nil {
42+
t.Fatalf("expected API error")
43+
}
44+
p, ok := errs.ProblemOf(err)
45+
if !ok {
46+
t.Fatalf("expected typed problem, got %T: %v", err, err)
47+
}
48+
if p.Code != 123456 {
49+
t.Fatalf("code: got %d, want 123456", p.Code)
50+
}
51+
if p.Category != errs.CategoryAPI {
52+
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryAPI)
53+
}
54+
if stdout.Len() != 0 {
55+
t.Fatalf("stdout should stay empty on API failure, got %q", stdout.String())
56+
}
57+
}
58+
59+
func TestGetUser_UserBasicBatchUsesTypedAPI(t *testing.T) {
60+
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
61+
stub := &httpmock.Stub{
62+
Method: "POST",
63+
URL: "/open-apis/contact/v3/users/basic_batch?user_id_type=open_id",
64+
Body: map[string]interface{}{
65+
"code": 0,
66+
"msg": "ok",
67+
"data": map[string]interface{}{
68+
"users": []interface{}{
69+
map[string]interface{}{"user_id": "ou_a", "name": "Alice"},
70+
},
71+
},
72+
},
73+
}
74+
reg.Register(stub)
75+
76+
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--user-id", "ou_a", "--as", "user", "--format", "json"}, f, stdout)
77+
if err != nil {
78+
t.Fatalf("execute: %v", err)
79+
}
80+
if !bytes.Contains(stub.CapturedBody, []byte(`"ou_a"`)) {
81+
t.Fatalf("request body should include user id, got %s", string(stub.CapturedBody))
82+
}
83+
if !bytes.Contains(stdout.Bytes(), []byte(`"user"`)) {
84+
t.Fatalf("stdout should include user object, got %s", stdout.String())
85+
}
86+
}

0 commit comments

Comments
 (0)