Skip to content

Commit 5bb73f6

Browse files
committed
feat(im): return typed error envelopes across the im domain
1 parent 8c3cba1 commit 5bb73f6

34 files changed

Lines changed: 605 additions & 284 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/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
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/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|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/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo

cmd/root_integration_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
377377
OK: false,
378378
Identity: "bot",
379379
Error: &output.ErrDetail{
380-
Type: "api_error",
380+
Type: "api",
381381
Code: 230002,
382-
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
382+
Message: "Bot/User can NOT be out of the chat.",
383383
},
384384
})
385385
}

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var migratedEnvelopePaths = []string{
2323
"shortcuts/okr/",
2424
"shortcuts/task/",
2525
"shortcuts/whiteboard/",
26+
"shortcuts/im/",
2627
}
2728

2829
// legacyOutputImportPath is the import path of the package that declares the

shortcuts/common/call_api_typed_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/spf13/cobra"
1313

1414
"github.com/larksuite/cli/errs"
15+
"github.com/larksuite/cli/internal/client"
1516
"github.com/larksuite/cli/internal/cmdutil"
1617
"github.com/larksuite/cli/internal/core"
1718
"github.com/larksuite/cli/internal/httpmock"
@@ -198,3 +199,58 @@ func TestCallAPITyped_NonObjectJSON(t *testing.T) {
198199
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
199200
}
200201
}
202+
203+
// TestDoAPIJSONTyped_Success returns the data object on code 0, confirming the
204+
// typed DoAPIJSON replacement preserves the success contract of DoAPIJSON.
205+
func TestDoAPIJSONTyped_Success(t *testing.T) {
206+
rt, reg := newCallAPITypedRuntime(t)
207+
reg.Register(&httpmock.Stub{
208+
Method: "GET",
209+
URL: "/open-apis/x/z",
210+
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"id": "z1"}},
211+
})
212+
213+
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
214+
if err != nil {
215+
t.Fatalf("unexpected error: %v", err)
216+
}
217+
if data["id"] != "z1" {
218+
t.Errorf("data[id] = %v, want z1", data["id"])
219+
}
220+
}
221+
222+
func TestDoAPIJSONTyped_RawClientErrorBecomesTypedInternal(t *testing.T) {
223+
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, &core.CliConfig{}, nil, core.AsUser)
224+
rt.apiClientFunc = func() (*client.APIClient, error) {
225+
return nil, errors.New("raw client construction error")
226+
}
227+
228+
_, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
229+
var internalErr *errs.InternalError
230+
if !errors.As(err, &internalErr) {
231+
t.Fatalf("expected raw client errors to be lifted to typed internal errors, got %T: %v", err, err)
232+
}
233+
if internalErr.Subtype != errs.SubtypeUnknown {
234+
t.Errorf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeUnknown)
235+
}
236+
}
237+
238+
// TestDoAPIJSONTyped_NonZeroCode classifies a non-zero API code into a typed
239+
// errs.* error (carrying log_id), never a legacy output.ExitError envelope.
240+
func TestDoAPIJSONTyped_NonZeroCode(t *testing.T) {
241+
rt, reg := newCallAPITypedRuntime(t)
242+
reg.Register(&httpmock.Stub{
243+
Method: "POST",
244+
URL: "/open-apis/x/z",
245+
Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "lz"},
246+
})
247+
248+
_, err := rt.DoAPIJSONTyped("POST", "/open-apis/x/z", nil, map[string]any{})
249+
p, ok := errs.ProblemOf(err)
250+
if !ok {
251+
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
252+
}
253+
if p.LogID != "lz" {
254+
t.Errorf("LogID = %q, want lz", p.LogID)
255+
}
256+
}

shortcuts/common/runner.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,28 @@ func (ctx *RuntimeContext) DoAPIJSONWithLogID(method, apiPath string, query lark
492492
return ctx.doAPIJSON(method, apiPath, query, body, true)
493493
}
494494

