Skip to content

Commit b74056b

Browse files
committed
feat(contact): emit typed error envelopes across the contact domain
1 parent 076f4d5 commit b74056b

12 files changed

Lines changed: 480 additions & 111 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/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|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/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|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/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
83+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|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/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
89+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|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/minutes/",

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/minutes/",

lint/errscontract/rules_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,7 @@ func boom() error {
691691
return &output.ExitError{Code: 1}
692692
}
693693
`
694-
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
694+
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
695695
if len(v) != 0 {
696696
t.Errorf("non-migrated path should pass, got: %+v", v)
697697
}
@@ -907,7 +907,7 @@ func boom(runtime *common.RuntimeContext) error {
907907
return err
908908
}
909909
`
910-
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
910+
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
911911
if len(v) != 0 {
912912
t.Errorf("non-migrated path must not fire, got: %+v", v)
913913
}
@@ -1006,7 +1006,7 @@ func boom() {
10061006
common.FlagErrorf("legacy allowed until domain migrates")
10071007
}
10081008
`
1009-
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
1009+
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
10101010
if len(v) != 0 {
10111011
t.Errorf("non-migrated path must pass, got: %+v", v)
10121012
}

shortcuts/common/validate_ids.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ package common
55

66
import (
77
"strings"
8-
9-
"github.com/larksuite/cli/internal/output"
108
)
119

1210
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
@@ -42,17 +40,6 @@ func normalizeChatID(input string) (string, string) {
4240
return input, ""
4341
}
4442

45-
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
46-
//
47-
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
48-
func ValidateUserID(input string) (string, error) {
49-
userID, msg := normalizeUserID(input)
50-
if msg != "" {
51-
return "", output.ErrValidation("%s", msg)
52-
}
53-
return userID, nil
54-
}
55-
5643
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
5744
// param names the flag being validated (e.g. "--creator-ids") and is
5845
// recorded on the typed error.
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: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 TestContactInvalidResponseError_TypedInternal(t *testing.T) {
28+
got := contactInvalidResponseError("decode contact response failed")
29+
p, ok := errs.ProblemOf(got)
30+
if !ok {
31+
t.Fatalf("expected typed problem, got %T", got)
32+
}
33+
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
34+
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
35+
}
36+
}
37+
38+
func TestContactFanoutAllFailedError_PreservesTypedProblem(t *testing.T) {
39+
err := errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").
40+
WithCode(99991663).
41+
WithLogID("log-contact-1").
42+
WithRetryable()
43+
44+
got := contactFanoutAllFailedError(err, "all 2 queries failed; first: API 99991663: rate limit (query=\"alice\")")
45+
p, ok := errs.ProblemOf(got)
46+
if !ok {
47+
t.Fatalf("expected typed problem, got %T", got)
48+
}
49+
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeRateLimit {
50+
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
51+
}
52+
if p.Code != 99991663 || p.LogID != "log-contact-1" || !p.Retryable {
53+
t.Fatalf("problem metadata not preserved: %+v", p)
54+
}
55+
if !strings.Contains(p.Message, "all 2 queries failed") {
56+
t.Fatalf("problem message not decorated: %q", p.Message)
57+
}
58+
}
59+
60+
func TestContactFanoutAllFailedError_UntypedGetsActionableHint(t *testing.T) {
61+
got := contactFanoutAllFailedError(nil, "all 2 queries failed; first: internal error (query=\"alice\")")
62+
p, ok := errs.ProblemOf(got)
63+
if !ok {
64+
t.Fatalf("expected typed problem, got %T", got)
65+
}
66+
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeUnknown {
67+
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
68+
}
69+
if !strings.Contains(p.Hint, "narrow --queries") {
70+
t.Fatalf("hint should guide recovery, got %q", p.Hint)
71+
}
72+
}

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: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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_DryRunShapes(t *testing.T) {
33+
cases := []struct {
34+
name string
35+
args []string
36+
want []string
37+
}{
38+
{
39+
name: "current user",
40+
args: []string{"+get-user", "--dry-run", "--as", "user"},
41+
want: []string{"GET", "/authen/v1/user_info", "current_user"},
42+
},
43+
{
44+
name: "bot specific user",
45+
args: []string{"+get-user", "--user-id", "ou_a", "--dry-run", "--as", "bot"},
46+
want: []string{"GET", "/contact/v3/users/ou_a", "ou_a", "open_id"},
47+
},
48+
{
49+
name: "user basic batch",
50+
args: []string{"+get-user", "--user-id", "ou_a", "--dry-run", "--as", "user"},
51+
want: []string{"POST", "/contact/v3/users/basic_batch", "ou_a", "open_id"},
52+
},
53+
}
54+
55+
for _, tc := range cases {
56+
t.Run(tc.name, func(t *testing.T) {
57+
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
58+
if err := mountAndRun(t, ContactGetUser, tc.args, f, stdout); err != nil {
59+
t.Fatalf("dry-run: %v", err)
60+
}
61+
out := stdout.String()
62+
for _, want := range tc.want {
63+
if !bytes.Contains(stdout.Bytes(), []byte(want)) {
64+
t.Fatalf("dry-run output missing %q: %s", want, out)
65+
}
66+
}
67+
})
68+
}
69+
}
70+
71+
func TestGetUser_CurrentUserAPIFailureTyped(t *testing.T) {
72+
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
73+
reg.Register(&httpmock.Stub{
74+
Method: "GET",
75+
URL: "/open-apis/authen/v1/user_info",
76+
Body: map[string]interface{}{"code": 123456, "msg": "upstream rejected contact request"},
77+
})
78+
79+
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--as", "user"}, f, stdout)
80+
if err == nil {
81+
t.Fatalf("expected API error")
82+
}
83+
p, ok := errs.ProblemOf(err)
84+
if !ok {
85+
t.Fatalf("expected typed problem, got %T: %v", err, err)
86+
}
87+
if p.Code != 123456 {
88+
t.Fatalf("code: got %d, want 123456", p.Code)
89+
}
90+
if p.Category != errs.CategoryAPI {
91+
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryAPI)
92+
}
93+
if stdout.Len() != 0 {
94+
t.Fatalf("stdout should stay empty on API failure, got %q", stdout.String())
95+
}
96+
}
97+
98+
func TestGetUser_UserBasicBatchUsesTypedAPI(t *testing.T) {
99+
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
100+
stub := &httpmock.Stub{
101+
Method: "POST",
102+
URL: "/open-apis/contact/v3/users/basic_batch?user_id_type=open_id",
103+
Body: map[string]interface{}{
104+
"code": 0,
105+
"msg": "ok",
106+
"data": map[string]interface{}{
107+
"users": []interface{}{
108+
map[string]interface{}{"user_id": "ou_a", "name": "Alice"},
109+
},
110+
},
111+
},
112+
}
113+
reg.Register(stub)
114+
115+
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--user-id", "ou_a", "--as", "user", "--format", "json"}, f, stdout)
116+
if err != nil {
117+
t.Fatalf("execute: %v", err)
118+
}
119+
if !bytes.Contains(stub.CapturedBody, []byte(`"ou_a"`)) {
120+
t.Fatalf("request body should include user id, got %s", string(stub.CapturedBody))
121+
}
122+
if !bytes.Contains(stdout.Bytes(), []byte(`"user"`)) {
123+
t.Fatalf("stdout should include user object, got %s", stdout.String())
124+
}
125+
}

0 commit comments

Comments
 (0)