Skip to content

Commit e453130

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

10 files changed

Lines changed: 251 additions & 25 deletions

File tree

.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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
func eventValidationParamErrorWithCause(param string, err error, format string, args ...any) *errs.ValidationError {
17+
return eventValidationParamError(param, format, args...).WithCause(err)
18+
}
19+
20+
func eventFileIOError(format string, err error, args ...any) *errs.InternalError {
21+
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
22+
}
23+
24+
func eventNetworkError(format string, err error, args ...any) *errs.NetworkError {
25+
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
26+
}

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("create output dir: %s", err, err)
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("create route dir %s: %s", err, route.dir, err)
7373
}
7474
}
7575
}

shortcuts/event/processor_test.go

Lines changed: 149 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,72 @@ 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+
fileErr := eventFileIOError("write failed: %s", cause, cause)
80+
requireProblem(t, fileErr, errs.CategoryInternal, errs.SubtypeFileIO, "")
81+
if !errors.Is(fileErr, cause) {
82+
t.Fatal("file_io error should preserve its cause")
83+
}
84+
85+
networkErr := eventNetworkError("websocket failed: %s", cause, cause)
86+
requireProblem(t, networkErr, errs.CategoryNetwork, errs.SubtypeNetworkTransport, "")
87+
if !errors.Is(networkErr, cause) {
88+
t.Fatal("network error should preserve its cause")
89+
}
90+
}
91+
92+
func newSubscribeTestRuntime(t *testing.T) *common.RuntimeContext {
93+
t.Helper()
94+
95+
var out, errOut bytes.Buffer
96+
cmd := &cobra.Command{Use: "+subscribe"}
97+
cmd.Flags().String("event-types", "", "")
98+
cmd.Flags().String("filter", "", "")
99+
cmd.Flags().Bool("json", false, "")
100+
cmd.Flags().Bool("compact", false, "")
101+
cmd.Flags().String("output-dir", "", "")
102+
cmd.Flags().Bool("quiet", false, "")
103+
cmd.Flags().StringArray("route", nil, "")
104+
cmd.Flags().Bool("force", false, "")
105+
106+
return &common.RuntimeContext{
107+
Cmd: cmd,
108+
Config: &core.CliConfig{
109+
AppID: "cli_event_test",
110+
AppSecret: "secret",
111+
Brand: core.BrandFeishu,
112+
},
113+
Factory: &cmdutil.Factory{
114+
IOStreams: cmdutil.NewIOStreams(strings.NewReader(""), &out, &errOut),
115+
},
116+
}
117+
}
118+
47119
// --- Registry ---
48120

49121
func TestRegistryLookup(t *testing.T) {
@@ -63,9 +135,11 @@ func TestRegistryDuplicateReturnsError(t *testing.T) {
63135
if err := r.Register(&ImMessageProcessor{}); err != nil {
64136
t.Fatalf("first register should succeed: %v", err)
65137
}
66-
if err := r.Register(&ImMessageProcessor{}); err == nil {
138+
err := r.Register(&ImMessageProcessor{})
139+
if err == nil {
67140
t.Error("expected error on duplicate registration")
68141
}
142+
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeUnknown, "")
69143
}
70144

71145
// --- Filters ---
@@ -106,6 +180,54 @@ func TestRegexFilter_Invalid(t *testing.T) {
106180
}
107181
}
108182