495+
// DoAPIJSONTyped is the typed-only replacement for DoAPIJSON: it issues the same
496+
// larkcore.ApiReq request (identical method / path / query / body model) but
497+
// classifies failures into typed errs.* errors via ClassifyAPIResponse instead
498+
// of emitting a legacy output.ExitError "api_error" envelope. A transport / auth
499+
// error from the client boundary is already typed and passes through unchanged;
500+
// a non-zero API code is classified with subtype / code / log_id.
501+
func (ctx *RuntimeContext) DoAPIJSONTyped(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) {
502+
req := &larkcore.ApiReq{
503+
HttpMethod: method,
504+
ApiPath: apiPath,
505+
QueryParams: query,
506+
}
507+
if body != nil {
508+
req.Body = body
509+
}
510+
resp, err := ctx.DoAPI(req)
511+
if err != nil {
512+
return nil, typedOrInternal(err)
513+
}
514+
return ctx.ClassifyAPIResponse(resp)
515+
}
516+
495517
func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.QueryParams, body any, includeLogID bool) (map[string]any, error) {
496518
req := &larkcore.ApiReq{
497519
HttpMethod: method,
@@ -682,6 +704,9 @@ func WrapSaveErrorTyped(err error) error {
682704
if err == nil {
683705
return nil
684706
}
707+
if _, ok := errs.ProblemOf(err); ok {
708+
return err
709+
}
685710
var me *fileio.MkdirError
686711
switch {
687712
case errors.Is(err, fileio.ErrPathValidation):

shortcuts/common/validate_ids.go

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,6 @@ import (
99
"github.com/larksuite/cli/internal/output"
1010
)
1111

12-
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
13-
// Also extracts token from URL if provided.
14-
//
15-
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
16-
func ValidateChatID(input string) (string, error) {
17-
chatID, msg := normalizeChatID(input)
18-
if msg != "" {
19-
return "", output.ErrValidation("%s", msg)
20-
}
21-
return chatID, nil
22-
}
23-
2412
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
2513
// Also extracts token from URL if provided. param names the flag being
2614
// validated (e.g. "--chat-ids") and is recorded on the typed error.

shortcuts/common/validate_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,21 @@ func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
194194
}
195195
}
196196

197+
func TestWrapSaveErrorTyped_PreservesTypedWriteCause(t *testing.T) {
198+
typed := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500: chunk failed").
199+
WithCode(500)
200+
err := WrapSaveErrorTyped(&fileio.WriteError{Err: typed})
201+
202+
p, ok := errs.ProblemOf(err)
203+
if !ok {
204+
t.Fatalf("expected typed problem, got %T: %v", err, err)
205+
}
206+
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer || p.Code != 500 {
207+
t.Fatalf("problem = category %q subtype %q code %d, want network/%s/500",
208+
p.Category, p.Subtype, p.Code, errs.SubtypeNetworkServer)
209+
}
210+
}
211+
197212
func TestAtLeastOne(t *testing.T) {
198213
tests := []struct {
199214
name string

shortcuts/im/convert_lib/helpers.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []str
162162
}
163163
batch := missingIDs[i:end]
164164

165-
data, err := runtime.DoAPIJSON(http.MethodPost,
165+
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
166166
"/open-apis/contact/v3/users/basic_batch",
167167
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
168168
map[string]interface{}{"user_ids": batch},
@@ -198,7 +198,7 @@ func batchResolveUsers(runtime *common.RuntimeContext, missingIDs []string, name
198198
}
199199
apiURL := "/open-apis/contact/v3/users/batch?" + strings.Join(parts, "&")
200200

201-
data, err := runtime.DoAPIJSON(http.MethodGet, apiURL, nil, nil)
201+
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiURL, nil, nil)
202202
if err != nil {
203203
break
204204
}

shortcuts/im/convert_lib/merge.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,20 +200,20 @@ func batchResolveMergeForwardSenders(runtime *common.RuntimeContext, prefetch ma
200200
// container via a single API call. Returns a flat list of raw message items
201201
// with upper_message_id for tree reconstruction.
202202
//
203-
// Uses DoAPIJSON so the response envelope's code/msg are checked and surfaced
203+
// Uses DoAPIJSONTyped so the response envelope's code/msg are checked and surfaced
204204
// — earlier this used the low-level DoAPI and reported every non-zero code
205205
// as a generic "empty data" error, hiding the real failure (e.g. a server
206206
// "code: 2200 Internal Error" with its log_id would show up as just "empty
207207
// data" in the output).
208208
func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeContext) ([]map[string]interface{}, error) {
209-
data, err := runtime.DoAPIJSON(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
209+
data, err := runtime.DoAPIJSONTyped(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
210210
"user_id_type": []string{"open_id"},
211211
"card_msg_content_type": []string{"raw_card_content"},
212212
}, nil)
213213
if err != nil {
214214
return nil, err
215215
}
216-
// DoAPIJSON returns the envelope's `data` field; when the server's JSON
216+
// DoAPIJSONTyped returns the envelope's `data` field; when the server's JSON
217217
// has `code: 0` but omits `data` entirely, that field comes back as nil.
218218
// Reading from a nil map in Go is safe (returns the zero value, never
219219
// panics), but guarding explicitly makes the "successful empty

shortcuts/im/convert_lib/reactions.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
156156
queries = append(queries, map[string]interface{}{"message_id": id})
157157
}
158158

159-
data, err := runtime.DoAPIJSON(http.MethodPost,
159+
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
160160
"/open-apis/im/v1/messages/reactions/batch_query",
161161
nil,
162162
map[string]interface{}{"queries": queries},

0 commit comments

Comments
 (0)