diff --git a/lusherr/README.md b/lusherr/README.md new file mode 100644 index 0000000..d7d0065 --- /dev/null +++ b/lusherr/README.md @@ -0,0 +1,116 @@ +# LUSH Core Errors +This package is used to streamline dealing with errors and error messages within the LUSH infrastructure. Using the errors provided by this package will collect a lot of useful debug information about where exactly the error occurred. + +## Error Types +These are the standard error types that can be used within a project's domain logic to aid with debugging and error reporting to any of its API consumers. + +### Internal Error +`InternalError` can be used to wrap any error e.g. Trying to generate a random UUID, but the generation failed. + +```go +id, err := uuid.NewV4() +if err != nil { + return NewInternalError(err) +} +``` + +### Unauthorized Error +`UnauthorizedError` should be used when an action is performed by a user that they don't have permission to do e.g. Someone tried to access something they were not allowed to according to a permission policy. + +```go +if err := policy.Permit(consumer); err != nil { + return NewUnauthorizedError(err) +} +``` + +### Validation Error +`ValidationError` should be used to detail what user generated information is incorrect and why e.g. Someone set the name field for a user to be empty, but the validation requires it to be present. + +```go +type ProductRevision struct { + plu string +} + +func (r ProductRevision) validate() error { + if plu == "" { + return NewValidationError("product revision", "plu", fmt.Errorf("must be present")) + } +} +``` + +### Database Query Error +`DatabaseQueryError` should be used to provide detail about a failed database query e.g. Trying to query the database, but the database rejects the query. + +```go +const stmt = `SELECT * FROM user` +rows, err := qu.Query(stmt) +if err != nil { + return nil, NewDatabaseQueryError(stmt, err) +} +``` + +### Not Found Error +`NotFoundError` should be used when an entity cannot be found e.g. Someone tries to retrieve a user, but the user for the given ID does not exist in the database. + +```go +const stmt = `SELECT * FROM user WHERE id = $1` +rows, err := qu.Query(stmt, id) +if err != nil { + switch err { + case sql.ErrNoRows: + return nil, NewNotFoundError("user", id, err) + default: + return nil, NewDatabaseQueryError(stmt, err) + } +} +``` + +### Not Allowed Error +`NotAllowedError` should be used when an certain action is not allowed e.g. Someone tries to delete something, but the record has been marked as permanent. + +```go +if product.permanent { + return NewNotAllowedError(fmt.Errorf("not allowed to remove permanent products")) +} +``` + +## Locate +Errors produced with the `lusherr` package can be located to return the `runtime.Frame` of where it occurred. + +```go +frame, found := lusherr.Locate(err) +if found { + log.Println(err, frame) +} else { + log.Println(err, "frame could not be found") +} +``` + +### Locator Interface +Implement the [`Locator`](#locator-interface) interface on an error type to return its caller frame to be used in conjunction with the `lusherr` package and associated tooling. + +```go +type Locator interface { + Locate() runtime.Frame +} +``` + +## Pin +Call `Pin` to wrap an error with information about the caller frame of where `Pin` was invoked for an error that does not already carry a caller frame. The resulting error can be cast to [`Locate`](#locator-interface). + +```go +func UploadToBucket(w io.Writer) error { + if err := upload(w); err != nil { + return lusherr.Pin(err) + } +} +``` + +### Pinner Interface +Implement the [`Pinner`](#pinner-interface) interface on an error type to prevent errors that already implements [`Locator`](#locator-interface) to be wrapped multiple times. + +```go +type Pinner interface { + Pin(runtime.Frame) error +} +``` \ No newline at end of file diff --git a/lusherr/debug.go b/lusherr/debug.go new file mode 100644 index 0000000..5fca945 --- /dev/null +++ b/lusherr/debug.go @@ -0,0 +1,17 @@ +package lusherr + +import ( + "fmt" +) + +// Debug where an error was raised. +func Debug(err error) string { + if err == nil { + return fmt.Sprintf("unknown error") + } + frame, found := Locate(err) + if !found { + return fmt.Sprintf("%v (unknown caller frame)", err) + } + return fmt.Sprintf("%v (%s %s:%d)", err, frame.Function, frame.File, frame.Line) +} diff --git a/lusherr/errors.go b/lusherr/errors.go new file mode 100644 index 0000000..2cc03fd --- /dev/null +++ b/lusherr/errors.go @@ -0,0 +1,249 @@ +package lusherr + +import ( + "fmt" + "runtime" +) + +// NewInternalError builds a generic error. +// e.g. Trying to generate a random UUID, but the generation failed. +func NewInternalError(inner error) error { + return InternalError{ + frame: frame(1), + inner: inner, + } +} + +// InternalError can be used to wrap any error. +// e.g. Trying to generate a random UUID, but the generation failed. +type InternalError struct { + frame runtime.Frame + inner error +} + +func (e InternalError) Error() string { + if e.inner == nil { + return fmt.Sprintf("internal failure") + } + return fmt.Sprintf("internal failure: %v", e.inner) +} + +// Unwrap the inner error. +func (e InternalError) Unwrap() error { + return e.inner +} + +// Locate the frame of the error. +func (e InternalError) Locate() runtime.Frame { + return e.frame +} + +// Pin the error to a caller frame. +func (e InternalError) Pin(frame runtime.Frame) error { + e.frame = frame + return e +} + +// NewUnauthorizedError builds a new unauthorized error. +// e.g. Someone tried to access something they were not allowed to according to a permission policy. +func NewUnauthorizedError(inner error) error { + return UnauthorizedError{ + frame: frame(1), + inner: inner, + } +} + +// UnauthorizedError should be used when an action is performed by a user that they don't have permission to do. +// e.g. Someone tried to access something they were not allowed to according to a permission policy. +type UnauthorizedError struct { + frame runtime.Frame + inner error +} + +func (e UnauthorizedError) Error() string { + if e.inner == nil { + return fmt.Sprintf("unauthorized") + } + return fmt.Sprintf("unauthorized: %v", e.inner) +} + +// Unwrap the inner error. +func (e UnauthorizedError) Unwrap() error { + return e.inner +} + +// Locate the frame of the error. +func (e UnauthorizedError) Locate() runtime.Frame { + return e.frame +} + +// Pin the error to a caller frame. +func (e UnauthorizedError) Pin(frame runtime.Frame) error { + e.frame = frame + return e +} + +// NewValidationError builds an error for failing to validate a field. +// e.g. Someone set the name field for a user to be empty, but the validation requires it to be present. +func NewValidationError(entity, field string, inner error) error { + return ValidationError{ + frame: frame(1), + inner: inner, + Entity: entity, + Field: field, + } +} + +// ValidationError should be used to detail what user generated information is incorrect and why. +// e.g. Someone set the name field for a user to be empty, but the validation requires it to be present. +type ValidationError struct { + Entity, Field string + frame runtime.Frame + inner error +} + +func (e ValidationError) Error() string { + if e.inner == nil { + return fmt.Sprintf("validation failed for %q on %q", e.Field, e.Entity) + } + return fmt.Sprintf("validation failed for %q on %q: %v", e.Field, e.Entity, e.inner) +} + +// Unwrap the inner error. +func (e ValidationError) Unwrap() error { + return e.inner +} + +// Locate the frame of the error. +func (e ValidationError) Locate() runtime.Frame { + return e.frame +} + +// Pin the error to a caller frame. +func (e ValidationError) Pin(frame runtime.Frame) error { + e.frame = frame + return e +} + +// NewDatabaseQueryError builds an error for a failed database query. +// e.g. Trying to query the database, but the database rejects the query. +func NewDatabaseQueryError(query string, inner error) error { + return DatabaseQueryError{ + frame: frame(1), + inner: inner, + Query: query, + } +} + +// DatabaseQueryError should be used to provide detail about a failed database query. +// e.g. Trying to query the database, but the database rejects the query. +type DatabaseQueryError struct { + Query string + frame runtime.Frame + inner error +} + +func (e DatabaseQueryError) Error() string { + if e.inner == nil { + return fmt.Sprintf("database query failed") + } + return fmt.Sprintf("database query failed: %v", e.inner) +} + +// Unwrap the inner error. +func (e DatabaseQueryError) Unwrap() error { + return e.inner +} + +// Locate the frame of the error. +func (e DatabaseQueryError) Locate() runtime.Frame { + return e.frame +} + +// Pin the error to a caller frame. +func (e DatabaseQueryError) Pin(frame runtime.Frame) error { + e.frame = frame + return e +} + +// NewNotFoundError builds an error for an entity that cannot be found. +// e.g. Someone tries to retrieve a user, but the user for the given ID does not exist in the database. +func NewNotFoundError(entity string, identifier interface{}, inner error) error { + return NotFoundError{ + frame: frame(1), + inner: inner, + Entity: entity, + Identifier: identifier, + } +} + +// NotFoundError should be used when an entity cannot be found. +// e.g. Someone tries to retrieve a user, but the user for the given ID does not exist in the database. +type NotFoundError struct { + Entity string + Identifier interface{} + frame runtime.Frame + inner error +} + +func (e NotFoundError) Error() string { + if e.inner == nil { + return fmt.Sprintf("cannot find %q (%v)", e.Entity, e.Identifier) + } + return fmt.Sprintf("cannot find %q (%v): %v", e.Entity, e.Identifier, e.inner) +} + +// Unwrap the inner error. +func (e NotFoundError) Unwrap() error { + return e.inner +} + +// Locate the frame of the error. +func (e NotFoundError) Locate() runtime.Frame { + return e.frame +} + +// Pin the error to a caller frame. +func (e NotFoundError) Pin(frame runtime.Frame) error { + e.frame = frame + return e +} + +// NewNotAllowedError builds an error for when a certain action is not allowed. +// e.g. Someone tries to delete something, but the record has been marked as permanenet. +func NewNotAllowedError(inner error) error { + return NotAllowedError{ + frame: frame(1), + inner: inner, + } +} + +// NotAllowedError should be used when an certain action is not allowed. +// e.g. Someone tries to delete something, but the record has been marked as permanent. +type NotAllowedError struct { + frame runtime.Frame + inner error +} + +func (e NotAllowedError) Error() string { + if e.inner == nil { + return fmt.Sprintf("action not allowed") + } + return fmt.Sprintf("action not allowed: %v", e.inner) +} + +// Unwrap the inner error. +func (e NotAllowedError) Unwrap() error { + return e.inner +} + +// Locate the frame of the error. +func (e NotAllowedError) Locate() runtime.Frame { + return e.frame +} + +// Pin the error to a caller frame. +func (e NotAllowedError) Pin(frame runtime.Frame) error { + e.frame = frame + return e +} diff --git a/lusherr/errors_test.go b/lusherr/errors_test.go new file mode 100644 index 0000000..e33ffe8 --- /dev/null +++ b/lusherr/errors_test.go @@ -0,0 +1,50 @@ +package lusherr_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/LUSHDigital/core-lush/lusherr" + "github.com/LUSHDigital/core/test" + "github.com/LUSHDigital/uuid" +) + +var inner = fmt.Errorf("this is the inner most error") + +func TestInternalError_Error(t *testing.T) { + test.Equals(t, "internal failure", lusherr.NewInternalError(nil).Error()) + test.Equals(t, "internal failure: inner", lusherr.NewInternalError(fmt.Errorf("inner")).Error()) + test.Equals(t, inner, errors.Unwrap(lusherr.NewInternalError(inner))) +} + +func TestUnauthorizedError_Error(t *testing.T) { + test.Equals(t, "unauthorized", lusherr.NewUnauthorizedError(nil).Error()) + test.Equals(t, "unauthorized: inner", lusherr.NewUnauthorizedError(fmt.Errorf("inner")).Error()) + test.Equals(t, inner, errors.Unwrap(lusherr.NewUnauthorizedError(inner))) +} + +func TestValidationError_Error(t *testing.T) { + test.Equals(t, "validation failed for \"name\" on \"user\"", lusherr.NewValidationError("user", "name", nil).Error()) + test.Equals(t, "validation failed for \"name\" on \"user\": inner", lusherr.NewValidationError("user", "name", fmt.Errorf("inner")).Error()) + test.Equals(t, inner, errors.Unwrap(lusherr.NewValidationError("user", "name", inner))) +} + +func TestDatabaseQueryError_Error(t *testing.T) { + test.Equals(t, "database query failed", lusherr.NewDatabaseQueryError("SELECT * FROM user", nil).Error()) + test.Equals(t, "database query failed: inner", lusherr.NewDatabaseQueryError("SELECT * FROM user", fmt.Errorf("inner")).Error()) + test.Equals(t, inner, errors.Unwrap(lusherr.NewDatabaseQueryError("", inner))) +} + +func TestNotFoundError_Error(t *testing.T) { + id := uuid.Must(uuid.NewV4()) + test.Equals(t, fmt.Sprintf("cannot find \"user\" (%s)", id), lusherr.NewNotFoundError("user", id, nil).Error()) + test.Equals(t, fmt.Sprintf("cannot find \"user\" (%s): inner", id), lusherr.NewNotFoundError("user", id, fmt.Errorf("inner")).Error()) + test.Equals(t, inner, errors.Unwrap(lusherr.NewNotFoundError("", "", inner))) +} + +func TestNotAllowedError_Error(t *testing.T) { + test.Equals(t, "action not allowed", lusherr.NewNotAllowedError(nil).Error()) + test.Equals(t, "action not allowed: inner", lusherr.NewNotAllowedError(fmt.Errorf("inner")).Error()) + test.Equals(t, inner, errors.Unwrap(lusherr.NewNotAllowedError(inner))) +} diff --git a/lusherr/frame.go b/lusherr/frame.go new file mode 100644 index 0000000..14b6e07 --- /dev/null +++ b/lusherr/frame.go @@ -0,0 +1,73 @@ +package lusherr + +import ( + "errors" + "fmt" + "runtime" +) + +// originError is used to give any error an origin frame. +type originError struct { + err error + frame runtime.Frame +} + +func (e originError) Error() string { + return fmt.Sprintf("%v", e.err) +} + +// Locate the frame of the error. +func (e originError) Locate() runtime.Frame { + return e.frame +} + +// Pin an error to a caller frame. +func (e originError) Pin(frame runtime.Frame) error { + e.frame = frame + return e +} + +// Locator defines behavior for locating an error frame. +type Locator interface { + Locate() runtime.Frame +} + +// Locate where an error was raised. +func Locate(err error) (runtime.Frame, bool) { + var frame runtime.Frame + switch err := err.(type) { + case Locator: + return err.Locate(), true + default: + if err := errors.Unwrap(err); err != nil { + return Locate(err) + } + } + return frame, false +} + +// Pinner defines behavior for defining an origin frame for an error. +type Pinner interface { + Pin(runtime.Frame) error +} + +// Pin an error to its caller frame to carry the location in code where the error occurred. +func Pin(err error) error { + switch err := err.(type) { + case Pinner: + return err.Pin(frame(1)) + default: + return originError{ + err: err, + frame: frame(1), + } + } +} + +// frame of the caller, skipped from the caller +func frame(skip int) runtime.Frame { + rpc := make([]uintptr, 1) + runtime.Callers(skip+2, rpc) + frame, _ := runtime.CallersFrames(rpc).Next() + return frame +} diff --git a/lusherr/frame_test.go b/lusherr/frame_test.go new file mode 100644 index 0000000..3a5338b --- /dev/null +++ b/lusherr/frame_test.go @@ -0,0 +1,93 @@ +package lusherr_test + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/LUSHDigital/core-lush/lusherr" + "github.com/LUSHDigital/core/test" +) + +var dir string + +func TestMain(m *testing.M) { + var err error + dir, err = os.Getwd() + if err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +func TestLocate(t *testing.T) { + internal := lusherr.NewInternalError(fmt.Errorf("inner")) + wrapped := fmt.Errorf("wrapping: %w", lusherr.NewInternalError(fmt.Errorf("wrapped"))) + untyped := fmt.Errorf("hello world") + type Test struct { + name string + err error + expect bool + expected runtime.Frame + } + cases := []Test{ + { + name: "with re-pinned inline error", + err: lusherr.Pin(fmt.Errorf("inline error")), + expect: true, + expected: runtime.Frame{ + Line: 38, + File: filepath.Join(dir, "frame_test.go"), + Function: "github.com/LUSHDigital/core-lush/lusherr_test.TestLocate", + }, + }, + { + name: "with re-pinned typed error", + err: lusherr.Pin(internal), + expect: true, + expected: runtime.Frame{ + Line: 48, + File: filepath.Join(dir, "frame_test.go"), + Function: "github.com/LUSHDigital/core-lush/lusherr_test.TestLocate", + }, + }, + { + name: "with error wrapped origin", + err: wrapped, + expect: true, + expected: runtime.Frame{ + Line: 27, + File: filepath.Join(dir, "frame_test.go"), + Function: "github.com/LUSHDigital/core-lush/lusherr_test.TestLocate", + }, + }, + { + name: "with untyped error", + err: untyped, + expect: false, + expected: runtime.Frame{ + Line: 28, + File: filepath.Join(dir, "frame_test.go"), + Function: "github.com/LUSHDigital/core-lush/lusherr_test.TestLocate", + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + frame, ok := lusherr.Locate(c.err) + if c.expect && !ok { + t.Fatal("frame not found") + } + if !c.expect && ok { + t.Fatal("frame found when none was expected") + } + if ok { + test.Equals(t, c.expected.File, frame.File) + test.Equals(t, c.expected.Function, frame.Function) + test.Equals(t, c.expected.Line, frame.Line) + } + }) + } +}