183+
func TestEventSubscribeExecuteRejectsUnsafeOutputDir(t *testing.T) {
184+
rt := newSubscribeTestRuntime(t)
185+
if err := rt.Cmd.Flags().Set("output-dir", "/tmp/events"); err != nil {
186+
t.Fatal(err)
187+
}
188+
err := EventSubscribe.Execute(context.Background(), rt)
189+
if err == nil {
190+
t.Fatal("expected unsafe output-dir error")
191+
}
192+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--output-dir")
193+
if errors.Unwrap(err) == nil {
194+
t.Fatal("unsafe output-dir error should preserve its cause")
195+
}
196+
}
197+
198+
func TestEventSubscribeExecuteRejectsInvalidFilter(t *testing.T) {
199+
rt := newSubscribeTestRuntime(t)
200+
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
201+
t.Fatal(err)
202+
}
203+
if err := rt.Cmd.Flags().Set("filter", "[invalid"); err != nil {
204+
t.Fatal(err)
205+
}
206+
err := EventSubscribe.Execute(context.Background(), rt)
207+
if err == nil {
208+
t.Fatal("expected invalid filter error")
209+
}
210+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--filter")
211+
if errors.Unwrap(err) == nil {
212+
t.Fatal("invalid filter error should preserve its cause")
213+
}
214+
}
215+
216+
func TestEventSubscribeExecuteRejectsInvalidRoute(t *testing.T) {
217+
rt := newSubscribeTestRuntime(t)
218+
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
219+
t.Fatal(err)
220+
}
221+
if err := rt.Cmd.Flags().Set("route", "no-equals-sign"); err != nil {
222+
t.Fatal(err)
223+
}
224+
err := EventSubscribe.Execute(context.Background(), rt)
225+
if err == nil {
226+
t.Fatal("expected invalid route error")
227+
}
228+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
229+
}
230+
109231
func TestFilterChain(t *testing.T) {
110232
etf := NewEventTypeFilter("im.message.receive_v1, drive.file.edit_v1")
111233
rf, _ := NewRegexFilter("im\\..*")
@@ -339,6 +461,23 @@ func TestPipeline_OutputDir(t *testing.T) {
339461
}
340462
}
341463

464+
func TestPipeline_EnsureDirsFileIOError(t *testing.T) {
465+
path := filepath.Join(t.TempDir(), "not-a-dir")
466+
if err := os.WriteFile(path, []byte("x"), 0600); err != nil {
467+
t.Fatal(err)
468+
}
469+
p := NewEventPipeline(DefaultRegistry(), NewFilterChain(),
470+
PipelineConfig{Mode: TransformCompact, OutputDir: filepath.Join(path, "child")}, io.Discard, io.Discard)
471+
err := p.EnsureDirs()
472+
if err == nil {
473+
t.Fatal("expected file_io error")
474+
}
475+
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeFileIO, "")
476+
if errors.Unwrap(err) == nil {
477+
t.Fatal("file_io error should preserve its cause")
478+
}
479+
}
480+
342481
// --- Pipeline: JsonFlag ---
343482

344483
func TestPipeline_JsonFlag(t *testing.T) {
@@ -608,20 +747,26 @@ func TestParseRoutes_MissingEquals(t *testing.T) {
608747
if err == nil {
609748
t.Error("expected error for missing =")
610749
}
750+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
611751
}
612752

613753
func TestParseRoutes_InvalidRegex(t *testing.T) {
614754
_, err := ParseRoutes([]string{"[invalid=dir:./foo/"})
615755
if err == nil {
616756
t.Error("expected error for invalid regex")
617757
}
758+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
759+
if errors.Unwrap(err) == nil {
760+
t.Fatal("invalid regex error should preserve its cause")
761+
}
618762
}
619763

620764
func TestParseRoutes_MissingPrefix(t *testing.T) {
621765
_, err := ParseRoutes([]string{`^im\.message=./messages/`})
622766
if err == nil {
623767
t.Error("expected error for missing dir: prefix")
624768
}
769+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
625770
if !strings.Contains(err.Error(), "dir:") {
626771
t.Errorf("error should mention dir: prefix, got: %v", err)
627772
}
@@ -632,20 +777,23 @@ func TestParseRoutes_EmptyPath(t *testing.T) {
632777
if err == nil {
633778
t.Error("expected error for empty path")
634779
}
780+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
635781
}
636782

637783
func TestParseRoutes_RejectsAbsolutePath(t *testing.T) {
638784
_, err := ParseRoutes([]string{`^test=dir:/tmp/evil`})
639785
if err == nil {
640786
t.Error("expected error for absolute path in route")
641787
}
788+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
642789
}
643790

644791
func TestParseRoutes_RejectsTraversal(t *testing.T) {
645792
_, err := ParseRoutes([]string{`^test=dir:../../etc/evil`})
646793
if err == nil {
647794
t.Error("expected error for path traversal in route")
648795
}
796+
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
649797
}
650798

651799
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("--route", err, "invalid regex in --route %q: %s", spec, err)
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("--route", err, "invalid --route %q: %s", spec, err)
5857
}
5958

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

0 commit comments

Comments
 (0)