Skip to content

Commit 2182b96

Browse files
committed
feat(event): emit typed error envelopes across the event domain
1 parent 8c3cba1 commit 2182b96

11 files changed

Lines changed: 319 additions & 25 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/event/|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/drive/|shortcuts/event/|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/drive/|shortcuts/event/|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
@@ -18,6 +18,7 @@ var migratedCommonHelperPaths = []string{
1818
"shortcuts/base/",
1919
"shortcuts/calendar/",
2020
"shortcuts/drive/",
21+
"shortcuts/event/",
2122
"shortcuts/mail/",
2223
"shortcuts/okr/",
2324
"shortcuts/task/",

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var migratedEnvelopePaths = []string{
1919
"shortcuts/base/",
2020
"shortcuts/calendar/",
2121
"shortcuts/drive/",
22+
"shortcuts/event/",
2223
"shortcuts/mail/",
2324
"shortcuts/okr/",
2425
"shortcuts/task/",

shortcuts/event/errors.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package event
5+
6+
import "github.com/larksuite/cli/errs"
7+
8+
func eventValidationError(format string, args ...any) *errs.ValidationError {
9+
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
10+
}
11+
12+
func eventValidationParamError(param, format string, args ...any) *errs.ValidationError {
13+
return eventValidationError(format, args...).WithParam(param)
14+
}
15+
16+
// eventValidationParamErrorWithCause appends ": <err>" to the formatted
17+
// message and preserves err as the unwrap cause.
18+
func eventValidationParamErrorWithCause(err error, param, format string, args ...any) *errs.ValidationError {
19+
return eventValidationParamError(param, format+": %s", append(args, err)...).WithCause(err)
20+
}
21+
22+
// eventFileIOError appends ": <err>" to the formatted message and preserves
23+
// err as the unwrap cause.
24+
func eventFileIOError(err error, format string, args ...any) *errs.InternalError {
25+
return errs.NewInternalError(errs.SubtypeFileIO, format+": %s", append(args, err)...).WithCause(err)
26+
}
27+
28+
// eventNetworkError appends ": <err>" to the formatted message and preserves
29+
// err as the unwrap cause.
30+
func eventNetworkError(err error, format string, args ...any) *errs.NetworkError {
31+
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format+": %s", append(args, err)...).WithCause(err)
32+
}

shortcuts/event/pipeline.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,13 @@ func NewEventPipeline(
6363
func (p *EventPipeline) EnsureDirs() error {
6464
if p.config.OutputDir != "" {
6565
if err := vfs.MkdirAll(p.config.OutputDir, 0700); err != nil {
66-
return fmt.Errorf("create output dir: %w", err)
66+
return eventFileIOError(err, "create output dir")
6767
}
6868
}
6969
if p.config.Router != nil {
7070
for _, route := range p.config.Router.routes {
7171
if err := vfs.MkdirAll(route.dir, 0700); err != nil {
72-
return fmt.Errorf("create route dir %s: %w", route.dir, err)
72+
return eventFileIOError(err, "create route dir %s", route.dir)
7373
}
7474
}
7575
}

shortcuts/event/processor_test.go

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"bytes"
88
"context"
99
"encoding/json"
10+
"errors"
1011
"io"
1112
"os"
1213
"path/filepath"
@@ -15,7 +16,12 @@ import (
1516
"testing"
1617
"time"
1718

19+
"github.com/larksuite/cli/errs"
20+
"github.com/larksuite/cli/internal/cmdutil"
21+
"github.com/larksuite/cli/internal/core"
22+
"github.com/larksuite/cli/shortcuts/common"
1823
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
24+
"github.com/spf13/cobra"
1925
)
2026

2127
// chdirTemp changes cwd to a fresh temp dir for the test duration.
@@ -44,6 +50,87 @@ func makeRawEvent(eventType string, eventJSON string) *RawEvent {
4450
}
4551
}
4652

53+
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, param string) {
54+
t.Helper()
55+
p, ok := errs.ProblemOf(err)
56+
if !ok {
57+
t.Fatalf("ProblemOf(%T) = false, error: %v", err, err)
58+
}
59+
if p.Category != category || p.Subtype != subtype {
60+
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
61+
}
62+
if param != "" {
63+
var ve *errs.ValidationError
64+
if !errors.As(err, &ve) {
65+
t.Fatalf("error %T is not *errs.ValidationError", err)
66+
}
67+
if ve.Param != param {
68+
t.Fatalf("Param = %q, want %q", ve.Param, param)
69+
}
70+
}
71+
}
72+
73+
func TestEventTypedErrorHelpers(t *testing.T) {
74+
cause := errors.New("cause")
75+
76+
validation := eventValidationError("bad input")
77+
requireProblem(t, validation, errs.CategoryValidation, errs.SubtypeInvalidArgument, "")
78+
79+
paramErr := eventValidationParamErrorWithCause(cause, "--flag", "bad %s value", "flag")
80+
requireProblem(t, paramErr, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--flag")
81+
if got := paramErr.Error(); got != "bad flag value: cause" {
82+
t.Fatalf("message = %q, want %q", got, "bad flag value: cause")
83+
}
84+
if !errors.Is(paramErr, cause) {
85+
t.Fatal("validation error should preserve its cause")
86+
}
87+
88+
fileErr := eventFileIOError(cause, "write failed")
89+
requireProblem(t, fileErr, errs.CategoryInternal, errs.SubtypeFileIO, "")
90+
if got := fileErr.Error(); got != "write failed: cause" {
91+
t.Fatalf("message = %q, want %q", got, "write failed: cause")
92+
}
93+
if !errors.Is(fileErr, cause) {
94+
t.Fatal("file_io error should preserve its cause")
95+
}
96+
97+
networkErr := eventNetworkError(cause, "websocket failed")
98+
requireProblem(t, networkErr, errs.CategoryNetwork, errs.SubtypeNetworkTransport, "")
99+
if got := networkErr.Error(); got != "websocket failed: cause" {
100+
t.Fatalf("message = %q, want %q", got, "websocket failed: cause")
101+
}
102+
if !errors.Is(networkErr, cause) {
103+
t.Fatal("network error should preserve its cause")
104+
}
105+
}
106+
107+
func newSubscribeTestRuntime(t *testing.T) *common.RuntimeContext {
108+
t.Helper()
109+
110+
var out, errOut bytes.Buffer
111+
cmd := &cobra.Command{Use: "+subscribe"}
112+
cmd.Flags().String("event-types", "", "")
113+
cmd.Flags().String("filter", "", "")
114+
cmd.Flags().Bool("json", false, "")
115+
cmd.Flags().Bool("compact", false, "")
116+
cmd.Flags().String("output-dir", "", "")
117+
cmd.Flags().Bool("quiet", false, "")
118+
cmd.Flags().StringArray("route", nil, "")
119+
cmd.Flags().Bool("force", false, "")
120+
121+
return &common.RuntimeContext{
122+
Cmd: cmd,
123+
Config: &core.CliConfig{
124+
AppID: "cli_event_test",
125+
AppSecret: "secret",
126+
Brand: core.BrandFeishu,
127+
},
128+
Factory: &cmdutil.Factory{
129+
IOStreams: cmdutil.NewIOStreams(strings.NewReader(""), &out, &errOut),
130+
},
131+
}
132+
}
133+
47134
// --- Registry ---
48135

49136
func TestRegistryLookup(t *testing.T) {
@@ -63,9 +150,11 @@ func TestRegistryDuplicateReturnsError(t *testing.T) {
63150
if err := r.Register(&ImMessageProcessor{}); err != nil {
64151
t.Fatalf("first register should succeed: %v", err)
65152
}
66-
if err := r.Register(&ImMessageProcessor{}); err == nil {
153+
err := r.Register(&ImMessageProcessor{})
154+
if err == nil {
67155
t.Error("expected error on duplicate registration")
68156
}
157+
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeUnknown, "")
69158
}
70159

71160
// --- Filters ---
@@ -106,6 +195,54 @@ func TestRegexFilter_Invalid(t *testing.T) {
106195
}
107196
}
108197

198+
func TestEventSubscribeExecuteRejectsUnsafeOutputDir(t *testing.T) {
199+
rt := newSubscribeTestRuntime(t)
200+
if err := rt.Cmd.Flags().Set("output-dir", "/tmp/events"); err != nil {
201+
t.Fatal(err)
202+
}
203+
err := EventSubscribe.Execute(context.Background(), rt)
204+
if err == nil {
205+
t.Fatal("expected unsafe output-dir error")
206+
}
207+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--output-dir")
208+
if errors.Unwrap(err) == nil {
209+
t.Fatal("unsafe output-dir error should preserve its cause")
210+
}
211+
}
212+
213+
func TestEventSubscribeExecuteRejectsInvalidFilter(t *testing.T) {
214+
rt := newSubscribeTestRuntime(t)
215+
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
216+
t.Fatal(err)
217+
}
218+
if err := rt.Cmd.Flags().Set("filter", "[invalid"); err != nil {
219+
t.Fatal(err)
220+
}
221+
err := EventSubscribe.Execute(context.Background(), rt)
222+
if err == nil {
223+
t.Fatal("expected invalid filter error")
224+
}
225+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--filter")
226+
if errors.Unwrap(err) == nil {
227+
t.Fatal("invalid filter error should preserve its cause")
228+
}
229+
}
230+
231+
func TestEventSubscribeExecuteRejectsInvalidRoute(t *testing.T) {
232+
rt := newSubscribeTestRuntime(t)
233+
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
234+
t.Fatal(err)
235+
}
236+
if err := rt.Cmd.Flags().Set("route", "no-equals-sign"); err != nil {
237+
t.Fatal(err)
238+
}
239+
err := EventSubscribe.Execute(context.Background(), rt)
240+
if err == nil {
241+
t.Fatal("expected invalid route error")
242+
}
243+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
244+
}
245+
109246
func TestFilterChain(t *testing.T) {
110247
etf := NewEventTypeFilter("im.message.receive_v1, drive.file.edit_v1")
111248
rf, _ := NewRegexFilter("im\\..*")
@@ -339,6 +476,23 @@ func TestPipeline_OutputDir(t *testing.T) {
339476
}
340477
}
341478

