diff --git a/errors.go b/errors.go index c757810..4827d8c 100644 --- a/errors.go +++ b/errors.go @@ -69,6 +69,10 @@ func (errUnknown) Error() string { return "unknown" } func (errUnknown) Unknown() {} +func (e errUnknown) WithMessage(msg string) error { + return customMessage{e, msg} +} + // unknown maps to Moby's "ErrUnknown" type unknown interface { Unknown() @@ -86,6 +90,10 @@ func (errInvalidArgument) Error() string { return "invalid argument" } func (errInvalidArgument) InvalidParameter() {} +func (e errInvalidArgument) WithMessage(msg string) error { + return customMessage{e, msg} +} + // invalidParameter maps to Moby's "ErrInvalidParameter" type invalidParameter interface { InvalidParameter() @@ -113,6 +121,10 @@ func (errNotFound) Error() string { return "not found" } func (errNotFound) NotFound() {} +func (e errNotFound) WithMessage(msg string) error { + return customMessage{e, msg} +} + // notFound maps to Moby's "ErrNotFound" type notFound interface { NotFound() @@ -127,6 +139,10 @@ type errAlreadyExists struct{} func (errAlreadyExists) Error() string { return "already exists" } +func (e errAlreadyExists) WithMessage(msg string) error { + return customMessage{e, msg} +} + // IsAlreadyExists returns true if the error is due to an already existing // metadata item func IsAlreadyExists(err error) bool { @@ -137,6 +153,10 @@ type errPermissionDenied struct{} func (errPermissionDenied) Error() string { return "permission denied" } +func (e errPermissionDenied) WithMessage(msg string) error { + return customMessage{e, msg} +} + // forbidden maps to Moby's "ErrForbidden" type forbidden interface { Forbidden() @@ -152,6 +172,10 @@ type errResourceExhausted struct{} func (errResourceExhausted) Error() string { return "resource exhausted" } +func (e errResourceExhausted) WithMessage(msg string) error { + return customMessage{e, msg} +} + // IsResourceExhausted returns true if the error is due to // a lack of resources or too many attempts. func IsResourceExhausted(err error) bool { @@ -162,6 +186,10 @@ type errFailedPrecondition struct{} func (e errFailedPrecondition) Error() string { return "failed precondition" } +func (e errFailedPrecondition) WithMessage(msg string) error { + return customMessage{e, msg} +} + // IsFailedPrecondition returns true if an operation could not proceed due to // the lack of a particular condition func IsFailedPrecondition(err error) bool { @@ -174,6 +202,10 @@ func (errConflict) Error() string { return "conflict" } func (errConflict) Conflict() {} +func (e errConflict) WithMessage(msg string) error { + return customMessage{e, msg} +} + // conflict maps to Moby's "ErrConflict" type conflict interface { Conflict() @@ -191,6 +223,10 @@ func (errNotModified) Error() string { return "not modified" } func (errNotModified) NotModified() {} +func (e errNotModified) WithMessage(msg string) error { + return customMessage{e, msg} +} + // notModified maps to Moby's "ErrNotModified" type notModified interface { NotModified() @@ -206,6 +242,10 @@ type errAborted struct{} func (errAborted) Error() string { return "aborted" } +func (e errAborted) WithMessage(msg string) error { + return customMessage{e, msg} +} + // IsAborted returns true if an operation was aborted. func IsAborted(err error) bool { return errors.Is(err, errAborted{}) @@ -215,6 +255,10 @@ type errOutOfRange struct{} func (errOutOfRange) Error() string { return "out of range" } +func (e errOutOfRange) WithMessage(msg string) error { + return customMessage{e, msg} +} + // IsOutOfRange returns true if an operation could not proceed due // to data being out of the expected range. func IsOutOfRange(err error) bool { @@ -227,6 +271,10 @@ func (errNotImplemented) Error() string { return "not implemented" } func (errNotImplemented) NotImplemented() {} +func (e errNotImplemented) WithMessage(msg string) error { + return customMessage{e, msg} +} + // notImplemented maps to Moby's "ErrNotImplemented" type notImplemented interface { NotImplemented() @@ -243,6 +291,10 @@ func (errInternal) Error() string { return "internal" } func (errInternal) System() {} +func (e errInternal) WithMessage(msg string) error { + return customMessage{e, msg} +} + // system maps to Moby's "ErrSystem" type system interface { System() @@ -259,6 +311,10 @@ func (errUnavailable) Error() string { return "unavailable" } func (errUnavailable) Unavailable() {} +func (e errUnavailable) WithMessage(msg string) error { + return customMessage{e, msg} +} + // unavailable maps to Moby's "ErrUnavailable" type unavailable interface { Unavailable() @@ -275,6 +331,10 @@ func (errDataLoss) Error() string { return "data loss" } func (errDataLoss) DataLoss() {} +func (e errDataLoss) WithMessage(msg string) error { + return customMessage{e, msg} +} + // dataLoss maps to Moby's "ErrDataLoss" type dataLoss interface { DataLoss() @@ -291,6 +351,10 @@ func (errUnauthorized) Error() string { return "unauthorized" } func (errUnauthorized) Unauthorized() {} +func (e errUnauthorized) WithMessage(msg string) error { + return customMessage{e, msg} +} + // unauthorized maps to Moby's "ErrUnauthorized" type unauthorized interface { Unauthorized() @@ -307,6 +371,8 @@ func isInterface[T any](err error) bool { switch x := err.(type) { case T: return true + case customMessage: + err = x.err case interface{ Unwrap() error }: err = x.Unwrap() if err == nil { @@ -324,3 +390,22 @@ func isInterface[T any](err error) bool { } } } + +// customMessage is used to provide a defined error with a custom message. +// The message is not wrapped but can be compared by the `Is(error) bool` interface. +type customMessage struct { + err error + msg string +} + +func (c customMessage) Is(err error) bool { + return c.err == err +} + +func (c customMessage) As(target any) bool { + return errors.As(c.err, target) +} + +func (c customMessage) Error() string { + return c.msg +} diff --git a/errors_test.go b/errors_test.go index 9ec9adb..40e564f 100644 --- a/errors_test.go +++ b/errors_test.go @@ -19,6 +19,7 @@ package errdefs import ( "context" "errors" + "reflect" "testing" ) @@ -44,6 +45,119 @@ func TestInvalidArgument(t *testing.T) { } } +func TestErrorEquivalence(t *testing.T) { + var e1 error = ErrAborted + var e2 error = ErrUnknown + if e1 == e2 { + t.Fatal("should not equal the same error") + } + if errors.Is(e1, e2) { + t.Fatal("errors.Is should not return true") + } + + var e3 error = errAborted{} + if e1 != e3 { + t.Fatal("new instance should be equivalent") + } + if !errors.Is(e1, e3) { + t.Fatal("errors.Is should be true") + } + if !errors.Is(e3, e1) { + t.Fatal("errors.Is should be true") + } + var aborted errAborted + if !errors.As(e1, &aborted) { + t.Fatal("errors.As should be true") + } + + var e4 = ErrAborted.WithMessage("custom message") + if e1 == e4 { + t.Fatal("should not equal the same error") + } + + if !errors.Is(e4, e1) { + t.Fatal("errors.Is should be true, e1 is in the tree of e4") + } + + if errors.Is(e1, e4) { + t.Fatal("errors.Is should be false, e1 is not a custom message") + } + + if !errors.As(e4, &aborted) { + t.Fatal("errors.As should be true") + } + + var custom customMessage + if !errors.As(e4, &custom) { + t.Fatal("errors.As should be true") + } + if custom.msg != "custom message" { + t.Fatalf("unexpected custom message: %q", custom.msg) + } + if custom.err != e1 { + t.Fatalf("unexpected custom message error: %v", custom.err) + } +} + +func TestWithMessage(t *testing.T) { + testErrors := []error{ErrUnknown, + ErrInvalidArgument, + ErrNotFound, + ErrAlreadyExists, + ErrPermissionDenied, + ErrResourceExhausted, + ErrFailedPrecondition, + ErrConflict, + ErrNotModified, + ErrAborted, + ErrOutOfRange, + ErrNotImplemented, + ErrInternal, + ErrUnavailable, + ErrDataLoss, + ErrUnauthenticated, + } + for _, err := range testErrors { + e1 := err + t.Run(err.Error(), func(t *testing.T) { + wm, ok := e1.(interface{ WithMessage(string) error }) + if !ok { + t.Fatal("WithMessage not supported") + } + e2 := wm.WithMessage("custom message") + + if e1 == e2 { + t.Fatal("should not equal the same error") + } + + if !errors.Is(e2, e1) { + t.Fatal("errors.Is should return true") + } + + if errors.Is(e1, e2) { + t.Fatal("errors.Is should be false, e1 is not a custom message") + } + + var raw = reflect.New(reflect.TypeOf(e1)).Interface() + if !errors.As(e2, raw) { + t.Fatal("errors.As should be true") + } + + var custom customMessage + if !errors.As(e2, &custom) { + t.Fatal("errors.As should be true") + } + if custom.msg != "custom message" { + t.Fatalf("unexpected custom message: %q", custom.msg) + } + if custom.err != e1 { + t.Fatalf("unexpected custom message error: %v", custom.err) + } + + }) + } +} + type customInvalidArgument struct{} func (*customInvalidArgument) Error() string { diff --git a/resolve.go b/resolve.go index 663338a..ea049da 100644 --- a/resolve.go +++ b/resolve.go @@ -66,6 +66,8 @@ func firstError(err error) error { return err } switch e := err.(type) { + case customMessage: + err = e.err case unknown: return ErrUnknown case invalidParameter: diff --git a/resolve_test.go b/resolve_test.go index c05cae1..8cebb8a 100644 --- a/resolve_test.go +++ b/resolve_test.go @@ -61,6 +61,9 @@ func TestResolve(t *testing.T) { {errors.Join(testUnavailable{}, ErrPermissionDenied), ErrUnavailable}, {errors.Join(errors.New("untyped join")), ErrUnknown}, {errors.Join(errors.New("untyped1"), errors.New("untyped2")), ErrUnknown}, + {ErrNotFound.WithMessage("something else"), ErrNotFound}, + {wrap(ErrNotFound.WithMessage("something else")), ErrNotFound}, + {errors.Join(ErrNotFound.WithMessage("something else"), ErrPermissionDenied), ErrNotFound}, } { name := fmt.Sprintf("%d-%s", i, errorString(tc.resolved)) tc := tc