diff --git a/README.md b/README.md index dcf99f9..574efe5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ Package `goerr` provides more contextual error handling in Go. - Stack traces - Compatible with `github.com/pkg/errors`. - Structured stack traces with `goerr.Stack` is available. -- Contextual variables to errors using `With(key, value)` and `WithTags(tags ...Tag)`. +- Contextual variables to errors using: + - Key value data by `goerr.Value(key, value)` (or `goerr.V(key, value)` as alias). + - Tag value data can be defined by `goerr.NewTag` and set into error by `goerr.Tag(tag)` (or `goerr.T(tag)` as alias). - `errors.Is` to identify errors and `errors.As` to unwrap errors. - `slog.LogValuer` interface to output structured logs with `slog`. @@ -86,9 +88,7 @@ if err := someAction("no_such_file.txt"); err != nil { ### Add/Extract contextual variables -#### Key-Value pairs - -`goerr` provides the `With(key, value)` method to add contextual variables to errors. The standard way to handle errors in Go is by injecting values into error messages. However, this approach makes it difficult to aggregate various errors. On the other hand, `goerr`'s `With` method allows for adding contextual information to errors without changing error message, making it easier to aggregate error logs. Additionally, error handling services like Sentry.io can handle errors more accurately with this feature. +`goerr` provides the `Value(key, value)` method to add contextual variables to errors. The standard way to handle errors in Go is by injecting values into error messages. However, this approach makes it difficult to aggregate various errors. On the other hand, `goerr`'s `Value` method allows for adding contextual information to errors without changing error message, making it easier to aggregate error logs. Additionally, error handling services like Sentry.io can handle errors more accurately with this feature. ```go var errFormatMismatch = errors.New("format mismatch") @@ -96,7 +96,7 @@ var errFormatMismatch = errors.New("format mismatch") func someAction(tasks []task) error { for _, t := range tasks { if err := validateData(t.Data); err != nil { - return goerr.Wrap(err, "failed to validate data").With("name", t.Name) + return goerr.Wrap(err, "failed to validate data", goerr.Value("name", t.Name)) } } // .... @@ -105,7 +105,7 @@ func someAction(tasks []task) error { func validateData(data string) error { if !strings.HasPrefix(data, "data:") { - return goerr.Wrap(errFormatMismatch).With("data", data) + return goerr.Wrap(errFormatMismatch, goerr.Value("data", data)) } return nil } @@ -220,7 +220,7 @@ func someAction(input string) error { func validate(input string) error { if input != "OK" { - return goerr.Wrap(errRuntime, "invalid input").With("input", input) + return goerr.Wrap(errRuntime, "invalid input", goerr.V("input", input)) } return nil } @@ -276,7 +276,7 @@ type object struct { } func (o *object) Validate() error { - eb := goerr.NewBuilder().With("id", o.id) + eb := goerr.NewBuilder(goerr.Value("id", o.id)) if o.color == "" { return eb.New("color is empty") diff --git a/builder.go b/builder.go index 8593ee1..d3f6085 100644 --- a/builder.go +++ b/builder.go @@ -1,40 +1,39 @@ package goerr -import ( - "fmt" -) - // Builder keeps a set of key-value pairs and can create a new error and wrap error with the key-value pairs. type Builder struct { - values values + options []Option } // NewBuilder creates a new Builder -func NewBuilder() *Builder { - return &Builder{values: make(values)} +func NewBuilder(options ...Option) *Builder { + return &Builder{ + options: options, + } } // With copies the current Builder and adds a new key-value pair. +// +// Deprecated: Use goerr.Value instead. func (x *Builder) With(key string, value any) *Builder { - newVS := &Builder{values: x.values.clone()} - newVS.values[key] = value - return newVS + newBuilder := &Builder{ + options: x.options[:], + } + newBuilder.options = append(newBuilder.options, Value(key, value)) + return newBuilder } // New creates a new error with message -func (x *Builder) New(format string, args ...any) *Error { - err := newError() - err.msg = fmt.Sprintf(format, args...) - err.values = x.values.clone() - +func (x *Builder) New(msg string, options ...Option) *Error { + err := newError(append(x.options, options...)...) + err.msg = msg return err } // Wrap creates a new Error with caused error and add message. -func (x *Builder) Wrap(cause error, msg ...any) *Error { - err := newError() - err.msg = toWrapMessage(msg) +func (x *Builder) Wrap(cause error, msg string, options ...Option) *Error { + err := newError(append(x.options, options...)...) + err.msg = msg err.cause = cause - err.values = x.values.clone() return err } diff --git a/builder_test.go b/builder_test.go index c43b160..04350e2 100644 --- a/builder_test.go +++ b/builder_test.go @@ -3,11 +3,11 @@ package goerr_test import ( "testing" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) func newErrorWithBuilder() *goerr.Error { - return goerr.NewBuilder().With("color", "orange").New("error") + return goerr.NewBuilder(goerr.V("color", "orange")).New("error") } func TestBuilderNew(t *testing.T) { @@ -20,7 +20,7 @@ func TestBuilderNew(t *testing.T) { func TestBuilderWrap(t *testing.T) { cause := goerr.New("cause") - err := goerr.NewBuilder().With("color", "blue").Wrap(cause, "error") + err := goerr.NewBuilder(goerr.V("color", "blue")).Wrap(cause, "error") if err.Values()["color"] != "blue" { t.Errorf("Unexpected value: %v", err.Values()) diff --git a/errors.go b/errors.go index a65589f..6d1dd18 100644 --- a/errors.go +++ b/errors.go @@ -4,42 +4,51 @@ import ( "errors" "fmt" "io" - "strings" "log/slog" "github.com/google/uuid" ) -// New creates a new error with message -func New(format string, args ...any) *Error { - err := newError() - err.msg = fmt.Sprintf(format, args...) - return err +type Option func(*Error) + +// Value sets key and value to the error +func Value(key string, value any) Option { + return func(err *Error) { + err.values[key] = value + } } -func toWrapMessage(msgs []any) string { - var newMsgs []string - for _, m := range msgs { - newMsgs = append(newMsgs, fmt.Sprintf("%v", m)) +// V is alias of Value +func V(key string, value any) Option { + return Value(key, value) +} + +// Tag sets tag to the error +func Tag(t tag) Option { + return func(err *Error) { + err.tags.add(t) } - return strings.Join(newMsgs, " ") } -// Wrap creates a new Error and add message. -func Wrap(cause error, msg ...any) *Error { - err := newError() - err.msg = toWrapMessage(msg) - err.cause = cause +// T is alias of Tag +func T(t tag) Option { + return Tag(t) +} +// New creates a new error with message +func New(msg string, options ...Option) *Error { + err := newError(options...) + err.msg = msg return err } -// Wrapf creates a new Error and add message. The error message is formatted by fmt.Sprintf. -func Wrapf(cause error, format string, args ...any) *Error { +// Wrap creates a new Error and add message. +func Wrap(cause error, msg string, options ...Option) *Error { err := newError() - err.msg = fmt.Sprintf(format, args...) + err.msg = msg err.cause = cause + return err } @@ -94,13 +103,19 @@ type Error struct { tags tags } -func newError() *Error { - return &Error{ +func newError(options ...Option) *Error { + e := &Error{ st: callers(), values: make(values), id: uuid.New().String(), tags: make(tags), } + + for _, opt := range options { + opt(e) + } + + return e } func (x *Error) copy(dst *Error) { @@ -186,6 +201,8 @@ func (x *Error) Unwrap() error { } // With adds key and value related to the error event +// +// Deprecated: Use goerr.Value instead. func (x *Error) With(key string, value any) *Error { x.values[key] = value return x diff --git a/errors_test.go b/errors_test.go index a861163..56e93a7 100644 --- a/errors_test.go +++ b/errors_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) func oops() *goerr.Error { @@ -28,24 +28,88 @@ func wrapError() *goerr.Error { func TestNew(t *testing.T) { err := oops() v := fmt.Sprintf("%+v", err) - if !strings.Contains(v, "goerr_test.oops") { - t.Error("Stack trace 'goerr_test.oops' is not found") + if !strings.Contains(v, "goerr/v2_test.oops") { + t.Error("Stack trace 'goerr/v2_test.oops' is not found") } if !strings.Contains(err.Error(), "omg") { t.Error("Error message is not correct") } } +func TestOptions(t *testing.T) { + var testCases = map[string]struct { + options []goerr.Option + values map[string]interface{} + tags []string + }{ + "empty": { + options: []goerr.Option{}, + values: map[string]interface{}{}, + tags: []string{}, + }, + "single value": { + options: []goerr.Option{goerr.Value("key", "value")}, + values: map[string]interface{}{"key": "value"}, + tags: []string{}, + }, + "multiple values": { + options: []goerr.Option{goerr.Value("key1", "value1"), goerr.Value("key2", "value2")}, + values: map[string]interface{}{"key1": "value1", "key2": "value2"}, + tags: []string{}, + }, + "single tag": { + options: []goerr.Option{goerr.Tag(goerr.NewTag("tag1"))}, + values: map[string]interface{}{}, + tags: []string{"tag1"}, + }, + "multiple tags": { + options: []goerr.Option{goerr.Tag(goerr.NewTag("tag1")), goerr.Tag(goerr.NewTag("tag2"))}, + values: map[string]interface{}{}, + tags: []string{"tag1", "tag2"}, + }, + "values and tags": { + options: []goerr.Option{goerr.Value("key", "value"), goerr.Tag(goerr.NewTag("tag1"))}, + values: map[string]interface{}{"key": "value"}, + tags: []string{"tag1"}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + err := goerr.New("test", tc.options...) + values := err.Values() + if len(values) != len(tc.values) { + t.Errorf("Expected values length to be %d, got %d", len(tc.values), len(values)) + } + for k, v := range tc.values { + if values[k] != v { + t.Errorf("Expected value for key '%s' to be '%v', got '%v'", k, v, values[k]) + } + } + + tags := goerr.Tags(err) + if len(tags) != len(tc.tags) { + t.Errorf("Expected tags length to be %d, got %d", len(tc.tags), len(tags)) + } + for _, tag := range tc.tags { + if !sliceHas(tags, tag) { + t.Errorf("Expected tags to contain '%s'", tag) + } + } + }) + } +} + func TestWrapError(t *testing.T) { err := wrapError() st := fmt.Sprintf("%+v", err) - if !strings.Contains(st, "github.com/m-mizutani/goerr_test.wrapError") { + if !strings.Contains(st, "github.com/m-mizutani/goerr/v2_test.wrapError") { t.Error("Stack trace 'wrapError' is not found") } - if !strings.Contains(st, "github.com/m-mizutani/goerr_test.TestWrapError") { + if !strings.Contains(st, "github.com/m-mizutani/goerr/v2_test.TestWrapError") { t.Error("Stack trace 'TestWrapError' is not found") } - if strings.Contains(st, "github.com/m-mizutani/goerr_test.normalError") { + if strings.Contains(st, "github.com/m-mizutani/goerr/v2_test.normalError") { t.Error("Stack trace 'normalError' is found") } if !strings.Contains(err.Error(), "orange: red") { @@ -59,8 +123,8 @@ func TestStackTrace(t *testing.T) { if len(st) != 4 { t.Errorf("Expected stack length of 4, got %d", len(st)) } - if st[0].Func != "github.com/m-mizutani/goerr_test.oops" { - t.Error("Stack trace 'github.com/m-mizutani/goerr_test.oops' is not found") + if st[0].Func != "github.com/m-mizutani/goerr/v2_test.oops" { + t.Error("Stack trace 'github.com/m-mizutani/goerr/v2_test.oops' is not found") } if !regexp.MustCompile(`/goerr/errors_test\.go$`).MatchString(st[0].File) { t.Error("Stack trace file is not correct") @@ -72,7 +136,7 @@ func TestStackTrace(t *testing.T) { func TestMultiWrap(t *testing.T) { err1 := oops() - err2 := goerr.Wrap(err1) + err2 := goerr.Wrap(err1, "some message") if err1 == err2 { t.Error("Expected err1 and err2 to be different") } @@ -163,13 +227,6 @@ func TestUnwrap(t *testing.T) { } } -func TestFormat(t *testing.T) { - err := goerr.New("test: %s", "blue") - if err.Error() != "test: blue" { - t.Errorf("Expected error message to be 'test: blue', got '%s'", err.Error()) - } -} - func TestErrorString(t *testing.T) { err := goerr.Wrap(goerr.Wrap(goerr.New("blue"), "orange"), "red") if err.Error() != "red: orange: blue" { @@ -211,8 +268,8 @@ func TestUnstack(t *testing.T) { if len(st) == 0 { t.Error("Expected stack trace length to be 0") } - if st[0].Func != "github.com/m-mizutani/goerr_test.oops" { - t.Errorf("Not expected stack trace func name (github.com/m-mizutani/goerr_test.oops): %s", st[0].Func) + if st[0].Func != "github.com/m-mizutani/goerr/v2_test.oops" { + t.Errorf("Not expected stack trace func name (github.com/m-mizutani/goerr/v2_test.oops): %s", st[0].Func) } }) @@ -225,8 +282,8 @@ func TestUnstack(t *testing.T) { if len(st1) == 0 { t.Error("Expected stack trace length to be non-zero") } - if st1[0].Func != "github.com/m-mizutani/goerr_test.TestUnstack.func2" { - t.Errorf("Not expected stack trace func name (github.com/m-mizutani/goerr_test.TestUnstack.func2): %s", st1[0].Func) + if st1[0].Func != "github.com/m-mizutani/goerr/v2_test.TestUnstack.func2" { + t.Errorf("Not expected stack trace func name (github.com/m-mizutani/goerr/v2_test.TestUnstack.func2): %s", st1[0].Func) } }) diff --git a/examples/basic/main.go b/examples/basic/main.go index 2fbd577..681ce33 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -5,12 +5,15 @@ import ( "log" "time" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) func someAction(input string) error { if input != "OK" { - return goerr.New("input is not OK").With("input", input).With("time", time.Now()) + return goerr.New("input is not OK", + goerr.Value("input", input), + goerr.Value("time", time.Now()), + ) } return nil } diff --git a/examples/builder/main.go b/examples/builder/main.go index b289a10..ec93528 100644 --- a/examples/builder/main.go +++ b/examples/builder/main.go @@ -3,7 +3,7 @@ package main import ( "log/slog" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) type object struct { diff --git a/examples/errors_is/main.go b/examples/errors_is/main.go index 91111e0..5fc225a 100644 --- a/examples/errors_is/main.go +++ b/examples/errors_is/main.go @@ -4,14 +4,14 @@ import ( "errors" "log" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) var errInvalidInput = errors.New("invalid input") func someAction(input string) error { if input != "OK" { - return goerr.Wrap(errInvalidInput, "input is not OK").With("input", input) + return goerr.Wrap(errInvalidInput, "input is not OK", goerr.Value("input", input)) } // ..... return nil diff --git a/examples/logging/main.go b/examples/logging/main.go index e340a00..751b6fb 100644 --- a/examples/logging/main.go +++ b/examples/logging/main.go @@ -6,7 +6,7 @@ import ( "log/slog" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) var errRuntime = errors.New("runtime error") @@ -20,7 +20,7 @@ func someAction(input string) error { func validate(input string) error { if input != "OK" { - return goerr.Wrap(errRuntime, "invalid input").With("input", input) + return goerr.Wrap(errRuntime, "invalid input", goerr.V("input", input)) } return nil } diff --git a/examples/stacktrace_extract/main.go b/examples/stacktrace_extract/main.go index 56f2eb2..edfcfed 100644 --- a/examples/stacktrace_extract/main.go +++ b/examples/stacktrace_extract/main.go @@ -4,7 +4,7 @@ import ( "log" "os" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) func someAction(fname string) error { diff --git a/examples/stacktrace_print/main.go b/examples/stacktrace_print/main.go index a8a7ed8..90b2c50 100644 --- a/examples/stacktrace_print/main.go +++ b/examples/stacktrace_print/main.go @@ -4,7 +4,7 @@ import ( "errors" "log" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) func nestedAction2() error { diff --git a/examples/tag/main.go b/examples/tag/main.go index dd56b11..e15484a 100644 --- a/examples/tag/main.go +++ b/examples/tag/main.go @@ -3,7 +3,7 @@ package main import ( "net/http" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) var ( @@ -29,7 +29,7 @@ func handleError(w http.ResponseWriter, err error) { func someAction() error { if _, err := http.Get("http://example.com/some/resource"); err != nil { - return goerr.Wrap(err, "failed to get some resource").WithTags(ErrTagSysError) + return goerr.Wrap(err, "failed to get some resource", goerr.T(ErrTagSysError)) } return nil } diff --git a/examples/variables/main.go b/examples/variables/main.go index 2de3125..996457e 100644 --- a/examples/variables/main.go +++ b/examples/variables/main.go @@ -5,7 +5,7 @@ import ( "log" "strings" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) var errFormatMismatch = errors.New("format mismatch") @@ -13,7 +13,7 @@ var errFormatMismatch = errors.New("format mismatch") func someAction(tasks []task) error { for _, t := range tasks { if err := validateData(t.Data); err != nil { - return goerr.Wrap(err, "failed to validate data").With("name", t.Name) + return goerr.Wrap(err, "failed to validate data", goerr.Value("name", t.Name)) } } // .... @@ -22,7 +22,7 @@ func someAction(tasks []task) error { func validateData(data string) error { if !strings.HasPrefix(data, "data:") { - return goerr.Wrap(errFormatMismatch).With("data", data) + return goerr.Wrap(errFormatMismatch, "validation error", goerr.V("data", data)) } return nil } diff --git a/go.mod b/go.mod index 3ce57eb..322916b 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/m-mizutani/goerr +module github.com/m-mizutani/goerr/v2 go 1.21 diff --git a/tag.go b/tag.go index 2a9c8d5..0a61621 100644 --- a/tag.go +++ b/tag.go @@ -14,7 +14,7 @@ import ( // func FindUser(id string) (*User, error) { // ... // if user == nil { -// return nil, goerr.New("user not found").WithTags(TagNotFound) +// return nil, goerr.New("user not found", goerr.Tag(TagNotFound)) // } // ... // } @@ -27,27 +27,29 @@ import ( // } // } // } -type Tag struct { +type tag struct { value string } // NewTag creates a new Tag. The key will be empty. -func NewTag(value string) Tag { - return Tag{value: value} +func NewTag(value string) tag { + return tag{value: value} } // String returns the string representation of the Tag. It's for implementing fmt.Stringer interface. -func (t Tag) String() string { +func (t tag) String() string { return t.value } // Format writes the Tag to the writer. It's for implementing fmt.Formatter interface. -func (t Tag) Format(s fmt.State, verb rune) { +func (t tag) Format(s fmt.State, verb rune) { _, _ = io.WriteString(s, t.value) } // WithTags adds tags to the error. The tags are used to categorize errors. -func (x *Error) WithTags(tags ...Tag) *Error { +// +// Deprecated: Use goerr.Tag instead. +func (x *Error) WithTags(tags ...tag) *Error { for _, tag := range tags { x.tags.add(tag) } @@ -55,17 +57,17 @@ func (x *Error) WithTags(tags ...Tag) *Error { } // HasTag returns true if the error has the tag. -func (x *Error) HasTag(tag Tag) bool { +func (x *Error) HasTag(tag tag) bool { return x.tags.has(tag) } -type tags map[Tag]struct{} +type tags map[tag]struct{} -func (t tags) add(tag Tag) { +func (t tags) add(tag tag) { t[tag] = struct{}{} } -func (t tags) has(tag Tag) bool { +func (t tags) has(tag tag) bool { _, ok := t[tag] return ok } diff --git a/tag_test.go b/tag_test.go index 475240c..6c83b43 100644 --- a/tag_test.go +++ b/tag_test.go @@ -4,12 +4,12 @@ import ( "fmt" "testing" - "github.com/m-mizutani/goerr" + "github.com/m-mizutani/goerr/v2" ) func ExampleNewTag() { t1 := goerr.NewTag("DB error") - err := goerr.New("error message").WithTags(t1) + err := goerr.New("error message", goerr.Tag(t1)) if goErr := goerr.Unwrap(err); goErr != nil { if goErr.HasTag(t1) { @@ -32,7 +32,7 @@ func TestWithTags(t *testing.T) { tag1 := goerr.NewTag("tag1") tag2 := goerr.NewTag("tag2") tag3 := goerr.NewTag("tag3") - err := goerr.New("error message").WithTags(tag1, tag2) + err := goerr.New("error message", goerr.Tag(tag1), goerr.Tag(tag2)) if goErr := goerr.Unwrap(err); goErr != nil { if !goErr.HasTag(tag1) { @@ -49,7 +49,7 @@ func TestWithTags(t *testing.T) { func TestHasTag(t *testing.T) { tag := goerr.NewTag("test_tag") - err := goerr.New("error message").WithTags(tag) + err := goerr.New("error message", goerr.Tag(tag)) if goErr := goerr.Unwrap(err); goErr != nil { if !goErr.HasTag(tag) {