479+
func TestPipeline_EnsureDirsFileIOError(t *testing.T) {
480+
path := filepath.Join(t.TempDir(), "not-a-dir")
481+
if err := os.WriteFile(path, []byte("x"), 0600); err != nil {
482+
t.Fatal(err)
483+
}
484+
p := NewEventPipeline(DefaultRegistry(), NewFilterChain(),
485+
PipelineConfig{Mode: TransformCompact, OutputDir: filepath.Join(path, "child")}, io.Discard, io.Discard)
486+
err := p.EnsureDirs()
487+
if err == nil {
488+
t.Fatal("expected file_io error")
489+
}
490+
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeFileIO, "")
491+
if errors.Unwrap(err) == nil {
492+
t.Fatal("file_io error should preserve its cause")
493+
}
494+
}
495+
342496
// --- Pipeline: JsonFlag ---
343497

344498
func TestPipeline_JsonFlag(t *testing.T) {
@@ -608,20 +762,26 @@ func TestParseRoutes_MissingEquals(t *testing.T) {
608762
if err == nil {
609763
t.Error("expected error for missing =")
610764
}
765+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
611766
}
612767

613768
func TestParseRoutes_InvalidRegex(t *testing.T) {
614769
_, err := ParseRoutes([]string{"[invalid=dir:./foo/"})
615770
if err == nil {
616771
t.Error("expected error for invalid regex")
617772
}
773+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
774+
if errors.Unwrap(err) == nil {
775+
t.Fatal("invalid regex error should preserve its cause")
776+
}
618777
}
619778

620779
func TestParseRoutes_MissingPrefix(t *testing.T) {
621780
_, err := ParseRoutes([]string{`^im\.message=./messages/`})
622781
if err == nil {
623782
t.Error("expected error for missing dir: prefix")
624783
}
784+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
625785
if !strings.Contains(err.Error(), "dir:") {
626786
t.Errorf("error should mention dir: prefix, got: %v", err)
627787
}
@@ -632,20 +792,23 @@ func TestParseRoutes_EmptyPath(t *testing.T) {
632792
if err == nil {
633793
t.Error("expected error for empty path")
634794
}
795+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
635796
}
636797

637798
func TestParseRoutes_RejectsAbsolutePath(t *testing.T) {
638799
_, err := ParseRoutes([]string{`^test=dir:/tmp/evil`})
639800
if err == nil {
640801
t.Error("expected error for absolute path in route")
641802
}
803+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
642804
}
643805

644806
func TestParseRoutes_RejectsTraversal(t *testing.T) {
645807
_, err := ParseRoutes([]string{`^test=dir:../../etc/evil`})
646808
if err == nil {
647809
t.Error("expected error for path traversal in route")
648810
}
811+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
649812
}
650813

651814
func TestParseRoutes_PathSafety(t *testing.T) {

shortcuts/event/registry.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
package event
55

6-
import "fmt"
6+
import "github.com/larksuite/cli/errs"
77

88
// ProcessorRegistry manages event_type → EventProcessor mappings.
99
type ProcessorRegistry struct {
@@ -23,7 +23,7 @@ func NewProcessorRegistry(fallback EventProcessor) *ProcessorRegistry {
2323
func (r *ProcessorRegistry) Register(p EventProcessor) error {
2424
et := p.EventType()
2525
if _, exists := r.processors[et]; exists {
26-
return fmt.Errorf("duplicate event processor for: %s", et)
26+
return errs.NewInternalError(errs.SubtypeUnknown, "duplicate event processor for: %s", et)
2727
}
2828
r.processors[et] = p
2929
return nil

shortcuts/event/router.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package event
55

66
import (
7-
"fmt"
87
"regexp"
98
"strings"
109

@@ -34,27 +33,27 @@ func ParseRoutes(specs []string) (*EventRouter, error) {
3433
for _, spec := range specs {
3534
parts := strings.SplitN(spec, "=", 2)
3635
if len(parts) != 2 {
37-
return nil, fmt.Errorf("invalid route %q: expected format regex=dir:./path", spec)
36+
return nil, eventValidationParamError("--route", "invalid --route %q: expected format regex=dir:./path", spec)
3837
}
3938
pattern := parts[0]
4039
target := parts[1]
4140

4241
re, err := regexp.Compile(pattern)
4342
if err != nil {
44-
return nil, fmt.Errorf("invalid regex in route %q: %w", spec, err)
43+
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid regex in --route %q", spec)
4544
}
4645

4746
if !strings.HasPrefix(target, "dir:") {
48-
return nil, fmt.Errorf("invalid route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
47+
return nil, eventValidationParamError("--route", "invalid --route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
4948
}
5049
dir := strings.TrimPrefix(target, "dir:")
5150
if dir == "" {
52-
return nil, fmt.Errorf("invalid route %q: directory path is empty", spec)
51+
return nil, eventValidationParamError("--route", "invalid --route %q: directory path is empty", spec)
5352
}
5453

5554
safeDir, err := validate.SafeOutputPath(dir)
5655
if err != nil {
57-
return nil, fmt.Errorf("invalid route %q: %w", spec, err)
56+
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid --route %q", spec)
5857
}
5958

6059
routes = append(routes, Route{pattern: re, dir: safeDir})

0 commit comments

Comments
 (0)