diff --git a/internal/errors/code.go b/internal/errors/code.go new file mode 100644 index 0000000000..03de4b61fa --- /dev/null +++ b/internal/errors/code.go @@ -0,0 +1,35 @@ +package errors + +// Code specifies a code for the error. +type Code uint32 + +// String will return the Code's Info.Message +func (c Code) String() string { + return c.Info().Message +} + +// Info will look up the Code's Info. If the Info is not found, it will return +// Info for an Unknown Code. +func (c Code) Info() Info { + if info, ok := errorCodeInfo[c]; ok { + return info + } + return errorCodeInfo[Unknown] +} + +const ( + Unknown Code = 0 // Unknown will be equal to a zero value for Codes + + // General function errors are reserved Codes 100-999 + InvalidParameter Code = 100 // InvalidParameter represents and invalid parameter for an operation. + + // DB errors are resevered Codes from 1000-1999 + CheckConstraint Code = 1000 // CheckConstraint represents a check constraint error + NotNull Code = 1001 // NotNull represents a value must not be null error + NotUnique Code = 1002 // NotUnique represents a value must be unique error + NotSpecificIntegrity Code = 1003 // NotSpecificIntegrity represents an integrity error that has no specificy domain error code + MissingTable Code = 1004 // Missing table represents an undefined table error + RecordNotFound Code = 1100 // RecordNotFound represents that a record/row was not found matching the criteria + MultipleRecords Code = 1101 // MultipleRecords represents that multiple records/rows were found matching the criteria + +) diff --git a/internal/errors/code_test.go b/internal/errors/code_test.go new file mode 100644 index 0000000000..4977b5a2c8 --- /dev/null +++ b/internal/errors/code_test.go @@ -0,0 +1,79 @@ +package errors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCode_Both_String_Info(t *testing.T) { + t.Parallel() + tests := []struct { + name string + c Code + want Code + }{ + { + name: "undefined-code", + c: Code(4294967295), + want: Unknown, + }, + { + name: "default-value", + want: Unknown, + }, + { + name: "Unknown", + c: Unknown, + want: Unknown, + }, + { + name: "InvalidParameter", + c: InvalidParameter, + want: InvalidParameter, + }, + { + name: "CheckConstraint", + c: CheckConstraint, + want: CheckConstraint, + }, + { + name: "NotNull", + c: NotNull, + want: NotNull, + }, + { + name: "NotUnique", + c: NotUnique, + want: NotUnique, + }, + { + name: "RecordNotFound", + c: RecordNotFound, + want: RecordNotFound, + }, + { + name: "MultipleRecords", + c: MultipleRecords, + want: MultipleRecords, + }, + { + name: "NotSpecificIntegrity", + c: NotSpecificIntegrity, + want: NotSpecificIntegrity, + }, + { + name: "MissingTable", + c: MissingTable, + want: MissingTable, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + assert.Equal(errorCodeInfo[tt.want], tt.c.Info()) + assert.Equal(errorCodeInfo[tt.want].Message, tt.c.String()) + + }) + } +} diff --git a/internal/errors/error.go b/internal/errors/error.go new file mode 100644 index 0000000000..73ce921ca1 --- /dev/null +++ b/internal/errors/error.go @@ -0,0 +1,150 @@ +package errors + +import ( + "errors" + "fmt" + "strings" + + "github.com/lib/pq" +) + +// Op represents an operation (package.function). +// For example iam.CreateRole +type Op string + +// Err provides the ability to specify a Msg, Op, Code and Wrapped error. +// Errs must have a Code and all other fields are optional. We've chosen Err +// over Error for the identifier to support the easy embedding of Errs. Errs +// can be embedded without a conflict between the embedded Err and Err.Error(). +type Err struct { + // Code is the error's code, which can be used to get the error's + // errorCodeInfo, which contains the error's Kind and Message + Code Code + + // Msg for the error + Msg string + + // Op represents the operation raising/propagating an error and is optional + Op Op + + // Wrapped is the error which this Err wraps and will be nil if there's no + // error to wrap. + Wrapped error +} + +// New creates a new Err and supports the options of: +// WithOp - allows you to specify an optional Op (operation) +// WithMsg() - allows you to specify an optional error msg, if the default +// msg for the error Code is not sufficient. +// WithWrap() - allows you to specify +// an error to wrap +func New(c Code, opt ...Option) error { + opts := GetOpts(opt...) + return &Err{ + Code: c, + Op: opts.withOp, + Wrapped: opts.withErrWrapped, + Msg: opts.withErrMsg, + } +} + +// Convert will convert the error to a Boundary *Err (returning it as an error) +// and attempt to add a helpful error msg as well. If that's not possible, it +// will return nil +func Convert(e error) *Err { + if e == nil { + return nil + } + if err, ok := e.(*Err); ok { + return err + } + var pqError *pq.Error + if As(e, &pqError) { + if pqError.Code.Class() == "23" { // class of integrity constraint violations + switch pqError.Code { + case "23505": // unique_violation + return New(NotUnique, WithMsg(pqError.Detail), WithWrap(ErrNotUnique)).(*Err) + case "23502": // not_null_violation + msg := fmt.Sprintf("%s must not be empty", pqError.Column) + return New(NotNull, WithMsg(msg), WithWrap(ErrNotNull)).(*Err) + case "23514": // check_violation + msg := fmt.Sprintf("%s constraint failed", pqError.Constraint) + return New(CheckConstraint, WithMsg(msg), WithWrap(ErrCheckConstraint)).(*Err) + default: + return New(NotSpecificIntegrity, WithMsg(pqError.Message)).(*Err) + } + } + if pqError.Code == "42P01" { + return New(MissingTable, WithMsg(pqError.Message)).(*Err) + } + } + // unfortunately, we can't help. + return nil +} + +// Info about the Err +func (e *Err) Info() Info { + if e == nil { + return errorCodeInfo[Unknown] + } + return e.Code.Info() +} + +// Error satisfies the error interface and returns a string representation of +// the Err +func (e *Err) Error() string { + if e == nil { + return "" + } + var s strings.Builder + if e.Op != "" { + join(&s, ": ", string(e.Op)) + } + if e.Msg != "" { + join(&s, ": ", e.Msg) + } + + if info, ok := errorCodeInfo[e.Code]; ok { + if e.Msg == "" { + join(&s, ": ", info.Message) // provide a default. + join(&s, ", ", info.Kind.String()) + } else { + join(&s, ": ", info.Kind.String()) + } + } + join(&s, ": ", fmt.Sprintf("error #%d", e.Code)) + + if e.Wrapped != nil { + join(&s, ": \n", e.Wrapped.Error()) + } + return s.String() +} + +func join(str *strings.Builder, delim string, s string) { + if str.Len() == 0 { + _, _ = str.WriteString(s) + return + } + _, _ = str.WriteString(delim + s) +} + +// Unwrap implements the errors.Unwrap interface and allows callers to use the +// errors.Is() and errors.As() functions effectively for any wrapped errors. +func (e *Err) Unwrap() error { + if e == nil { + return nil + } + return e.Wrapped +} + +// Is the equivalent of the std errors.Is, but allows Devs to only import +// this package for the capability. +func Is(err, target error) bool { + return errors.Is(err, target) +} + +// As is the equivalent of the std errors.As, and allows devs to only import +// this package for the capability. +func As(err error, target interface{}) bool { + return errors.As(err, target) +} diff --git a/internal/errors/error_test.go b/internal/errors/error_test.go new file mode 100644 index 0000000000..9299d125a3 --- /dev/null +++ b/internal/errors/error_test.go @@ -0,0 +1,275 @@ +package errors_test + +import ( + "context" + stderrors "errors" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewError(t *testing.T) { + t.Parallel() + tests := []struct { + name string + code errors.Code + opt []errors.Option + want error + }{ + { + name: "all-options", + code: errors.InvalidParameter, + opt: []errors.Option{ + errors.WithOp("alice.Bob"), + errors.WithWrap(errors.ErrRecordNotFound), + errors.WithMsg("test msg"), + }, + want: &errors.Err{ + Op: "alice.Bob", + Wrapped: errors.ErrRecordNotFound, + Msg: "test msg", + Code: errors.InvalidParameter, + }, + }, + { + name: "no-options", + opt: nil, + want: &errors.Err{ + Code: errors.Unknown, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + err := errors.New(tt.code, tt.opt...) + require.Error(err) + assert.Equal(tt.want, err) + }) + } +} + +func TestError_Info(t *testing.T) { + t.Parallel() + tests := []struct { + name string + err *errors.Err + want errors.Code + }{ + { + name: "nil", + err: nil, + want: errors.Unknown, + }, + { + name: "Unknown", + err: errors.New(errors.Unknown).(*errors.Err), + want: errors.Unknown, + }, + { + name: "InvalidParameter", + err: errors.New(errors.InvalidParameter).(*errors.Err), + want: errors.InvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + assert.Equal(tt.want.Info(), tt.err.Info()) + }) + } +} + +func TestError_Error(t *testing.T) { + t.Parallel() + tests := []struct { + name string + err error + want string + }{ + { + name: "msg", + err: errors.New(errors.Unknown, errors.WithMsg("test msg")), + want: "test msg: unknown: error #0", + }, + { + name: "code", + err: errors.New(errors.CheckConstraint), + want: "constraint check failed, integrity violation: error #1000", + }, + { + name: "op-msg-and-code", + err: errors.New(errors.CheckConstraint, errors.WithOp("alice.bob"), errors.WithMsg("test msg")), + want: "alice.bob: test msg: integrity violation: error #1000", + }, + { + name: "unknown", + err: errors.New(errors.Unknown), + want: "unknown, unknown: error #0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + got := tt.err.Error() + assert.Equal(tt.want, got) + }) + } + t.Run("nil *Err", func(t *testing.T) { + assert := assert.New(t) + var err *errors.Err + got := err.Error() + assert.Equal("", got) + }) +} + +func TestError_Unwrap(t *testing.T) { + t.Parallel() + testErr := errors.New(errors.Unknown, errors.WithMsg("test error")) + + tests := []struct { + name string + err error + want error + wantIsErr error + }{ + { + name: "ErrInvalidParameter", + err: errors.New(errors.InvalidParameter, errors.WithWrap(errors.ErrInvalidParameter)), + want: errors.ErrInvalidParameter, + wantIsErr: errors.ErrInvalidParameter, + }, + { + name: "testErr", + err: testErr, + want: nil, + wantIsErr: testErr, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + err := tt.err.(interface { + Unwrap() error + }).Unwrap() + assert.Equal(tt.want, err) + assert.True(stderrors.Is(tt.err, tt.wantIsErr)) + }) + } + t.Run("nil *Err", func(t *testing.T) { + assert := assert.New(t) + var err *errors.Err + got := err.Unwrap() + assert.Equal(nil, got) + }) +} + +func TestConvertError(t *testing.T) { + t.Parallel() + const ( + createTable = ` + create table if not exists test_table ( + id bigint generated always as identity primary key, + name text unique, + description text not null, + five text check(length(trim(five)) > 5) + ); + ` + truncateTable = `truncate test_table;` + insert = `insert into test_table(name, description, five) values (?, ?, ?)` + missingTable = `select * from not_a_defined_table` + ) + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + + _, err := rw.Exec(ctx, createTable, nil) + require.NoError(t, err) + + tests := []struct { + name string + e error + wantErr error + }{ + { + name: "nil", + e: nil, + wantErr: nil, + }, + { + name: "not-convertible", + e: stderrors.New("test error"), + wantErr: nil, + }, + { + name: "NotSpecificIntegrity", + e: &pq.Error{ + Code: pq.ErrorCode("23001"), + }, + wantErr: errors.New(errors.NotSpecificIntegrity), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + err := errors.Convert(tt.e) + if tt.wantErr == nil { + assert.Nil(err) + return + } + require.NotNil(err) + assert.Equal(tt.wantErr, err) + }) + } + t.Run("ErrCodeUnique", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + _, err := rw.Exec(ctx, truncateTable, nil) + require.NoError(err) + _, err = rw.Exec(ctx, insert, []interface{}{"alice", "coworker", nil}) + require.NoError(err) + _, err = rw.Exec(ctx, insert, []interface{}{"alice", "dup coworker", nil}) + require.Error(err) + + e := errors.Convert(err) + require.NotNil(e) + assert.True(stderrors.Is(e, errors.ErrNotUnique)) + assert.Equal("Key (name)=(alice) already exists.: integrity violation: error #1002: \nunique constraint violation: integrity violation: error #1002", e.Error()) + }) + t.Run("ErrCodeNotNull", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + _, err := rw.Exec(ctx, truncateTable, nil) + require.NoError(err) + _, err = rw.Exec(ctx, insert, []interface{}{"alice", nil, nil}) + require.Error(err) + + e := errors.Convert(err) + require.NotNil(e) + assert.True(stderrors.Is(e, errors.ErrNotNull)) + assert.Equal("description must not be empty: integrity violation: error #1001: \nnot null constraint violated: integrity violation: error #1001", e.Error()) + }) + t.Run("ErrCodeCheckConstraint", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + _, err := rw.Exec(ctx, truncateTable, nil) + require.NoError(err) + _, err = rw.Exec(ctx, insert, []interface{}{"alice", "coworker", "one"}) + require.Error(err) + + e := errors.Convert(err) + require.NotNil(e) + assert.True(stderrors.Is(e, errors.ErrCheckConstraint)) + assert.Equal("test_table_five_check constraint failed: integrity violation: error #1000: \ncheck constraint violated: integrity violation: error #1000", e.Error()) + }) + t.Run("MissingTable", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + _, err := rw.Exec(ctx, missingTable, nil) + require.Error(err) + e := errors.Convert(err) + require.NotNil(e) + assert.True(errors.Match(errors.T(errors.MissingTable), e)) + assert.Equal("relation \"not_a_defined_table\" does not exist: integrity violation: error #1004", e.Error()) + }) +} diff --git a/internal/errors/info.go b/internal/errors/info.go new file mode 100644 index 0000000000..47124ac9a8 --- /dev/null +++ b/internal/errors/info.go @@ -0,0 +1,47 @@ +package errors + +type Info struct { + Kind Kind + Message string +} + +// errorCodeInfo provides a map of unique Codes (IDs) to their +// corresponding Kind and a default Message. +var errorCodeInfo = map[Code]Info{ + Unknown: { + Message: "unknown", + Kind: Other, + }, + InvalidParameter: { + Message: "invalid parameter", + Kind: Parameter, + }, + CheckConstraint: { + Message: "constraint check failed", + Kind: Integrity, + }, + NotNull: { + Message: "must not be empty (null) violation", + Kind: Integrity, + }, + NotUnique: { + Message: "must be unique violation", + Kind: Integrity, + }, + NotSpecificIntegrity: { + Message: "Integrity violation without specific details", + Kind: Integrity, + }, + MissingTable: { + Message: "missing table", + Kind: Integrity, + }, + RecordNotFound: { + Message: "record not found", + Kind: Search, + }, + MultipleRecords: { + Message: "multiple records", + Kind: Search, + }, +} diff --git a/internal/errors/is.go b/internal/errors/is.go new file mode 100644 index 0000000000..6cf39bb409 --- /dev/null +++ b/internal/errors/is.go @@ -0,0 +1,91 @@ +package errors + +import ( + "errors" + + "github.com/lib/pq" +) + +// IsUniqueError returns a boolean indicating whether the error is known to +// report a unique constraint violation. +func IsUniqueError(err error) bool { + if err == nil { + return false + } + + var domainErr *Err + if errors.As(err, &domainErr) { + if domainErr.Code == NotUnique { + return true + } + } + + var pqError *pq.Error + if errors.As(err, &pqError) { + if pqError.Code == "23505" { // unique_violation + return true + } + } + + return false +} + +// IsCheckConstraintError returns a boolean indicating whether the error is +// known to report a check constraint violation. +func IsCheckConstraintError(err error) bool { + if err == nil { + return false + } + + var domainErr *Err + if errors.As(err, &domainErr) { + if domainErr.Code == CheckConstraint { + return true + } + } + + var pqError *pq.Error + if errors.As(err, &pqError) { + if pqError.Code == "23514" { // check_violation + return true + } + } + + return false +} + +// IsNotNullError returns a boolean indicating whether the error is known +// to report a not-null constraint violation. +func IsNotNullError(err error) bool { + if err == nil { + return false + } + + var domainErr *Err + if errors.As(err, &domainErr) { + if domainErr.Code == NotNull { + return true + } + } + + var pqError *pq.Error + if errors.As(err, &pqError) { + if pqError.Code == "23502" { // not_null_violation + return true + } + } + + return false +} + +// IsMissingTableError returns a boolean indicating whether the error is known +// to report a undefined/missing table violation. +func IsMissingTableError(err error) bool { + var pqError *pq.Error + if errors.As(err, &pqError) { + if pqError.Code == "42P01" { + return true + } + } + return false +} diff --git a/internal/errors/is_test.go b/internal/errors/is_test.go new file mode 100644 index 0000000000..af71a970a1 --- /dev/null +++ b/internal/errors/is_test.go @@ -0,0 +1,242 @@ +package errors_test + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestError_IsUnique(t *testing.T) { + t.Parallel() + var tests = []struct { + name string + in error + want bool + }{ + { + name: "nil-error", + in: nil, + want: false, + }, + { + name: "postgres-not-unique", + in: &pq.Error{ + Code: pq.ErrorCode("23503"), + }, + want: false, + }, + { + name: "postgres-is-unique2", + in: &pq.Error{ + Code: pq.ErrorCode("23505"), + }, + want: true, + }, + { + name: "ErrCodeUnique", + in: errors.ErrNotUnique, + want: true, + }, + { + name: "wrapped-pq-is-unique", + in: errors.New(errors.NotUnique, + errors.WithWrap(&pq.Error{ + Code: pq.ErrorCode("23505"), + }), + ), + want: true, + }, + { + name: "ErrRecordNotFound", + in: errors.ErrRecordNotFound, + want: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + err := tt.in + got := errors.IsUniqueError(err) + assert.Equal(tt.want, got) + }) + } +} + +func TestError_IsCheckConstraint(t *testing.T) { + t.Parallel() + var tests = []struct { + name string + in error + want bool + }{ + { + name: "nil-error", + in: nil, + want: false, + }, + { + name: "postgres-not-check-constraint", + in: &pq.Error{ + Code: pq.ErrorCode("23505"), + }, + want: false, + }, + { + name: "postgres-is-check-constraint", + in: &pq.Error{ + Code: pq.ErrorCode("23514"), + }, + want: true, + }, + { + name: "ErrCodeCheckConstraint", + in: errors.New(errors.CheckConstraint), + want: true, + }, + { + name: "wrapped-pq-is-check-constraint", + in: errors.New(errors.CheckConstraint, + errors.WithWrap(&pq.Error{ + Code: pq.ErrorCode("23514"), + }), + ), + want: true, + }, + { + name: "ErrRecordNotFound", + in: errors.ErrRecordNotFound, + want: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + err := tt.in + got := errors.IsCheckConstraintError(err) + assert.Equal(tt.want, got) + }) + } +} + +func TestError_IsNotNullError(t *testing.T) { + t.Parallel() + var tests = []struct { + name string + in error + want bool + }{ + { + name: "nil-error", + in: nil, + want: false, + }, + { + name: "postgres-is-unique-not-not-null", + in: &pq.Error{ + Code: pq.ErrorCode("23505"), + }, + want: false, + }, + { + name: "postgres-is-check-constraint-not-not-null", + in: &pq.Error{ + Code: pq.ErrorCode("23514"), + }, + want: false, + }, + { + name: "postgres-is-not-null", + in: &pq.Error{ + Code: pq.ErrorCode("23502"), + }, + want: true, + }, + { + name: "ErrCodeNotNull", + in: errors.New(errors.NotNull), + want: true, + }, + { + name: "wrapped-pq-is-not-null", + in: errors.New(errors.NotNull, + errors.WithWrap(&pq.Error{ + Code: pq.ErrorCode("23502"), + }), + ), + want: true, + }, + { + name: "ErrRecordNotFound", + in: errors.ErrRecordNotFound, + want: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + err := tt.in + got := errors.IsNotNullError(err) + assert.Equal(tt.want, got) + }) + } +} + +func TestError_IsMissingTableError(t *testing.T) { + var tests = []struct { + name string + in error + want bool + }{ + { + name: "nil-error", + in: nil, + want: false, + }, + { + name: "postgres-is-unique-not-not-null", + in: &pq.Error{ + Code: pq.ErrorCode("23505"), + }, + want: false, + }, + { + name: "postgres-is-check-constraint-not-not-null", + in: &pq.Error{ + Code: pq.ErrorCode("23514"), + }, + want: false, + }, + { + name: "postgres-is-missing-table", + in: &pq.Error{ + Code: pq.ErrorCode("42P01"), + }, + want: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + err := tt.in + got := errors.IsMissingTableError(err) + assert.Equal(tt.want, got) + }) + } + t.Run("query-missing-table", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + _, err := rw.Query(context.Background(), "select * from non_existent_table", nil) + require.Error(err) + assert.True(errors.IsMissingTableError(err)) + }) +} diff --git a/internal/errors/kind.go b/internal/errors/kind.go new file mode 100644 index 0000000000..1e0c92483d --- /dev/null +++ b/internal/errors/kind.go @@ -0,0 +1,20 @@ +package errors + +// Kind specifies the kind of error (unknown, parameter, integrity, etc). +type Kind uint32 + +const ( + Other Kind = iota + Parameter + Integrity + Search +) + +func (e Kind) String() string { + return map[Kind]string{ + Other: "unknown", + Parameter: "parameter violation", + Integrity: "integrity violation", + Search: "search issue", + }[e] +} diff --git a/internal/errors/kind_test.go b/internal/errors/kind_test.go new file mode 100644 index 0000000000..e646879790 --- /dev/null +++ b/internal/errors/kind_test.go @@ -0,0 +1,44 @@ +package errors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKind_String(t *testing.T) { + t.Parallel() + tests := []struct { + name string + e Kind + want string + }{ + { + name: "Other", + e: Other, + want: "unknown", + }, + { + name: "Parameter", + e: Parameter, + want: "parameter violation", + }, + { + name: "Integrity", + e: Integrity, + want: "integrity violation", + }, + { + name: "Search", + e: Search, + want: "search issue", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + got := tt.e.String() + assert.Equal(tt.want, got) + }) + } +} diff --git a/internal/errors/match.go b/internal/errors/match.go new file mode 100644 index 0000000000..359d399822 --- /dev/null +++ b/internal/errors/match.go @@ -0,0 +1,97 @@ +package errors + +// Template is useful constructing Match Err templates. Templates allow you to +// match Errs without specifying a Code. In other words, just Match using the +// Errs: Kind, Op, etc. +type Template struct { + Err // Err embedded to support matching Errs + Kind Kind // Kind allows explicit matching on a Template without a Code. +} + +// T creates a new Template for matching Errs. Invalid parameters are ignored. +// If more than is one parameter for a given type, only the last one is used. +func T(args ...interface{}) *Template { + t := &Template{} + for _, a := range args { + switch arg := a.(type) { + case Code: + t.Code = arg + case string: + t.Msg = arg + case Op: + t.Op = arg + case *Err: // order is important, this match must before "case error:" + c := *arg + t.Wrapped = &c + case error: + t.Wrapped = arg + case Kind: + t.Kind = arg + default: + // ignore it + } + } + return t +} + +// Info about the Template, which is useful when matching a Template's Kind with +// an Err's Kind. +func (t *Template) Info() Info { + if t == nil { + return errorCodeInfo[Unknown] + } + switch { + case t.Code != Unknown: + return t.Code.Info() + case t.Kind != Other: + return Info{ + Message: "Unknown", + Kind: t.Kind, + } + default: + return errorCodeInfo[Unknown] + } +} + +// Error satisfies the error interface but we intentionally don't return +// anything of value, in an effort to stop users from substituting Templates in +// place of Errs, when creating domain errors. +func (t *Template) Error() string { + return "Template error" +} + +// Match the template against the error. The error must be a *Err, or match +// will return false. Matches all non-empty fields of the template against the +// error. +func Match(t *Template, err error) bool { + if t == nil || err == nil { + return false + } + e, ok := err.(*Err) + if !ok { + return false + } + + if t.Code != Unknown && t.Code != e.Code { + return false + } + if t.Msg != "" && t.Msg != e.Msg { + return false + } + if t.Op != "" && t.Op != e.Op { + return false + } + if t.Kind != Other && t.Info().Kind != e.Info().Kind { + return false + } + if t.Wrapped != nil { + if wrappedT, ok := t.Wrapped.(*Template); ok { + return Match(wrappedT, e.Wrapped) + } + if e.Wrapped != nil && t.Wrapped.Error() != e.Wrapped.Error() { + return false + } + } + + return true +} diff --git a/internal/errors/match_test.go b/internal/errors/match_test.go new file mode 100644 index 0000000000..b902ecdc5b --- /dev/null +++ b/internal/errors/match_test.go @@ -0,0 +1,296 @@ +package errors + +import ( + stderrors "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestT(t *testing.T) { + t.Parallel() + stdErr := stderrors.New("test error") + tests := []struct { + name string + args []interface{} + want *Template + }{ + { + name: "all fields", + args: []interface{}{ + "test error msg", + Op("alice.Bob"), + InvalidParameter, + stdErr, + Integrity, + }, + want: &Template{ + Err: Err{ + Code: InvalidParameter, + Msg: "test error msg", + Op: "alice.Bob", + Wrapped: stdErr, + }, + Kind: Integrity, + }, + }, + { + name: "Kind only", + args: []interface{}{ + Integrity, + }, + want: &Template{ + Kind: Integrity, + }, + }, + { + name: "multiple Kinds", + args: []interface{}{ + Search, + Integrity, + }, + want: &Template{ + Kind: Integrity, + }, + }, + { + name: "ignore", + args: []interface{}{ + 32, + }, + want: &Template{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + got := T(tt.args...) + assert.Equal(tt.want, got) + }) + } +} + +func TestTemplate_Info(t *testing.T) { + t.Parallel() + tests := []struct { + name string + template *Template + want Info + }{ + { + name: "nil", + template: nil, + want: errorCodeInfo[Unknown], + }, + { + name: "Code", + template: T(InvalidParameter), + want: errorCodeInfo[InvalidParameter], + }, + { + name: "Code and Kind", + template: T(InvalidParameter, Integrity), + want: errorCodeInfo[InvalidParameter], + }, + { + name: "Kind without Code", + template: T(Integrity), + want: Info{Kind: Integrity, Message: "Unknown"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + assert.Equal(tt.want, tt.template.Info()) + }) + } +} + +func TestTemplate_Error(t *testing.T) { + t.Parallel() + stdErr := stderrors.New("test error") + tests := []struct { + name string + template *Template + }{ + { + name: "Kind only", + template: T(Integrity), + }, + { + name: "all params", + template: T( + "test error msg", + Op("alice.Bob"), + InvalidParameter, + stdErr, + Integrity, + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + got := tt.template.Error() + assert.Equal("Template error", got) + }) + } +} + +func TestMatch(t *testing.T) { + t.Parallel() + stdErr := stderrors.New("test error") + tests := []struct { + name string + template *Template + err error + want bool + }{ + { + name: "nil template", + template: nil, + err: New(NotUnique, WithMsg("this thing was must be unique")), + want: false, + }, + { + name: "nil err", + template: T(Integrity), + err: nil, + want: false, + }, + { + name: "match on Kind only", + template: T(Integrity), + err: New( + NotUnique, + WithMsg("this thing must be unique"), + WithOp("alice.Bob"), + WithWrap(ErrInvalidFieldMask), + ), + want: true, + }, + { + name: "no match on Kind only", + template: T(Integrity), + err: New( + RecordNotFound, + WithMsg("this thing is missing"), + WithOp("alice.Bob"), + WithWrap(ErrInvalidFieldMask), + ), + want: false, + }, + { + name: "match on Code only", + template: T(NotUnique), + err: New( + NotUnique, + WithMsg("this thing must be unique"), + WithOp("alice.Bob"), + WithWrap(ErrInvalidFieldMask), + ), + want: true, + }, + { + name: "no match on Code only", + template: T(NotUnique), + err: New( + RecordNotFound, + WithMsg("this thing is missing"), + WithOp("alice.Bob"), + WithWrap(ErrInvalidFieldMask), + ), + want: false, + }, + { + name: "match on Op only", + template: T(Op("alice.Bob")), + err: New( + NotUnique, + WithMsg("this thing must be unique"), + WithOp("alice.Bob"), + WithWrap(ErrInvalidFieldMask), + ), + want: true, + }, + { + name: "no match on Op only", + template: T(Op("alice.Alice")), + err: New( + RecordNotFound, + WithMsg("this thing is missing"), + WithOp("alice.Bob"), + WithWrap(ErrInvalidFieldMask), + ), + want: false, + }, + { + name: "match on everything", + template: T( + "this thing must be unique", + Integrity, + InvalidParameter, + ErrInvalidFieldMask, + Op("alice.Bob"), + ), + err: New( + InvalidParameter, + WithMsg("this thing must be unique"), + WithOp("alice.Bob"), + WithWrap(ErrInvalidFieldMask), + ), + want: true, + }, + { + name: "match on Wrapped only", + template: T(ErrInvalidFieldMask), + err: New( + NotUnique, + WithMsg("this thing must be unique"), + WithOp("alice.Bob"), + WithWrap(ErrInvalidFieldMask), + ), + want: true, + }, + { + name: "no match on Wrapped only", + template: T(ErrNotUnique), + err: New( + RecordNotFound, + WithMsg("this thing is missing"), + WithOp("alice.Bob"), + WithWrap(ErrInvalidFieldMask), + ), + want: false, + }, + { + name: "match on Wrapped only stderror", + template: T(stdErr), + err: New( + NotUnique, + WithMsg("this thing must be unique"), + WithOp("alice.Bob"), + WithWrap(stdErr), + ), + want: true, + }, + { + name: "no match on Wrapped only stderror", + template: T(stderrors.New("no match")), + err: New( + RecordNotFound, + WithMsg("this thing is missing"), + WithOp("alice.Bob"), + WithWrap(stdErr), + ), + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + got := Match(tt.template, tt.err) + assert.Equal(tt.want, got) + }) + } +} diff --git a/internal/errors/option.go b/internal/errors/option.go new file mode 100644 index 0000000000..469939ece2 --- /dev/null +++ b/internal/errors/option.go @@ -0,0 +1,48 @@ +package errors + +// GetOpts - iterate the inbound Options and return a struct. +func GetOpts(opt ...Option) Options { + opts := getDefaultOptions() + for _, o := range opt { + o(&opts) + } + return opts +} + +// Option - how Options are passed as arguments. +type Option func(*Options) + +// Options - how Options are represented. +type Options struct { + withErrWrapped error + withErrMsg string + withOp Op +} + +func getDefaultOptions() Options { + return Options{} +} + +// WithErrCode provides an option to provide an error to wrap when creating a +// new error. +func WithWrap(e error) Option { + return func(o *Options) { + o.withErrWrapped = e + } +} + +// WithMsg provides an option to provide a message when creating a new +// error. +func WithMsg(msg string) Option { + return func(o *Options) { + o.withErrMsg = msg + } +} + +// WithOp provides an option to provide the operation that's raising/propagating +// the error. +func WithOp(op Op) Option { + return func(o *Options) { + o.withOp = op + } +} diff --git a/internal/errors/option_test.go b/internal/errors/option_test.go new file mode 100644 index 0000000000..c9987b6b79 --- /dev/null +++ b/internal/errors/option_test.go @@ -0,0 +1,51 @@ +package errors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Test_getOpts provides unit tests for GetOpts and all the options +func Test_getOpts(t *testing.T) { + t.Parallel() + t.Run("WithMsg", func(t *testing.T) { + assert := assert.New(t) + // test default + opts := GetOpts() + testOpts := getDefaultOptions() + testOpts.withErrMsg = "" + assert.Equal(opts, testOpts) + + // try setting it + opts = GetOpts(WithMsg("test msg")) + testOpts.withErrMsg = "test msg" + assert.Equal(opts, testOpts) + }) + t.Run("WithWrap", func(t *testing.T) { + assert := assert.New(t) + // test default + opts := GetOpts() + testOpts := getDefaultOptions() + testOpts.withErrWrapped = nil + assert.Equal(opts, testOpts) + + // try setting it + opts = GetOpts(WithWrap(ErrInvalidParameter)) + testOpts.withErrWrapped = ErrInvalidParameter + assert.Equal(opts, testOpts) + }) + t.Run("WithOp", func(t *testing.T) { + assert := assert.New(t) + // test default + opts := GetOpts() + testOpts := getDefaultOptions() + testOpts.withOp = "" + assert.Equal(opts, testOpts) + + // try setting it + opts = GetOpts(WithOp("alice.bob")) + testOpts.withOp = "alice.bob" + assert.Equal(opts, testOpts) + }) +} diff --git a/internal/errors/sentinels.go b/internal/errors/sentinels.go new file mode 100644 index 0000000000..30dc9bf79a --- /dev/null +++ b/internal/errors/sentinels.go @@ -0,0 +1,44 @@ +package errors + +// Errors returned from this package may be tested against these errors +// with errors.Is. Creating new Sentinel type errors like these should be +// deprecated in favor of the new Err type that includes unique Codes and a +// Matching function. +var ( + // ErrInvalidPublicId indicates an invalid PublicId. + ErrInvalidPublicId = New(InvalidParameter, WithMsg("invalid publicId")) + + // ErrInvalidParameter is returned by create and update methods if + // an attribute on a struct contains illegal or invalid values. + ErrInvalidParameter = New(InvalidParameter, WithMsg("invalid parameter")) + + // ErrInvalidFieldMask is returned by update methods if the field mask + // contains unknown fields or fields that cannot be updated. + ErrInvalidFieldMask = New(InvalidParameter, WithMsg("invalid field mask")) + + // ErrEmptyFieldMask is returned by update methods if the field mask is + // empty. + ErrEmptyFieldMask = New(InvalidParameter, WithMsg("empty field mask")) + + // ErrNotUnique is returned by create and update methods when a write + // to the repository resulted in a unique constraint violation. + ErrNotUnique = New(NotUnique, WithMsg("unique constraint violation")) + + // ErrNotNull is returned by methods when a write to the repository resulted + // in a check constraint violation + ErrCheckConstraint = New(CheckConstraint, WithMsg("check constraint violated")) + + // ErrNotNull is returned by methods when a write to the repository resulted + // in a not null constraint violation + ErrNotNull = New(NotNull, WithMsg("not null constraint violated")) + + // ErrRecordNotFound returns a "record not found" error and it only occurs + // when attempting to read from the database into struct. + // When reading into a slice it won't return this error. + ErrRecordNotFound = New(RecordNotFound, WithMsg("record not found")) + + // ErrMultipleRecords is returned by update and delete methods when a + // write to the repository would result in more than one record being + // changed resulting in the transaction being rolled back. + ErrMultipleRecords = New(MultipleRecords, WithMsg("multiple records")) +)